一、前序
近兩年AI發展太過迅速,各類AI產品層出不窮,AI繪圖/AI工作流/AI視頻等平臺的蓬勃發展,促使圖片/視頻等復雜內容的創作更加簡單,讓更多普通人有了圖片和視頻創作的機會。另一方面用戶內容消費也逐漸向圖片和視頻傾斜。在“需求增長”和“工具簡化”兩者作用下,必然會帶來繁榮的產業產品,因此也給前端提供了更多施展技術的機會和平臺。
在上面提到的發展中,我相信多數前端將會面對的是一個全新產品形態,沒有相應開發經驗,圖片編輯/視頻編輯甚至AI開發。缺少核心技術的儲備和項目經驗,這在產品研發過程中,是非常致命的,這會導致很多非預期的問題產生,例如:方案的可行性評估準確性、調研設計效率、系統兼容問題評估、風險評估、突發問題的解決效率、性能問題等等。
為了解決這種困境,就需要系統了解技術方案實現,掌握整個功能鏈路上的技術核心,才能從頭到尾準確的評估技術可行性,能力邊界,風險預案,最優場景應用。
二、本文主要內容
內容篇幅較多,因此分兩篇講解,從基礎到框架細致入微的講解實現圖片編輯器技術方案,本文為第一篇,Canvas基礎講解,核心內容包括如下,區分難度標注,方便不同熟練度的讀者省略
- 名詞解釋,對涉及到的名詞解釋說明,幫助熟悉概念
- Canvas 是什么,能做什么,有什么特點(局限性)(基礎沒有深度)
- 簡單的一個Canvas使用(基礎沒有深度)
- Canvas常見的API和使用(部分有深度,按需查看,不要過多浪費時)
- Canvas的事件(開始深入)
- 正確理解Canvas的寬高,canvas.width/height 和 style.width/height(開始深入)
- 理解DPR和像素和scale的互相影響(深入)
- Canvas中的坐標與定位以及Canvas像素操作以及在DPR影響下像素定位(深入)
- Canvas性能優化(優化&深入)
- Canvas原理,底層機制和顏色擴散問題(深入)
- Canvas開發實踐中的問題(經驗總結)
三、名詞解釋
3.1 抗鋸齒(Anti-aliasing)
由于在圖像中,受分辨的制約,物體邊緣總會或多或少的呈現三角形的鋸齒,而抗鋸齒就是指對圖像邊緣進行柔化處理,使圖像邊緣看起來更平滑,更接近實物的物體
3.2 像素密度 PPI(pixels per inch)
即每英寸所擁有的像素數目,表示的是每英寸對角線上所擁有的像素(pixel)數目
- 公式:
PPI=√(X^2+Y^2)/ Z
(X:長度像素數;Y:寬度像素數;Z:屏幕大小(英寸),對角線長度)。 - 舉例:小米2屏幕的PPI,4.3英寸、分辨率1280*720,
PPI:PPI=√(1280^2+720^2)/4.3=341.5359……≈342
3.3 每英寸點數 DPI(Dots Per Inch)
最初用于印刷行業,指打印機每英寸能噴墨的物理墨點數,因為經常與PPI混用,有時也用來表示PPI的含義,嚴格來說,DPI 屬于輸出設備(如打印機),PPI 屬于輸入設備(如屏幕)
- 300 DPI 的打印機,每英寸打印 300 個墨點
3.4 位圖 (Bitmap)也 叫 柵格圖像 (Raster Graphic)
- 位圖是一種基于像素的圖像格式。它將圖像分解成一個由無數個微小方塊(像素)組成的網格,每個像素都有自己的顏色信息
- 文件大小與圖像尺寸和顏色深度成正比;放大時失真;適用照片、掃描圖像
這里有個容易歧義的地方,就是顏色深度影響圖片大小,顏色深度是指每個像素用來存儲顏色信息所需的位數或字節數(24 位 RGB 意味著每個像素用3個字節存儲紅、綠、藍分量,每個分量 8 位,范圍是 0-255)
- 對于一個未壓縮的位圖(BMP格式),其大小主要由三個因素決定:
寬度 * 高度 * 顏色深度
;在原始數據層面,無論一個像素是純黑 (0,0,0) 還是純白 (255,255,255),只要顏色深度相同,存儲這個像素所需的字節數是完全一樣的。一個全黑100x100像素的24位 BMP和一個全白 100x100像素的24位BMP,它們的原始像素數據大小完全一樣100 * 100 * 3 字節 = 30000 字節
實際圖像文件大小
- 我們一般看到的圖片都是經過壓縮的。文件大小是原始像素數據經過壓縮后,再加上一些文件頭信息,壓縮算法是影響文件大小的關鍵。所以說我們常見的兩張圖片寬高一定,但是大小卻不相同。
四、Canvas 是什么,能做什么,有什么特點
4.1 定義
Canvas是一個 HTML 元素,是一個通用的繪圖區域,通過 <canvas>
標簽在網頁上創建一個空白的畫布。它本身只提供繪圖環境容器,不直接定義繪圖方式。實際繪圖需要通過 JavaScript 調用其 繪圖上下文 實現。
這個定義很重要,很多人可能理解Canvas就是繪制能力的提供方。但是實際上它只是畫布容器,繪制由繪圖上下文實現,當你看webgl使用時,就可以更加清晰的理解這個定義。
它本質上是一個位圖(Bitmap) 或者叫 柵格圖像(Raster Graphic) ,是一塊你在內存中開辟出來的二維像素數組。Canvas API 的各類方法,實際上就是在修改這個像素數組中的顏色值**(看到后面的像素操作和渲染原理,你就理解這里了)
Canvas 采用即時模式渲染,Canvas調用API會立即執行這些繪制命令,并直接修改其內部的像素數據
SVG 采用保留模式,SVG會構建一個場景圖,記住創建的每個元素。你可以隨時修改這些元素的屬性,瀏覽器會自動計算并更新受影響區域
// 獲取Canvas元素
const canvas = document.getElementById('glCanvas');
// 嘗試獲取WebGL上下文
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
4.2 用途
- 繪制圖表、數據可視化(如折線圖、餅圖)
- 開發 2D 游戲或動畫
- 圖像處理(如濾鏡、裁剪、拼接)
- 實現繪圖工具(如在線白板)
- 可以視頻播放,但較少有這樣用(編碼問題/高清性能/跨域問題等)
4.3 特點
- 基于像素的繪圖,適合動態渲染
- 需要 JavaScript 操作 API(如繪制路徑、形狀、文本)
- Canvas基于像素的即時渲染,每次重繪要重新計算并覆蓋整個畫布
- 大尺寸或高分辨率圖像占用較多內存
4.4 局限性
- 大量元素或復雜動畫,有性能問題
- 繪制圖片有跨域限制(造成畫布無法,導致部分api異常)
- 無法直接操作單個元素,畫布是一個整體,無法像DOM那樣單獨修改、刪除
- 事件處理困難,只能監聽整個畫布的點擊、移動事件,無法自動識別點擊的是哪個圖形
- 狀態管理困難,所有元素的坐標、狀態(如顏色、位置)開發者自己記錄/處理
- 可訪問性差,chrome開發工具,無障礙工具,無法獲取canvas內容
- 縮放模糊像素失真,放大時會模糊(尤其在高 DPI 屏幕下)
- 文本渲染能力有限,無法直接實現行文本換行、首行縮進。字體加載延遲,自定義字體需確保加載完成,否則顯示默認字體
- 跨瀏覽器兼容性,圖像混合模式、濾鏡等API表現不一致
五、Canvas 基礎使用示例
Canvas的基礎使用可以從下面四大類總結,基本上對canvas應用都在這個范圍內,比如圖片編輯器本質上是Canvas的繪制過程。
- 創建和生成:創建和基本API調用
- 基礎繪制:顏色/形狀繪制相關API
- 數據轉換:canvas轉成blob二進制數據或轉成base64
- 像素操作:像素讀取和改寫
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>canvas {display: block;width: 600px;height: 300px;background-color: #f5f5f5;}</style>
</head><body><canvas id="myCanvas"></canvas><div><button id="btnFillBg">填充背景</button><button id="btnExportBase64">導出Base64</button><button id="btnExportBlob">導出Blob</button><button id="btnPrintPixels">打印像素信息</button></div><script>// 獲取Canvas元素和上下文const canvas = document.getElementById('myCanvas');const ctx = canvas.getContext('2d');// 初始化Canvas尺寸(適配高DPI屏幕)function initCanvasSize() {// 獲取CSS設置的顯示尺寸const displayWidth = canvas.clientWidth;const displayHeight = canvas.clientHeight;// 獲取設備像素比(DPI縮放因子)const dpr = window.devicePixelRatio || 1;console.log('設備像素比:', dpr);// 設置Canvas的實際像素尺寸canvas.width = displayWidth * dpr;canvas.height = displayHeight * dpr;// 縮放繪圖上下文以匹配CSS尺寸ctx.scale(dpr, dpr);}// 填充背景色function fillBackground() {// 保存當前狀態ctx.save();// 重置縮放(使用CSS尺寸)ctx.setTransform(1, 0, 0, 1, 0, 0);// 填充背景ctx.fillStyle = '#e0f7fa';ctx.fillRect(0, 0, canvas.width, canvas.height);// 恢復縮放ctx.restore();// 重新繪制內容drawShapes();}// 繪制基本圖形function drawShapes() {// 繪制矩形ctx.fillStyle = '#2196F3';ctx.fillRect(20, 20, 100, 80);// 繪制圓形ctx.beginPath();ctx.arc(200, 60, 40, 0, Math.PI * 2);ctx.fillStyle = '#FF5722';ctx.fill();// 繪制文本ctx.font = '20px Arial';ctx.fillStyle = '#333';ctx.fillText('Canvas 示例', 20, 150);// 繪制路徑ctx.beginPath();ctx.moveTo(300, 20);ctx.lineTo(350, 80);ctx.lineTo(250, 80);ctx.closePath();ctx.strokeStyle = '#4CAF50';ctx.lineWidth = 3;ctx.stroke();}// 導出為Base64function exportToBase64() {const exportCanvas = document.createElement('canvas');const exportCtx = exportCanvas.getContext('2d');// 設置導出Canvas的尺寸為實際像素尺寸exportCanvas.width = canvas.width;exportCanvas.height = canvas.height;// 繪制內容到臨時CanvasexportCtx.drawImage(canvas, 0, 0);// 獲取Base64數據(PNG格式)const base64 = exportCanvas.toDataURL('image/png');console.log('Base64 數據:', base64);}// 導出為Blobfunction exportToBlob() {canvas.toBlob(function (blob) {// 創建下載鏈接const url = URL.createObjectURL(blob);console.log('Blob:', blob);console.log('Blob URL:', url);}, 'image/png');}// 打印像素數量function printPixelInfo() {// 獲取整個Canvas的像素數據const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);const data = imageData.data; // 包含RGBA值的Uint8ClampedArray// 計算中心點位置const centerX = Math.floor(canvas.width / 2);const centerY = Math.floor(canvas.height / 2);// 獲取中心點RGBA值(每個像素占4個數組元素)const pixelPos = (centerY * canvas.width + centerX) * 4;const r = data[pixelPos]; // 紅色 (0-255)const g = data[pixelPos + 1]; // 綠色const b = data[pixelPos + 2]; // 藍色const a = data[pixelPos + 3]; // 透明度 (0-255)// 構建并顯示信息let info = `=== Canvas像素信息 ===\n`;info += `顯示尺寸: ${canvas.clientWidth} × ${canvas.clientHeight} px\n`;info += `實際像素: ${canvas.width} × ${canvas.height} px\n`;info += `設備像素比(DPR): ${window.devicePixelRatio}\n`;info += `中心點(${centerX},${centerY}) RGBA: ${r},${g},${b},${a}`;info += `二進制數據長度: ${data.length} bytes\n`;info += `像素數量: ${data.length / 4} pixels\n`;console.log(info);}// 初始化Canvas尺寸initCanvasSize();// 綁定按鈕事件document.getElementById('btnFillBg').addEventListener('click', fillBackground);document.getElementById('btnExportBase64').addEventListener('click', exportToBase64);document.getElementById('btnExportBlob').addEventListener('click', exportToBlob);document.getElementById('btnPrintPixels').addEventListener('click', printPixelInfo);// 初始繪制drawShapes();</script>
</body></html>
六、Canvas 常見API和使用
6.1 創建API
// html標簽
<canvas id="tutorial" width="150" height="150"></canvas>const canvas = document.getElementById('tutorial');// canvas 2d上下文(常見創建)
const ctx = canvas.getContext('2d');// 嘗試獲取WebGL上下文(webgl創建)
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
6.2 繪制API
6.2.1 實例屬性API
屬性有很多,這里只說明一些有特殊使用性,或是不好理解的屬性,全部屬性看下面鏈接
https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
6.2.1.1、globalCompositeOperation 控制繪制形狀組合疊加表現,默認 source-over
- "source-over"這是默認設置,在現有畫布上繪制新圖形
- “source-in” 僅在新形狀和目標畫布重疊的地方繪制新形狀,其他的都是透明的
- “source-out” 在不與現有畫布內容重疊的地方繪制新圖形
- "source-atop"只在與現有畫布內容重疊的地方繪制新圖形
- “xor” 形狀在重疊處變為透明,并在其他地方正常繪制
MDN示意效果
6.2.1.2、imageSmoothingEnabled 對縮放后圖片進行平滑處理,默認 true
MDN示意效果
6.2.1.3、imageSmoothingQuality 設置圖像平滑度,默認是低。
要使此屬性生效,上面的imageSmoothingEnabled屬性必須為 true
左低右高,左邊鋸齒感嚴重,右邊更平滑
6.2.1.4、各類字體屬性
有了這些屬性,我們就可以用canvas繪制各類字體相關內容
- font
- fontKerning
- fontStretch
- fontVariantCaps
6.2.1.5、文本字體布局屬性
有了布局屬性,可以利用canvas實現富文本內容繪制,文字排版等
- textAlign 對齊方式
- textBaseline 文字基線
- textRendering 文字渲染引擎優化
- wordSpacing 單詞間距
- direction 文字方向
- letterSpacing 字母間距
6.2.1.6、線條屬性
有了線條繪制屬性,那我們就可以繪制各類形狀
- lineCap 線頭部形狀
- lineDashOffset 線偏移量
- lineJoin 2個線段如何連接在一起
- lineWidth 線寬
- miterLimit 斜線限制比例
6.2.1.7、著色/濾鏡/陰影/透明度屬性
有了顏色/濾鏡/陰影/透明度,那我們可以繪制各種顏色內容
- fillStyle 著色
- filter 濾鏡
- shadowBlur 模糊效果程度
- shadowColor 陰影顏色
- shadowOffsetX 陰影偏移
- shadowOffsetY 陰影偏移
- globalAlpha 透明度
6.2.2 實例方法API
6.2.2.1、路徑繪制方法
- beginPath() 開始一個新的路徑
- closePath() 閉合當前路徑,從當前點到起點畫一條直線
- moveTo(x, y) 將畫筆移動到指定坐標
- lineTo(x, y) 從當前點到指定點畫一條直線
- arc(x, y, radius, startAngle, endAngle, anticlockwise) 繪制圓弧路徑
- arcTo(x1, y1, x2, y2, radius) 通過控制點和半徑繪制圓弧路徑
- ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise) 繪制橢圓路徑
- rect(x, y, width, height) 繪制矩形路徑
- roundRect(x, y, width, height, radii) 繪制圓角矩形路徑
- quadraticCurveTo(cp1x, cp1y, x, y) 繪制二次貝塞爾曲線
- bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 繪制三次貝塞爾曲線
beginPath 和 closePath,可以粗淺理解為畫筆落筆和提筆。beginPath 和 closePath 之間是閉合的作用域。
- 將下面的 beginPath 和 closePath 分別注釋進行測試,基本上就能理解兩個API作用。
<canvas id="myCanvas" width="600" height="300"></canvas>
<script>const canvas = document.getElementById('myCanvas');const ctx = canvas.getContext('2d');// 第一個三角形ctx.beginPath(); ctx.moveTo(50, 50);ctx.lineTo(150, 50);ctx.lineTo(100, 150);ctx.closePath(); ctx.fillStyle = 'red';ctx.fill();// 第二個圓形ctx.beginPath(); ctx.arc(200, 100, 30, 0, Math.PI * 2);ctx.strokeStyle = 'blue';ctx.stroke();
</script>
6.2.2.2、填充與描邊方法
- fill() 填充當前路徑
- stroke() 描邊當前路徑
- fillRect(x, y, width, height) 直接填充矩形
- strokeRect(x, y, width, height) 直接描邊矩形
- fillText(text, x, y [, maxWidth]) 填充文本
- strokeText(text, x, y [, maxWidth]) 描邊文本
6.2.2.3、圖像操作方法
- drawImage(image, dx, dy [, dWidth, dHeight]) 繪制圖像
- createImageData(width, height) 創建新的空白ImageData對象
- getImageData(sx, sy, sw, sh) 獲取畫布指定區域的像素數據
- putImageData(imageData, dx, dy) 將像素數據放回畫布
6.2.2.4、漸變與圖案
- createLinearGradient(x0, y0, x1, y1) 創建線性漸變
- createRadialGradient(x0, y0, r0, x1, y1, r1) 創建徑向漸變
- createConicGradient(startAngle, x, y) 創建錐形漸變
- createPattern(image, repetition) 創建基于圖像的圖案
6.2.2.5、變換方法
- translate(x, y) 移動畫布原點
- rotate(angle) 旋轉畫布
- scale(x, y) 縮放畫布
- transform(a, b, c, d, e, f) 應用變換矩陣
- setTransform(a, b, c, d, e, f) 重置并應用變換矩陣
- resetTransform() 重置所有變換
- getTransform() 獲取當前變換矩陣
6.2.2.6、狀態管理
- save() 保存當前狀態到棧中
- restore() 從棧中恢復之前保存的狀態
- reset() 重置畫布所有狀態(實驗性功能)
應用場景總結
- 臨時修改顏色
- 批量繪制文本
- 動畫中獨立狀態
- 嵌套變換,旋轉與縮放
Canvas.save() 和 Canvas.restore()使用
每個 canvas 的 context 都包含一個保存繪畫狀態的棧,下內容都屬于繪畫狀態:
- 當前的 transformation matrix(變換矩陣)
- 當前的裁剪區域
- strokeStyle、fillStyle、globalAlpha、lineWidth、lineCap、lineJoin、miterLimit、shadowOffsetX、shadowOffsetY、shadowBlur、shadowColor、globalCompositeOperation、font、textAlign、textBaseline這些屬性的當前值
從下圖和代碼中,我們知道,fillStyle這個顏色每次save后被入棧,當restore后會將新的棧頂信息作為當前繪制配置,常用來
臨時修改繪圖狀態(顏色、樣式隔離)
restore出棧1次,當前顏色回到藍色,restore出棧2次,當前顏色回到綠色
function canvasStore(id) {var canvas;var ctx;canvas = document.getElementById(id);ctx = canvas.getContext("2d");draw();function draw() {// 畫了一個紅色矩形ctx.fillStyle = '#ff0000';ctx.fillRect(0, 0, 15, 150);// 把當前狀態推入棧中,我們叫他狀態1(紅色相關屬性)ctx.save();// 畫了一個綠色矩形ctx.fillStyle = '#00ff00';ctx.fillRect(30, 0, 30, 150);// 把當前狀態推入棧中,我們叫他狀態2(綠色相關屬性)ctx.save();// 畫一個藍色色矩形ctx.fillStyle = '#0000ff';ctx.fillRect(90, 0, 45, 150);// 把當前狀態推入棧中,我們叫他狀態3(藍色相關屬性)ctx.save();// 我們第一次取出狀態棧的頂部狀態,那么當前繪制狀態就是狀態3了,即藍色相關屬性ctx.restore();// 我們再次取出狀態棧的頂部狀態,那么當前繪制狀態就是狀態2了,即綠色相關屬性ctx.restore();// 當前狀態是狀態3那么繪制的就是藍色,如果當前狀態是狀態2那么繪制的就是綠色ctx.beginPath();ctx.arc(185, 75, 22, 0, Math.PI * 2, true);ctx.closePath();ctx.fill();}
}
批量修改顏色,然后在恢復開始顏色
<canvas id="canvas5" width="300" height="150"></canvas>
<script>const canvas = document.getElementById('canvas5');const ctx = canvas.getContext('2d');// 批量文本數據const texts = ["Apple", "Banana", "Cherry", "Date", "Fig"];ctx.fillStyle = 'green';// 入棧green顏色ctx.save();ctx.fillStyle = 'blue';ctx.font = '16px Arial';texts.forEach((text, index) => {ctx.fillText(text, 20, 30 + index * 25);});// 出棧恢復顏色樣式ctx.restore();// 繼續使用原有樣式繪制其他內容ctx.fillText('綠色顏色', 70, 100);
</script>
雙圖形動畫
<canvas id="canvas4" width="300" height="100"></canvas>
<script>const canvas = document.getElementById('canvas4');const ctx = canvas.getContext('2d');let angle1 = 0, angle2 = 0;function animate() {ctx.clearRect(0, 0, canvas.width, canvas.height);// 第一個方塊(左側)ctx.save();ctx.translate(50, 50);ctx.rotate(angle1);ctx.fillStyle = 'purple';ctx.fillRect(-25, -25, 50, 50);ctx.restore();// 第二個方塊(右側)ctx.save();ctx.translate(150, 50);ctx.rotate(angle2);ctx.fillStyle = 'cyan';ctx.fillRect(-25, -25, 50, 50);ctx.restore();angle1 += 0.02;angle2 += 0.03;requestAnimationFrame(animate);}animate();
</script>
6.2.2.7、其他方法
- clip() 將當前路徑作為裁剪區域
- clearRect(x, y, width, height) 清除指定矩形區域
- measureText(text) 測量文本的寬度
- setLineDash(segments) 設置虛線模式
- getLineDash() 獲取當前虛線模式
- isPointInPath(x, y) 檢查點是否在當前路徑內
- isPointInStroke(x, y) 檢查點是否在路徑描邊上
- drawFocusIfNeeded(element) 為元素繪制焦點環(可訪問性)
- getContextAttributes() 獲取上下文屬性
- isContextLost() 檢查上下文是否丟失
七、Canvas 事件
前面我們已經提到Canvas比較特殊,不同于DOM 或 SVG,Canvas 他們有本質的不同。SVG 和 DOM 都有獨立的 DOM 節點,可以直接添加事件監聽器。而 Canvas 是一個畫布容器,所有繪制都融合在一起,瀏覽器無法識內部的獨立圖形元素,所以沒法針對形狀添加事件。
基本實現流程
- 綁定 Canvas 事件:在 Canvas 上監聽所需事件
- 坐標轉換:將事件坐標轉換為 Canvas 坐標
- 檢測:檢測碰撞確定點擊圖形
- 觸發處理函數:執行對應圖形的事件處理函數
這個流程并不完善只能處理簡單內容,下面會講解系統事件處理
7.1 與DOM關鍵區別
- SVG:每個圖形是 DOM 節點,支持直接事件綁定
- Canvas:整個畫布是一個整體,內部圖形無法直接綁定事件
7.2 基本事件支持
Canvas 元素本身可以響應所有標準的 DOM 事件,但需要通過額外處理才能識別內部圖形的事件(坐標計算)
- 鼠標事件:click, dblclick, mousedown, mouseup, mousemove, mouseover, mouseout, mouseenter, mouseleave
- 觸摸事件:touchstart, touchmove, touchend
- 鍵盤事件:keydown, keyup, keypress (需要 Canvas 獲取焦點)
- 其他事件:contextmenu, wheel
7.3 簡單坐標定位
// 除了直接給canvas增加事件還可以在外層套一個DOM添加事件
function getCanvasMousePos(canvas, evt) {const rect = canvas.getBoundingClientRect();return {x: evt.clientX - rect.left,y: evt.clientY - rect.top};
}canvas.addEventListener('mousemove', function(evt) {const mousePos = getCanvasMousePos(canvas, evt);console.log('Mouse position:', mousePos.x, mousePos.y);
}, false);
7.4 碰撞檢測
7.4.1 方法一:使用 isPointInPath
- 利用
isPointInPath()
方法,可以檢測點是否在當前路徑中 - 只能檢測當前路徑,無法回溯已繪制的圖形
// 繪制一個矩形
ctx.beginPath();
ctx.rect(50, 50, 100, 80);
ctx.fillStyle = 'blue';
ctx.fill();// 檢測點擊
canvas.addEventListener('click', function(evt) {const pos = getCanvasMousePos(canvas, evt);if (ctx.isPointInPath(pos.x, pos.y)) {console.log('Clicked on the rectangle!');}
}, false);
7.4.2 方法二:數學幾何檢測
- 對于簡單圖形(矩形、圓形等),可以使用數學方法檢測
// 檢測點是否在矩形內
function isPointInRect(x, y, rect) {return x >= rect.x && x <= rect.x + rect.width &&y >= rect.y && y <= rect.y + rect.height;
}// 檢測點是否在圓內
function isPointInCircle(x, y, circle) {const dx = x - circle.x;const dy = y - circle.y;return dx * dx + dy * dy <= circle.radius * circle.radius;
}
7.4.3 方法三:射線法(PNPoly算法)
對于復雜多邊形,可以使用射線法判斷點是否在多邊形內部
function isPointInPolygon(point, polygon) {let inside = false;for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {const xi = polygon[i].x, yi = polygon[i].y;const xj = polygon[j].x, yj = polygon[j].y;const intersect = ((yi > point.y) != (yj > point.y))&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);if (intersect) inside = !inside;}return inside;
}
八、真正理解Canvas寬高和換算
8.1 Canvas的寬高有2種形式
- canvas.width/canvas.height 屬性寬高
- canvas.style.width/canvas.style.height 樣式寬高
8.1.1 屬性形式canvas.height/canvas.width
canvas.height
未被定義或定義為一個無效值(如負值)時,將使用150作為它的默認值。canvas.width
未被定義或定義為一個無效值(如負值)時,將使用300作為它的默認值。
canvas.height 和 canvas.width 決定的了Canvas的像素數量
8.1.2 屬性形式style.width/style.height
屬于DOM的樣式,默認跟隨上面的width/height。最終決定了canvas在DOM流中寬高布局
8.1.3 通過四組數據理解二者區別
我們對Canvas設置不同寬高,繪制(0,0)到(100,100)的一個線條,進而分析兩種寬高的表現差異,來驗證幾個結論。下述代碼都是基于dpr=1的情況。
<canvas id="diagonal1" style="border: 1px solid" width="100px" height="100px"></canvas>
<canvas id="diagonal2" style="border: 1px solid; width: 200px; height: 200px" width="100px" height="100px"></canvas>
<canvas id="diagonal3" style="border: 1px solid; width: 200px; height: 200px"></canvas>
<canvas id="diagonal4" style="border: 1px solid"></canvas>function canvasWidthDemo() {function drawDiagonal(id) {var canvas = document.getElementById(id);var context = canvas.getContext("2d");context.beginPath();context.moveTo(0, 0);context.lineTo(100, 100);context.stroke();const pixCount = context.getImageData(0,0,canvas.width,canvas.height);// 像素數量2中計算方式,比對是否一致// canvas.width * canvas.height// context.getImageData / 4 上面提到過1個像素由數組四位組成(rgba),getImageData.data 包含RGBA值的Uint8ClampedArray,因此除以4為像素數量。console.log('像素數量1:' + (pixCount.data.length / 4));console.log('像素數量2:' + (canvas.width * canvas.height));// 像素數量1:10000// 像素數量1:10000// 像素數量1:45000// 像素數量1:45000}window.onload = function () {// width/height = 100/100// style.width/style.height = 100/100drawDiagonal("diagonal1");// width/height = 100/100// style.width/style.height = 200/200drawDiagonal("diagonal2");// width/height 未設置默認 300/150// style.width/style.height = 200/200drawDiagonal("diagonal3");// width/height 未設置默認 300/150// style.width/style.height 未設置,跟隨width/heightdrawDiagonal("diagonal4");};
}canvasWidthDemo();
四種方案
- 第一種:二者相等,這種沒什么問題基本符合預期,作為基準參考。
- 第二種:二者不等但是比例一致,canvas.width/canvas.height為100/100,style.width/style.height 為 200/200。
- 第三種:設置style.width/style.height 200/200,canvas.width/canvas.height 未設置為默認值 300/150。
- 第四種:style.width/style.height 和 canvas.width/canvas.height均未設置,那么style的值以canvas.width/canvas.height為準300/150。
測試結論
- 結論1: style.width/style.height 最終決定了canvas在DOM流中寬高布局。參考二畫布在DOM布局中占據寬高是200px。
- 結論2: canvas繪制行為是基于畫布大小進行繪制,畫布內像素位置坐標不受style影響(參考方案1和方案2,從0,0 到 100,100始終是對角線,而且從方案1到方案2的像素數量不變,因為canvas.width/canvas.height 沒變都是100)。
- 結論3: style指定DOM布局大小,會導致畫布按比例縮放。(那我們此時可以有個概念:DOM布局大小和畫布大小的概念區分)
- 結論4: 基于上面不變和縮放的結論,結合方案3我們可以有計算公式,300/200 = 100/x,x=66.67;150/200=100/y; y=133.33;求出x,y實際坐標,100是繪制的點位坐標。
- 結論5: 最后我們在看一下像素點數據,方案1和方案2均是10000個,方案3和方案4均是45000個,由此可知像素點數是基于canvas.width和canvas.height得到,而不是style.width和style.height。
- 結論6: style.width和style.height會拉伸單個畫布像素在DOM布局中占據空間大小,但不會改變像素數量。
九、理解DPR和像素關系
9.1 定義和說明
代碼中的一個像素(獨立像素),原本只需要一個屏幕的一個發光單元(物理像素),一對一關系。但隨著顯示器的發展,我們有了更小尺寸的發光單位。
Device Pixel Ratio(dpr):指當前顯示設備的物理像素與邏輯像素之比。這里容易有歧義,這個定義沒有明確歷史背景,導致在面積緯度進行像素數量計算時感覺和定義不符合(面積緯度像素數量倍數關系變為平方倍數 2 => 4; 3 => 9)。
DPR 最初的定義是為了描述每個邏輯像素在單行或單列上占據多少物理像素 。無論DPR是多少,代碼樣式/JS中的像素都是邏輯像素。
DPR=2 表示:1 個邏輯像素的寬度 = 2 個物理像素的寬度;1 個邏輯像素的高度 = 2 個物理像素的高度。
9.2 圖像清晰模糊和縮放關系
清晰與模糊是我們視覺最直接的感受,那落到數據上應該是什么呢?一種那就是像素密度(定義在上面名詞解釋),還有一種是圖片是否發生了縮放。
像素密度產生的模糊
對于如下兩個圖,圖1像素密度越小,我們越清晰的看到弧度處的鋸齒,感覺越模糊,反之如圖2,就越清晰。
圖片縮放產生的模糊
圖片的像素是物理像素,圖片文件的像素數(100×100
)是固定的,表示圖片包含 100×100=10000
個顏色信息點(物理像素)。這些像素是絕對值,與顯示設備無關。一張 100×100
的圖片在任何設備上打開,其文件本身的像素數都是 10000;
當你在DPR為2的設備上繪制圖片時,沒有做DPR換算,那相當于你把圖片100 * 100
的物理像素,繪制成了100 * 100
的邏輯像素,實際發生了放大,導致邏輯像素與物理像素的映射關系不匹配,被強制拉伸,原始像素信息不足以填充細節
9.2.1 為什么密度高就清晰了呢?
做個小實驗,上面在標題八中,理解canvas.width/canvas.height中,我們有個重要結論和概念。
- 結論3: style指定DOM布局大小,會導致畫布按比例縮放(那我們此時可以有個概念:DOM布局大小和畫布大小的概念區分)
- 結論6: style.width和style.height會拉伸單個畫布像素在DOM布局中占據空間大小,但不會改變像素數量。
基于這兩個結論和概念,即使我們不管DPR,我們也可以模擬類似倍數效果。
- 我們創建2個Canvas畫布,將他們在DOM流中的寬高固定200px,(style.width/style.height)
- 一個設置canvas.width/canvas.height 200,另一個設置canvas.width/canvas.height * 2,然后在ctx.scale(2)。
- 因為canvas.width/canvas.height不同,所以Canvas內像素數量是不一致的。我有限制了DOM的寬高一致,那在
200 * 200
的DOM空間內放的像素數量是不一致的,就產生了DPR=1和DPR=2的效果。 - DPR為1塞了40000個像素,DPR為2塞了160000個像素,很明顯像素密度大的清晰度更高
<canvas id="canvas-not-scale" style="display: inline-block;"></canvas>
<canvas id="canvas-scale" style="display: inline-block;"></canvas>function canvasDpr(id, dpr = 1, sc) {var canvas = document.getElementById(id);var ctx = canvas.getContext("2d");var size = 200;canvas.style.width = size + "px";canvas.style.height = size + "px";// 調整dpr能展示不同的清晰度var scale = dpr || window.devicePixelRatio;canvas.width = Math.floor(size * scale);canvas.height = Math.floor(size * scale);ctx.fillStyle = "#c0da69";ctx.fillRect(0, 0, canvas.width, canvas.height);ctx.scale(sc || scale, sc || scale);ctx.fillStyle = "#ffffff";ctx.font = "28px Arial";ctx.textAlign = "center";ctx.textBaseline = "middle";var x = size / 2;var y = size / 2;var textString = "高清文字";ctx.fillText(textString, x, y);
}canvasDpr('canvas-not-scale', 1);
canvasDpr('canvas-scale', 2, 2);
9.2.2 理解 scale 縮放的是什么
默認情況Canvas一個單位像素和DOM空間的一個像素大小一樣(無關dpr)。在使用scale時,如果將 0.5 作為縮放因子,最終單位會變成 0.5 像素,并且形狀的尺寸會變成原來的一半。如果將 2 作為縮放因子,最終單位會變成 2 像素,并且形狀的尺寸會變成原來的2倍
如下圖,理解scale縮放的本質,紅色代表DOM的基本單元,藍色代表Canvas 基本單元
- 左一:沒有縮放;左二:scale 2倍,像素數量不變,代碼可在上面那個稍做改造即可。
9.2.3 綜上有一些結論
為了方便理解,我們明確幾個名詞,邏輯像素,物理像素,DOM像素單元,Canvas 畫布像素單元。
物理像素: 就是常說的設備發光單元,和設備機型有關系
邏輯像素: 就是我們通用意義的像素,它是不會變大變小的,只是一種像素概念 (1米始終是1米,你把一米長的繩子拉伸,那是繩子變了,不是1米變了)
DOM像素單元: 前端常用頁面開發布局,默認都是針對DOM書寫,是可以通過CSS/JS改變大小 (CSS/JS改變這就相當于繩子的拉伸)
Canvas 畫布像素單元: 前端Canvas繪制,是可以通過DOM或ctx.scale改變大小。 (我在1米 * 1米的畫紙內,可以使用1 * 1厘米網格,也可以用2 * 2厘米網格,這個網格的大小就是畫布像素單元)
- 邏輯像素和物理像素之間關系是DPR承接,可以通過DPR進行換算,二者大小都是固定不變的(在同一設備上)。
- 邏輯像素/DOM像素單元/Canvas像素單元默認三者一樣大小,CSS的transform的scale可以改變DOM像素單元大小;
- ctx.scale可以改變Canvas像素單元大小;
- 通過設置Canvas的style.width/style.height和Canvas.width/Canvas.height不一致,也可以改變Canvas像素單元大小。
- DOM像素數量和DOM的width/height有關,不會隨縮放改變。
- Canvas像素數量和Canvas.width/Canvas.height有關,不會隨縮放改變。
十、Canvas坐標定位
本節將,
10.1 坐標原點和方向
坐標系原點
- Canvas 的坐標系原點
(0, 0)
默認位于畫布的左上角 - X 軸向右為正方向,Y 軸向下為正方向(與數學坐標系相反)。
10.2 坐標換算和定位
- getBoundingClientRect:返回包含整個元素的最小矩形(包括 padding 和 border-width)
- 使用 left、top、right、bottom、x、y、width 和 height 這幾個只讀屬性描述整個矩形的位置和大小。
- 除 width 和 height 以外的屬性是相對于視圖窗口的左上角來計算的,這個必須清楚,灰色區域代表視圖窗口。
- 我們在事件的屬性里,也有一個基于視圖窗口定位的,屬性clientX/clientY
- 當我點擊畫布上一個點時,如下圖的綠色圓點,那么他基于Canvas的左上角坐標就等于
e.clientX - contentPosition.left
和e.clientY - contentPosition.top
,這樣我們可以得到點擊Canvas位置的坐標數據(相對于Canvas的左上角)。
// html
<canvas id="event-canvas2" style="width: 500px;height: 300px;"></canvas>// js
function eventCanvas(id) {var dom = document.getElementById(id);function _getContentPosition() {var rect = dom.getBoundingClientRect();console.log(rect);return {top: rect.top,left: rect.left};}dom.addEventListener("click", function (e) {const contentPosition = _getContentPosition();x = (e.clientX - contentPosition.left);y = (e.clientY - contentPosition.top);// clientX/Y 相對于可視區域的x,yconsole.log('click', x, y, id, e.clientX, e.clientY);})
}eventCanvas('event-canvas2');
10.3 復雜坐標和定位,DOM和Canvas的像素單位不是1:1
上面我們基于DOM像素和Canvas像素為1:1的情況做的計算,整體比較簡單。下圖Canvas是我通過設置style.widht/height 和 Canvas.width/height 不匹配,實現的壓縮Canvas像素單元,達到1:2的效果。canvas.width/height = 12/4,style.width/height=6/2
- 當我們點擊了紅色圓點位置,那他的坐標應該是(4, 1),但是這個對應到Canvas上,應該是(8, 2);
- 我們在9.2.3得到過一些結論,物理像素和邏輯像素大小是固定的,他們之間的關系是dpr承接(一定不要混淆dpr和DOM像素和Canvas像素的關系)。Canvas像素單元大小是可以通過style.width/height 和 Canvas.width/height 來改變的和DPR沒有關系。
我們為什么要做這個事情,主要原因是DPR不為1時,如果我們不做畫布單元的縮放,使其保持和物理像素大小一致,會導致畫布繪制的形狀/圖片清晰度異常。因此這是做圖片編輯器或者Canvas處理必須要關注的事情。
10.4 像素操作
10.4.1 通過 ImageData
對象直接操作像素,核心API:
getImageData()
:獲取畫布指定區域的像素數據putImageData()
:將像素數據繪制到畫布上。createImageData()
:創建新的空白像素數據對象。
10.4.2 基本操作demo
直接通過數組修改顏色,上面我們提到過,每個像素由于數組的四項組成(rgba),數組每項取值范圍是0-255;分別代表紅,綠,藍,透明度。
通過下面的代碼操作,直接修改數組的值,即可改變顏色信息。
<canvas id="canvas-px" width="300" height="200" style="border: 1px solid red;"></canvas><script>function pixFun () {const canvas = document.getElementById('canvas-px');const ctx = canvas.getContext('2d');// 1. 繪制基礎圖形ctx.fillStyle = 'red';ctx.fillRect(50, 50, 100, 100);// 2. 獲取像素數據const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);const data = imageData.data; // Uint8ClampedArray [R, G, B, A, R, G, B, A, ...]// 3. 遍歷并修改像素(反轉顏色)for (let i = 0; i < data.length; i += 4) {data[i] = 255 - data[i]; // Rdata[i + 1] = 255 - data[i + 1]; // Gdata[i + 2] = 255 - data[i + 2]; // B// Alpha 通道保持不變}// 4. 將修改后的像素數據寫回畫布ctx.putImageData(imageData, 0, 0);};pixFun();
</script>
10.5 像素操作在DPR影響下像素定位
有了上面的基礎,那我們基于像素定位,也就簡單了,一句總結就是:將DOM像素單元的坐標點換算成Canvas像素單元的坐標點。
用下面demo跑一下,你就能夠理解:
- 為了保證清晰度,我們必須基于DPR的關系,利用style.width/height和Canvas.width/height,將 Canvas 畫布像素單元縮放成和物理像素大小一樣,才能保證繪制像素與物理像素的映射關系。這樣才不會產生放大,導致原始像素信息不足以填充細節;
- 有了上面的處理,那在實際坐標計算時,我們通過事件獲取的坐標都是基于DOM得到的,大小是和邏輯像素保持一致,此時就和畫布像素單元無法匹配,因此整個坐標值也要基于DPR的倍數關系進行換算才行。
// html
<canvas id="event-canvas2" style="width: 500px;height: 300px;"></canvas>// js
function eventCanvas(id) {var dom = document.getElementById(id);const width = 500;const height = 300;dom.style.width = `${width}px`;dom.style.height = `${height}px`;dom.width = width * window.devicePixelRatio || 2;dom.height = height * window.devicePixelRatio || 2;console.log(window.devicePixelRatio);function _getContentPosition() {var rect = dom.getBoundingClientRect();return {top: rect.top,left: rect.left};}dom.addEventListener("click", function (e) {const contentPosition = _getContentPosition();// 如果我刪掉這里這個 window.devicePixelRatio 換算,那就定位上無法準確匹配了。x = (e.clientX - contentPosition.left) * window.devicePixelRatio;y = (e.clientY - contentPosition.top) * window.devicePixelRatio;// clientX/Y 相對于可視區域的x,yconsole.log('click', `新X:${x}`, `新Y:${y}`, `舊X:${e.clientX - contentPosition.left}`, `舊Y:${e.clientY - contentPosition.top}`, 'DPR: ' + window.devicePixelRatio);const ctx = dom.getContext("2d");// 取6個px大小,是為了視覺上能看清const imageData = ctx.getImageData(x,y,6,6);// 我這里只取6個像素大小的距離。一個像素是由rgba數組的四個元素組成。// 下面的操作我是將點擊的這個像素改成紅色。for (let i = 0; i < imageData.data.length; i++) {imageData.data[4 * i] = 255; // R 拿到每個像素點的第一個小點imageData.data[4 * i + 1] = 0; // GimageData.data[4 * i + 2] = 0; // BimageData.data[4 * i + 3] = 255; // 透明度}ctx.putImageData(imageData, x, y);})
}// 執行
eventCanvas('event-canvas2');
如果沒有進行換算,點擊2的位置,最后修改的顏色是1的位置,剛好差了DPR的倍數關系
十一、Canvas性能優化
11.1 避免浮點數坐標
當你畫一個沒有整數坐標點的對象時會發生子像素渲染,瀏覽器為了達到抗鋸齒的效果會做額外的運算
ctx.drawImage(Image, 0.5, 0.5);
11.2 使用多層畫布
- 比如canvas有動畫目標,還有靜態背景,可以通過分層繪制,保證動畫不斷重繪,靜態背景不用重繪。使用多個
<canvas>
元素進行分層。 - 也可以用css實現背景,而不用canvas。
11.3 關閉透明度
創建上下文時把 alpha
設置為 false
關閉透明度,可以幫助瀏覽器進行內部優化
var ctx = canvas.getContext("2d", { alpha: false });
11.4 整合函數調用
- 畫一條折線,而不要畫多條分開的直線
11.5 不必要的狀態變更
- 避免不必要的畫布狀態改變
- 如果可以局部更新,就不要整個畫布更新
11.6 避免一些屬性應用
- 盡可能避免
text rendering
和shadowBlur
11.7 嘗試不同方式清除畫布
-
嘗試不同的方式來清除畫布
clearRect()
和fillRect()
和 調整 canvas 大小 -
比如局部清除,可以采用再次繪制覆蓋,而不是整個畫布清除。或者縮小畫布,刪掉邊角內容。
11.8 使用requestAnimationFrame()(通用型優化技術)
- requestAnimationFrame() 可以優化動畫效果,動畫優先使用。
11.9 抽幀(節流)控制和空閑幀優化(通用型優化技術)
優化cpu調用占用,導致系統卡頓,手機電腦發熱問題,整體思路和節流是一樣的
前半段是抽幀的CPU占用,后半段是未抽幀CPU占用。
- 抽幀的本質就是時間控制 (節流) ,減少繪制的次數,從而達到節省CPU
requestIdleCallback(callback, options)
空閑幀執行callback,可用來執行后臺或低優先級任務,不會影響延遲關鍵事件,如動畫和輸入響應。指定了超時時間options.timeout
,有可能為了在超時前執行函數而打亂執行順序。
空閑幀優化
<canvas id="canvas" width="800" height="600"></canvas><script>const canvas = document.getElementById('canvas');const ctx = canvas.getContext('2d');let lastTime = 0;const targetFPS = 30; // 目標幀率const interval = 1000 / targetFPS;function animate(timestamp) {// 1、不抽幀繪制,硬畫draw();if (timestamp - lastTime >= interval) {// 2、抽幀繪制優化,控制幀率30// draw();lastTime = timestamp;}requestAnimationFrame(animate);// 3、空閑幀優化// requestIdleCallback(animate, { timeout: 100 });}// 初始坐標let mouseX = 0;let mouseY = 0;// 監聽鼠標移動事件canvas.addEventListener('mousemove', (e) => {// 獲取相對于 Canvas 的坐標const rect = canvas.getBoundingClientRect();mouseX = e.clientX - rect.left;mouseY = e.clientY - rect.top;// 立即重繪畫布animate(0);});// 繪制函數function draw() {let count = 400000;do {count--;}while (count > 0);// 清空畫布ctx.clearRect(0, 0, canvas.width, canvas.height);// 繪制紅點ctx.beginPath();ctx.arc(mouseX, mouseY, 10, 0, Math.PI * 2);ctx.fillStyle = 'red';ctx.fill();}draw();
</script>
11.10 拆解任務(通用型優化技術方案)
主要是解決JS在單線程無法借助worker技術下計算占用渲染問題,當你有一個復雜JS計算時,如何在不卡頓頁面情況下完成。
- 拆分任務,又可以叫時間分片, 本質上就是,千萬級別計算,拆成1000個萬級別計算,通過異步函數拆分后,主線程間斷計算,給動畫渲染計算執行留出空余時間 (堵車時的交替通過)。
阻塞形渲染,導致動畫卡死,CPU 爆滿
<canvas id="myCanvas" width="400" height="300"></canvas>
<div class="controls"><button id="blockingBtn">觸發阻塞計算 (100萬步)</button><button id="chunkedBtn">觸發拆分計算 (100萬步)</button>
</div>
<script>const canvas = document.getElementById('myCanvas');const ctx = canvas.getContext('2d');const blockingBtn = document.getElementById('blockingBtn');const chunkedBtn = document.getElementById('chunkedBtn');const canvasWidth = canvas.width;const canvasHeight = canvas.height;// --- 旋轉矩形動畫部分 ---const rectSize = 50;let rotationAngle = 0; // 當前旋轉角度 (弧度)const rotationSpeed = 0.02; // 旋轉速度 (弧度/幀)function drawRotatingRectangle() {// 清空畫布ctx.clearRect(0, 0, canvasWidth, canvasHeight);// 保存當前 Canvas 狀態 (坐標系,旋轉,顏色等)ctx.save();// 將坐標系原點移動到 Canvas 中心ctx.translate(canvasWidth / 2, canvasHeight / 2);// 旋轉 Canvas 坐標系ctx.rotate(rotationAngle);// 繪制矩形 (現在繪制在新的坐標系下,中心在 (0,0))ctx.fillStyle = 'red';// 注意:繪制坐標是矩形的左上角,所以需要偏移 -rectSize/2 使其中心與原點對齊ctx.fillRect(-rectSize / 2, -rectSize / 2, rectSize, rectSize);// 恢復之前保存的 Canvas 狀態ctx.restore();// 更新旋轉角度rotationAngle += rotationSpeed;// 安排下一幀動畫requestAnimationFrame(drawRotatingRectangle);}// 啟動動畫循環drawRotatingRectangle();// --- 耗時計算部分 ---const TOTAL_COMPUTATIONS = 1000000000; // 總計算步數 (100萬)const CHUNK_SIZE = 500000; // 每批處理的步數 (可以調整這個值來觀察差異)let currentComputationStep = 0;// 模擬單步計算的工作 (這里只是簡單的加法和數學運算)function performSingleStepWork(step) {let temp = step * Math.random();temp = Math.sqrt(temp + step);return temp;}// 阻塞式計算function performBlockingCalculation() {let result = 0;for (let i = 0; i < TOTAL_COMPUTATIONS; i++) {result += performSingleStepWork(i);}}// 拆分式計算function performChunkedCalculation() {currentComputationStep = 0;// 定義處理一個任務塊的函數function processChunk() {// 計算當前塊的結束步數const endStep = Math.min(currentComputationStep + CHUNK_SIZE, TOTAL_COMPUTATIONS);// 執行當前塊的計算let chunkResult = 0;for (let i = currentComputationStep; i < endStep; i++) {chunkResult += performSingleStepWork(i);}// 更新進度currentComputationStep = endStep;const progress = Math.floor((currentComputationStep / TOTAL_COMPUTATIONS) * 100);console.log(`狀態: 拆分計算中... 進度: ${progress}%`);// 檢查是否完成if (currentComputationStep < TOTAL_COMPUTATIONS) {// 如果未完成,安排下一個任務塊在下一個事件循環周期執行// setTimeout 的延遲設置為 0 意味著“盡快執行”,但會等待當前腳本塊執行完畢并處理其他待處理事件(包括 requestAnimationFrame 回調)setTimeout(processChunk, 0);} else {// 完成計算}}// 啟動第一個任務塊processChunk();}// --- 按鈕控制 ---blockingBtn.addEventListener('click', performBlockingCalculation);chunkedBtn.addEventListener('click', performChunkedCalculation);
</script>
11.11 離屏Canvas
如果沒有離屏Canvas,那整個動畫要不斷的繪制這些圓點,性能開銷非常大。此Demo的思路也可以用在上面說的分層Canvas上,其實本質上差不多。
<p>大量背景圓圈是靜態的,只有紅色方塊在移動</p>
<p>背景圓圈只繪制了一次在離屏 Canvas 上,動畫循環中只復制離屏 Canvas 的內容。</p><canvas id="mainCanvas" width="500" height="300"></canvas><script>const mainCanvas = document.getElementById('mainCanvas');const ctxMain = mainCanvas.getContext('2d');const canvasWidth = mainCanvas.width;const canvasHeight = mainCanvas.height;// --- 創建并預渲染離屏 Canvas ---// 1. 創建一個離屏 Canvas 元素 (不會添加到 DOM 中)const offscreenCanvas = document.createElement('canvas');offscreenCanvas.width = canvasWidth;offscreenCanvas.height = canvasHeight;const ctxOffscreen = offscreenCanvas.getContext('2d');// 預渲染背景函數 (只執行一次)function preRenderBackground() {// 在離屏 Canvas 上繪制一個包含大量隨機圓圈的背景const numberOfCircles = 200; // 繪制 200 個圓圈const maxRadius = 10;for (let i = 0; i < numberOfCircles; i++) {const x = Math.random() * canvasWidth;const y = Math.random() * canvasHeight;const radius = Math.random() * maxRadius;const color = `hsl(${Math.random() * 360}, 70%, 50%)`; // 隨機顏色ctxOffscreen.beginPath();ctxOffscreen.arc(x, y, radius, 0, Math.PI * 2);ctxOffscreen.fillStyle = color;ctxOffscreen.fill();ctxOffscreen.closePath();}}// 在腳本加載后立即預渲染背景preRenderBackground();// --- 動態元素和主 Canvas 動畫循環 ---const movingRectSize = 30;let movingRectX = 0;const movingRectSpeed = 2;function animate() {// 1. 清空主 CanvasctxMain.clearRect(0, 0, canvasWidth, canvasHeight);// 2. 將離屏 Canvas 的內容繪制到主 Canvas 上 (這是優化點!)// 只需要一次 drawImage 調用,而不是重復繪制所有背景圓圈ctxMain.drawImage(offscreenCanvas, 0, 0);// 3. 繪制動態元素 (紅色矩形)ctxMain.fillStyle = 'red';ctxMain.fillRect(movingRectX, canvasHeight / 2 - movingRectSize / 2, movingRectSize, movingRectSize);// 4. 更新動態元素位置movingRectX += movingRectSpeed;// 讓矩形在畫布邊緣循環出現if (movingRectX > canvasWidth) {movingRectX = -movingRectSize;}// 5. 安排下一幀動畫requestAnimationFrame(animate);}// 啟動主 Canvas 的動畫循環animate();
</script>
11.12 使用worker處理密集型計算(通用型優化)
將復雜計算,放到worker內執行
十二、Canvas原理,底層機制和顏色擴散問題
12.1 Canvas 渲染
繪圖指令調用
- 調用CanvasRenderingContext2D提供的API,各種繪圖方法
命令解析與處理
- 瀏覽器接收到繪圖指令后進行解析,指令不直接在屏幕上繪制,而是被轉換為底層的圖形繪制命令,針對Canvas內部維護的一個位圖進行操作
位圖操作
Canvas內部維護一個與Canvas元素尺寸對應的像素緩沖區(一個位圖),瀏覽器圖形引擎根據解析的繪圖命令,修改內存中的位圖相應像素
硬件加速GPU
瀏覽器會利用GPU加速Canvas渲染過程
- 圖像繪制 drawImage: 對圖像進行復制,縮放,旋轉,透明度處理和混合,這是最常見的部分
- 幾何變換 Transformations: 平移,旋轉,縮放等變換到圖形
- 簡單填充和描邊:矩形,簡單路徑的純色或線性漸變填充和描邊,也可以加速
合成(此步驟是瀏覽器每幀渲染階段的合成,非只是canvas)
- 位圖被更新后,瀏覽器將Canvas位圖與網頁中的其他元素進行合成
光柵化與顯示(這是瀏覽器每幀渲染階段行為)
- 合成后的圖像會被光柵化,轉換為屏幕上實際像素,將最終的像素數據顯示在屏幕上
12.2 Canvas 坐標特性
- 浮點數坐標:允許使用小數坐標,提供更精細的繪制控制,最終渲染到屏幕的像素網格時進行抗鋸齒處理,讓圖形邊緣更平滑
- 像素網格:每個像素對應一個整數坐標區域
- 筆觸中心對齊規則:默認繪制路徑(線條、矩形邊框),筆觸中心線會精確對齊指定的坐標點
- 筆觸寬度影響,假設筆觸寬度為
1px
,筆觸會以坐標點為中心,向兩側各延伸0.5px
下圖舉例2組數據
- 第一組觸點1px的畫筆,橫向劃線從(4,4)劃線到(8,4),長4px高1px的線條。
- 第二組觸點2px的畫筆,橫向劃線從(4,8)劃線到(8,8),長4px高2px的線條。
根據筆觸對齊規則,當你在整數坐標上繪制一個奇數寬度(1/3/5/7像素)的線條,就會出現問題,表現如下第一個繪制。
12.3 坐標特性帶來的顏色擴散
- 一個長寬為12 * 12的Canvas畫布,從(4,4)到(8,4)繪制1px寬的線條。我們通過打印所用像素顏色,看實際被填充的情況。
- 結論和上面一致,X方向,從4->5->6->7,Y方向從 3 -> 4,而且這里要特別關注,顏色輸出,我代碼層面設置的red也就是紅色,沒有透明的,理論上顏色應該是(255,0,0,255)結果打印是(255,0,0,128),這是因為Canvas底層算法特性導致的,也就是抗鋸齒。
- 標準的Canvas 2D API沒有可以全局關閉所有繪制操作(包括描邊)的抗鋸齒,也就是說無法完全消除抗鋸齒,抗鋸齒通常是瀏覽器渲染引擎在底層處理像素時的一個特性,
imageSmoothingEnabled
控制圖像縮放時平滑處理,無法影響線條描邊的抗鋸齒行為,
<style>canvas {width: 12px;height: 12px;border: 1px solid black;}
</style>
<canvas id="canvas"></canvas>
<script>const canvas = document.createElement("canvas");const ctx = canvas.getContext("2d");canvas.width = 12;canvas.height = 12;ctx.translate(0.5, 0.5);// 繪制紅色水平線ctx.beginPath();ctx.moveTo(4, 4);ctx.lineTo(8, 4);ctx.lineWidth = 1;// ctx.lineWidth = 2;ctx.strokeStyle = "red";ctx.stroke();// 提取像素數據const imageData = ctx.getImageData(0, 0, 12, 12);const pixels = imageData.data;// 檢查 (4,4) 到 (8,4) 的像素顏色,4個數組item,組成一個像素;for (let x = 0; x < pixels.length; x += 4) {if (pixels[x] !== 0) {console.log("像素個數和位置:", x / 4, (x / 4) % 12, Math.floor((x / 4) / 12));console.log("像素顏色:", pixels[x], pixels[x + 1], pixels[x + 2], pixels[x + 3]);}}
</script>
十三、常見問題
13.1 圖片跨域問題
- 需要Server或運維配置圖片允許跨域響應頭,不然繪制圖片有異常
- 解決跨域,除了Server配置,Image必須設置跨域屬性
img.crossOrigin = 'anonymous';
- 跨域的圖片可以使用drawImage 繪制到Canvas,但是會導致畫布被污染,無法使用轉blob或者base64或讀取像素等相關API
- 可以通過charles或者其他代理方式(瀏覽器插件代理工具),解決本地開發跨域問題
13.2 隱藏性的跨域問題(圖片緩存機制,這問題特別難定位)
- 命名設置了跨域屬性,Image加載圖片卻報錯,這可能是因為,該圖片在你的頁面DOM上已經渲染了,而那個渲染圖片的img標簽沒有設置跨域屬性,當你使用new Image加載圖片時,因為圖片地址沒變,讀取了緩存圖片地址。緩存內容因為DOM的img沒有設置跨域屬性,導致響應頭沒有跨域返回。解決方式:可以給DOM的img設置上屬性,也可以每次給鏈接加一個時間戳參數(這會導致重新加載,性能不太好)
13.3 顏色擴散問題和線條模糊
- 在第12節,我們講到了原理,也知道抗鋸齒導致的顏色擴散問題,而且抗鋸齒無法完全消除,這就會導致使用像素操作時,獲取到的顏色在邊緣位置,不會是完全符合預期的內容,解決這個問題,可行性方案是直接遍歷像素點,逐個修改顏色。當你畫筆是圓形或者有弧度時或者繪制弧度形狀時,這個問題無解。
- 上面12節的demo,也展現了線條繪制的原理,繪制奇數寬度線條時,顏色發生擴散,這會導致線條清晰度不夠。解決這個問題可以用
translate(0.5, 0.5);
平移0.5的方案實現,對于偶數寬度就沒必要平移了。
13.4 Canvas 大小問題
https://developer.mozilla.org/zh-CN/docs/Web/HTML/Reference/Elements/canvas
特別注意iOS移動設備布尺寸限制為4096 * 4096,真是垃圾,解決不了的。