Canvas 进阶篇
基础绘制会了之后,交互和动画才是 Canvas 真正费神的地方:坐标要自己换算,像素要自己改,跨域图还会把画布锁死。
Canvas 事件监听
需要给画布里某一块图形单独做点击吗?Canvas 没有「点到某个形状」的事件 API。只能监听整块画布,再自己判断点落在哪。
方式一:利用数学的力量
以圆为例:有圆心和半径后,用距离公式判断点击是否在圆内。拿到鼠标相对画布的坐标,再和每个圆做相交检测。
矩形、椭圆、多边形都有对应的几何判定。这种方式很常见。
方式二:模拟点击区域
原理:在隐藏画布上把每个图形画成唯一颜色,点击时读取该像素颜色,反查是哪一个图形。主画布上的可见颜色可以重复,隐藏画布上的颜色必须唯一。
每个图形画两次:一次在主画布(可见),一次在「点击」画布(可放在屏外,不必插入文档)。
点击主画布时,用同一坐标去隐藏画布 getImageData,用颜色做索引。
简单示例
generateCircleList、drawCircle 以及 ctx 的创建见 DEMO。鼠标坐标用 getBoundingClientRect,避免 offsetLeft 遇到滚动、嵌套定位时算错。

<!-- 方式一:利用数学的力量 -->
<canvas id="canvas" width="500" height="300"></canvas>
<p class="tips"></p>
<script>
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const p = document.querySelector('p')
const circleList = generateCircleList(5)
drawCircle(ctx, circleList)
canvas.addEventListener('mousedown', (event) => {
const rect = canvas.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
circleList.forEach(e => {
const dx = x - e.x
const dy = y - e.y
if (dx * dx + dy * dy <= e.radius * e.radius) {
canvas.style.borderColor = e.fillColor
p.innerHTML = e.fillColor
p.style.color = e.fillColor
}
});
}, false);
</script>
Canvas 动画
动画的基本步骤
可以通过以下的步骤来画出一帧:
- 清空 canvas: 除非接下来要画的内容会完全盖住 canvas(例如背景图),否则需要先清空。最简单的做法是
clearRect。 - 保存 canvas 状态: 如果这一帧会改样式、变形等,又希望下一帧从原始状态开始,先
save()。 - 绘制动画图形(animated shapes): 这一步才是重绘动画帧。
- 恢复 canvas 状态: 如果已经
save(),绘制后restore(),再进入下一帧。
操控动画
在 canvas 上绘制内容要用它提供的方法或自己封装的方法。脚本一次跑完才把结果画上去,所以在 for 循环里把所有帧一口气画完,通常看不到中间过程。
因此要实现动画,需要按时间重复重绘。常见两种做法:
- 用
setInterval、setTimeout在设定的时间点重绘。 - 用
requestAnimationFrame告诉浏览器:希望做动画,并在下一次重绘前调用指定函数更新画面。
注: requestAnimationFrame 跟显示器刷新对齐,通常约 60 次/秒,高刷新屏会更高;标签页在后台时浏览器往往会降频或暂停。比 setInterval 更合适做动画。
Canvas 的优化
在离屏 canvas 上预渲染相似的图形或重复的对象: 如果每个动画帧都在重复同一套绘制,可以先画到屏幕外的画布上,再按需把离屏图像拷到主画布,而不必每帧重新生成。
坐标尽量用整数,避免浮点坐标。
不要在
drawImage时顺便缩放图像。复杂场景可以用多层画布分别绘制。
用 CSS 设置大的背景图。
用 CSS transforms 缩放画布: CSS transforms 走 GPU,通常更快。更稳妥的是不直接缩放画布,或者用较小的画布再放大,而不是先画很大再缩小。
关闭透明度: 若不需要透明,创建上下文时把
alpha设为false,便于浏览器内部优化。const ctx = canvas.getContext('2d', { alpha: false });
示例:动态绘制图形
<canvas id="canvas" width="500" height="300"></canvas>
<script>
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const circleList = generateCircleList(5)
circleList.forEach((e, i) => {
setTimeout(() => {
ctx.beginPath();
ctx.fillStyle = e.fillColor;
ctx.strokeStyle = e.strokeColor;
ctx.arc(e.x, e.y, e.radius, e.startAngle, e.endAngle, e.anticlockwise);
ctx.fill();
ctx.stroke();
}, i * 1000)
})
</script>
示例:循环全景照片
<canvas id="canvas" width="500" height="300"></canvas>
<script>
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const canvasWidth = canvas.clientWidth;
const canvasHeight = canvas.clientHeight;
const speed = 30;
const scale = 1.2
const img = new Image();
img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';
let imageWidth = 0
let imageHeight = 0
let x = 0;
let y = 0
let dx = -1;
let dy = -1
img.onload = function () {
img.width = img.width / img.height * canvasHeight * scale;
img.height = canvasHeight * scale;
imageWidth = img.width
imageHeight = img.height
return setInterval(draw, speed);
}
function draw() {
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(img, x, y, imageWidth, imageHeight);
if (imageWidth !== canvasWidth) {
if (dx < 0 && Math.abs(x) > imageWidth - canvasWidth) {
dx = Math.abs(dx)
}
if (dx > 0 && x >= 0) {
dx = Math.abs(dx) * -1
}
x += dx;
}
if (imageHeight !== canvasHeight) {
if (dy < 0 && Math.abs(y) > imageHeight - canvasHeight) {
dy = Math.abs(dy)
}
if (dy > 0 && y >= 0) {
dy = Math.abs(dy) * -1
}
y += dy;
}
}
</script>
示例:鼠标追踪动画
<canvas id="canvas" width="500" height="300"></canvas>
<script>
function ColorLine(lineWidth, color, deg) {
this.x = mousePoint.x;
this.y = mousePoint.y;
this.lineWidth = lineWidth;
this.color = color;
this.theta = Math.random() * Math.PI * 2;
this.deg = deg;
this.radius = Math.random() * 150;
this.draw = function () {
const point = {
x: this.x,
y: this.y
};
this.theta += this.deg;
this.x = mousePoint.x + Math.cos(this.theta) * this.radius;
this.y = mousePoint.y + Math.sin(this.theta) * this.radius;
ctx.beginPath();
ctx.lineWidth = this.lineWidth;
ctx.strokeStyle = this.color;
ctx.moveTo(point.x, point.y);
ctx.lineTo(this.x, this.y);
ctx.stroke();
ctx.closePath();
}
}
function beginDraw() {
requestAnimationFrame(beginDraw);
ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
colorLines.forEach(e => e.draw());
}
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const mousePoint = {
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2
};
const colorLines = [];
for (let i = 0; i < 100; i++) {
colorLines.push(new ColorLine(5, `#${Math.random().toString(16).slice(2, 8)}`, Math.PI / 180));
}
beginDraw()
window.onmousemove = function (e) {
const rect = canvas.getBoundingClientRect()
mousePoint.x = e.clientX - rect.left;
mousePoint.y = e.clientY - rect.top;
}
</script>
像素操作
ImageData 对象里存着 canvas 位图的像素。它包含以下只读属性:
width:图片宽度,单位是像素。height:图片高度,单位是像素。data:Uint8ClampedArray,按 RGBA 存放,每个通道 0~255。colorSpace:色彩空间,如srgb、display-p3。
规范里没有 storageFormat 这一项。
data 里每个像素占 4 项,依次是 RGBA:
- R:红色(0~255)。
- G:绿色(0~255)。
- B:蓝色(0~255)。
- A:alpha 通道(0~255;0 为透明,255 为完全不透明)。
color、alpha 按数组顺序排列:第一个像素在 data[0]~data[3],第二个在 data[4]~data[7]。
createImageData
createImageData 用于在 Canvas 2D 里创建一块指定大小的空白 ImageData。新对象里所有像素都是透明黑,即 rgba 为 0, 0, 0, 0。
ctx.createImageData(width, height);
ctx.createImageData(imagedata);
参数说明:
- width:ImageData 新对象的宽度。
- height:ImageData 新对象的高度。
- imagedata:从现有的 ImageData 对象中,复制一个和其宽度和高度相同的对象。图像自身不允许被复制。
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const img = new Image();
img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';
img.onload = function () {
img.height = img.height / img.width * canvas.clientWidth * 1.2;
img.width = canvas.clientWidth * 1.2;
ctx.drawImage(img, 0, 0, img.width, img.height);
const imageData = ctx.createImageData(canvas.clientWidth, canvas.clientHeight);
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
data[i + 0] = Math.random() * 255;
data[i + 1] = Math.random() * 255;
data[i + 2] = Math.random() * 255;
data[i + 3] = Math.random() * 255;
}
ctx.putImageData(imageData, 10, 10, 0, 0, 100, 100);
}
getImageData
getImageData 返回一个 ImageData 对象,描述画布上矩形区域内的像素。起点为 sx、sy,宽为 sw,高为 sh。
ctx.getImageData(sx, sy, sw, sh);
参数说明:
- sx:将要被提取的图像数据矩形区域的左上角 x 坐标。
- sy:将要被提取的图像数据矩形区域的左上角 y 坐标。
- sw:将要被提取的图像数据矩形区域的宽度。
- sh:将要被提取的图像数据矩形区域的高度。
const imageData = ctx.getImageData(10, 10, 200, 300);
const data = imageData.data
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const img = new Image();
img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';
img.onload = function () {
img.height = img.height / img.width * canvas.clientWidth * 1.2;
img.width = canvas.clientWidth * 1.2;
ctx.drawImage(img, 0, 0, img.width, img.height);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
data[i + 3] = Math.random() * 255;
}
ctx.putImageData(imageData, 0, 0, 0, 0, img.width, img.height);
}
putImageData
putImageData 把已有 ImageData 写回位图。如果提供了脏矩形,则只写该矩形内的像素。此方法不受画布变换矩阵影响。
ctx.putImageData(imagedata, dx, dy);
ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);
参数说明:
- imagedata:包含像素值的 ImageData 对象。
- dx:源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)。
- dy:源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)。
- dirtyX(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(x 坐标)。
- dirtyY(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(y 坐标)。
- dirtyWidth(可选):在源图像数据中,矩形区域的宽度。默认是图像数据的宽度。
- dirtyHeight(可选):在源图像数据中,矩形区域的高度。默认是图像数据的高度。
数据转换
SVG 转 CANVAS

const svgDom = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svgDom.setAttribute('version', 1.1)
svgDom.setAttribute('baseProfile', 'full')
svgDom.setAttribute('width', canvas.clientWidth)
svgDom.setAttribute('height', canvas.clientHeight)
svgDom.innerHTML = `
<rect width="100%" height="100%" fill="orange" />
<text x="250" y="150" font-size="55" text-anchor="middle" fill="white">SVG TO CANVAS</text>
`
const image = new Image();
image.src = 'data:image/svg+xml;charset=utf-8,' + new XMLSerializer().serializeToString(svgDom);
image.onload = function () {
ctx.drawImage(image, 0, 0, image.width, image.height);
}
// 注意:以下方法,svg 标签一定要加属性 xmlns='http://www.w3.org/2000/svg',即
svgDom.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
// svg 内容中可以有中文字符
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgDom.outerHTML);
image.src = 'data:image/svg+xml;base64,' + window.btoa(
encodeURIComponent(svgDom.outerHTML).replace(/%([0-9A-F]{2})/g, (_, p) => String.fromCharCode('0x' + p))
);
// svg 内容中不能有中文字符
image.src = 'data:image/svg+xml,' + svgDom.outerHTML;
image.src = 'data:image/svg+xml;base64,' + window.btoa(svgDom.outerHTML);
CANVAS 转 base64
const base64Url = canvas.toDataURL("image/jpeg");
const a = document.createElement('a');
a.setAttribute('href', base64Url);
a.setAttribute('download', 'lizhao-download-canvas-image.jpg');
a.click();
base64 转图片文件
const base64Url = canvas.toDataURL("image/jpeg");
const bytes = window.atob(base64Url.split(',')[1]);
const arrayBuffer = new ArrayBuffer(bytes.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < bytes.length; i++) {
uint8Array[i] = bytes.charCodeAt(i);
}
const jpegBlob = new Blob([arrayBuffer], { type: 'image/jpeg' });
const jpegFile = new File([uint8Array], 'lizhao-base64-to-jpeg.jpeg', { type: 'image/jpeg' });
CANVAS 转 URL
const jpegUrl = URL.createObjectURL(jpegBlob);
图像合成(查看效果)
在绘制图形时,不同的图形会因为绘制的先后而有了层级关系。如果新绘制的图形和原有内容有重叠部分,在默认情况下,新绘制的图形是会覆盖在原有内容之上。
globalCompositeOperation 设置绘制新形状时的合成方式。下面表格里前 12 个是 Porter-Duff 合成。现行规范还支持 CSS 混合模式,如 multiply、screen、overlay、plus-lighter 等。darker 从未进入现行规范,引擎已移除,仅作对照。
| 属性值 | 描述 |
|---|---|
| source-over | 默认。新图像覆盖老图像。 |
| source-in | 新图像覆盖老图像,且仅仅会绘制重叠的部分,其他区域都变成透明的。 |
| source-out | 只绘制不重叠的部分,老图像是透明的。 |
| source-atop | 新图像覆盖老图像,且仅仅会绘制重叠的部分,其他区域不变。 |
| destination-over | 老图像覆盖新图像。 |
| destination-in | 老图像覆盖新图像,且仅仅会绘制重叠的部分,其他区域都变成透明的。 |
| destination-out | 只绘制不重叠的部分,新图像是透明的。 |
| destination-atop | 老图像覆盖新图像,且仅仅会绘制重叠的部分,其他区域不变。 |
| lighter | 重叠部分作加色处理。 |
| xor | 重叠部分会变成透明。 |
| copy | 只保留新图像,老图像被清除。 |
| darker | 重叠部分作减色处理。从未进入现行规范,引擎已移除。 |
常见问题
图片的 crossorigin 属性
HTML5 里部分元素支持 CORS,例如 audio、img、link、script、video 都有跨域属性 crossorigin,用来配置获取数据时的 CORS 请求。
图片的 crossorigin 属性配合合适的 CORS 响应头,就可以在画布里使用跨域 img,效果与同源图像相同。
HTML 属性 crossorigin 的取值:
anonymous:对此元素的 CORS 请求将不设置凭据标志。use-credentials:对此元素的 CORS 请求将设置凭证标志,请求会带上凭据。"":空值,如crossorigin或crossorigin="",效果与anonymous相同。
无效关键字和空字符串也会被当成 anonymous。
JavaScript 里对应的是 img.crossOrigin(驼峰)。只要属性值不是 use-credentials,都会解析为 anonymous,包括空字符串和类似 abc 这样的字符。
注意: canvas 使用跨域图片时,图片所在服务器必须允许跨域访问。
画布像素可能来自多种来源,包括其他主机上的图像或视频,因此会有安全限制。非同源图片仍可画到 canvas 上,但会污染画布,此后再读取像素就会抛出安全错误。
在被污染的画布上调用以下方法会抛出安全错误:
- 调用
getImageData()方法; - 调用
toBlob()方法; - 调用
toDataURL()方法。
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const img = new Image();
img.onload = function () {
context.drawImage(this, 0, 0);
context.getImageData(0, 0, this.width, this.height);
};
img.src = 'https://diff.example.com/images/sidgie.png';
如果使用 canvas.toDataURL() 方法,则会报错:
Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported
解决方法就是给 img 加上 crossOrigin:
img.crossOrigin = 'anonymous'
Canvas 图片渲染异常
封装一个方法 drawCardImage 用于将图片和一些文本绘制到 canvas,再导出为图片。在 HUAWEI P30(Harmony 3.0.0)下,连续两次调用该方法,报错:Tainted canvasses may not be exported。
解决方法: 第二次调用放进定时器,例如 setTimeout(async () => { await drawCardImage() }, 100)。
备注: 未定位到具体原因。其他机型未复现,HUAWEI Mate 40 Pro(Harmony 3.0.0)、HUAWEI Mate 20 X(Harmony 3.0.0)正常。
Canvas 大小限制
HTML <canvas> 元素在现代和传统浏览器里都支持。元素本身不规定宽度上限,但每种浏览器和平台组合都有自己的尺寸上限,超过后画布会无法使用。
浏览器没有提供 API 来查询这些上限,创建一个不可用的画布后也不会给出明确反馈。这对需要支持多种浏览器和平台、又要处理大型画布的应用是个麻烦。
canvas-size 是一个 npm 包,提供了浏览器支持的 Canvas 元素的最大面积、高度和宽度,以及测试自定义画布尺寸的能力。
以下是 canvas-size 当时的测试结果,测试使用 BrowserStack 提供的虚拟设备(结果因实际硬件而异):
PC 端:
| Browser (OS) | Max Width | Max Height | Max Area (Total) |
|---|---|---|---|
| Chrome >= 73 (Mac, Win) | 65,535 | 65,535 | 16,384 x 16,384 (268,435,456) |
| Chrome <= 72 (Mac, Win) | 32,767 | 32,767 | 16,384 x 16,384 (268,435,456) |
| Edge >= 80 (Mac, Win) | 65,535 | 65,535 | 16,384 x 16,384 (268,435,456) |
| Edge <= 18 (Win) | 16,384 | 16,384 | 16,384 x 16,384 (268,435,456) |
| Firefox >= 60 (Mac, Win) | 32,767 | 32,767 | 11,180 x 11,180 (124,992,400) |
| IE 11 (Win) | 16,384 | 16,384 | 8,192 x 8,192 (67,108,864) |
| IE 9 - 10 (Win) | 8,192 | 8,192 | 8,192 x 8,192 (67,108,864) |
| Safari >= 5 (Mac) | 4,194,303 | 8,388,607 | 16,384 x 16,384 (268,435,456) |
移动端: 同一平台、浏览器组合在不同移动设备上的测试结果会因硬件而异,尤其是性能较差的旧设备。
| Browser (OS) | Max Width | Max Height | Max Area (Total) |
|---|---|---|---|
| Chrome 91 (Android 8 - 11) | 65,535 | 65,535 | 16,384 x 16,384 (268,435,456) |
| Chrome 91 (Android 7) | 65,535 | 65,535 | 14,188 x 14,188 (201,299,344) |
| Chrome 91 (Android 6) | 65,535 | 65,535 | 16,384 x 16,384 (268,435,456) |
| Chrome 91 (Android 5) | 65,535 | 65,535 | 11,180 x 11,180 (124,992,400) |
| Chrome 68 (Android 7.1 - 9) | 32,767 | 32,767 | 14,188 x 14,188 (201,299,344) |
| Chrome 68 (Android 6) | 32,767 | 32,767 | 10,836 x 10,836 (117,418,896) |
| Chrome 68 (Android 5) | 32,767 | 32,767 | 11,402 x 11,402 (130,005,604) |
| IE (Windows Phone 8.x) | 4,096 | 4,096 | 4,096 x 4,096 (16,777,216) |
| Safari (iOS >= 9) | 4,194,303 | 8,388,607 | 4,096 x 4,096 (16,777,216) |