Canvas设计图片编辑器全讲解(一)Canvas基础(万字图文讲解)
一、前序
近两年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,真是垃圾,解决不了的。