canvas
Canvas 上绘制图形是一种即时模式(immediate mode),一旦在 Canvas 上绘制了图形之后,Canvas 将不再知道这个图形的任何信息。被绘制的图形是可见的,但是你不能够操作这个图形,留在上面的只是一些像素。这是 Canvas 和 SVG 不同的地方,SVG 图形是可以被单独操纵的,也可以被重新绘制。在 canvas 中如果你想修改绘制的图形,你需要重新绘制所有的东西
用 canvas 绘制图形的基本步骤:
- 等待页面 DOM 元素加载完毕
- 获取 canvas 元素的引用
- 从 canvas 元素中获取 2D 上下文(context)
- 从 2D 上下文中使用绘制函数绘制图形
// ...
window.onload = function () {
drawExamples();
};
function drawExamples() {
const canvas = document.getElementById("myCanvas");
const context = canvas.getContext("2d");
context.fillStyle = "#000";
context.fillRect(100, 100, 100, 100);
}
Canvas 使用的是 W3C 坐标系,坐标空间参考如下:

图形转换与状态机 (State Machine)
Canvas 2D 的上下文(Context)是一个全局状态机。任何对 fillStyle、lineWidth 甚至坐标系(translate, scale)的修改,都会永久影响后续的所有绘制。
为了防止状态污染,必须熟练掌握状态的保存与恢复:
ctx.save():将当前的所有状态(颜色、线宽、坐标系变换矩阵、剪切区域等)压入一个栈(Stack)中。ctx.restore():从栈顶弹出一个状态并应用。
经典面试题:实现图形独立变换
如果你想让 A 图形旋转 45 度,而 B 图形不旋转,必须这样做:ctx.save(); // 1. 保存初始状态(未旋转)
ctx.translate(x, y); // 2. 移动原点到图形中心
ctx.rotate(Math.PI / 4); // 3. 旋转坐标系
ctx.fillRect(-10, -10, 20, 20); // 4. 画 A 图形
ctx.restore(); // 5. 恢复初始状态,此时坐标系回正
ctx.fillRect(50, 50, 20, 20); // 6. 画 B 图形(不受旋转影响)
核心 API 与实战指南
为了方便复习,我们将 Canvas API 按照功能模块进行归类,并提供直观的代码示例。
1. 矩形绘制 (Rectangles)
矩形是唯一一种可以直接在画布上绘制而不需要路径的对象。
// 1. 填充矩形
ctx.fillStyle = "red";
ctx.fillRect(10, 10, 100, 100);
// 2. 描边矩形
ctx.strokeStyle = "blue";
ctx.lineWidth = 5;
ctx.strokeRect(130, 10, 100, 100);
// 3. 清除局部区域 (橡皮擦)
ctx.clearRect(50, 50, 20, 20);
2. 路径控制 (Paths) - 核心基础
除了矩形,其他所有图形(圆、线、多边形)都必须通过路径来绘制。beginPath() 是防止路径污染的关键。
// 绘制一个三角形
ctx.beginPath(); // 1. 开始新路径
ctx.moveTo(50, 50); // 2. 移动画笔到起点
ctx.lineTo(100, 75); // 3. 画第一条线
ctx.lineTo(100, 25); // 4. 画第二条线
ctx.closePath(); // 5. 闭合路径 (自动连回起点)
ctx.stroke(); // 6. 描边
// 绘制圆形
ctx.beginPath();
ctx.arc(200, 50, 30, 0, Math.PI * 2); // 圆心(200,50), 半径30, 0到360度
ctx.fill(); // 填充实心圆
// 路径裁剪 (Clip)
ctx.beginPath();
ctx.arc(50, 150, 40, 0, Math.PI * 2);
ctx.clip(); // 之后的绘制只会在这个圆圈范围内显示
ctx.fillRect(0, 100, 100, 100);
// Path2D (路径复用)
// 允许创建可重用的路径对象,避免重复调用 moveTo/lineTo
const path = new Path2D();
path.moveTo(10, 10);
path.lineTo(100, 100);
ctx.stroke(path);
3. 文本绘制 (Text)
ctx.font = "bold 24px Arial";
ctx.textAlign = "center"; // 水平对齐:left, center, right
ctx.textBaseline = "middle"; // 垂直对齐:top, middle, bottom
// 绘制填充文字
ctx.fillText("Hello Canvas", 150, 150);
// 获取文本宽度 (用于动态布局)
const metrics = ctx.measureText("Hello Canvas");
console.log(`文本宽度为: ${metrics.width}px`);
为什么文本绘制"看起来一行,实际很贵"?
fillText 在浏览器内部要走完整流水线:解析 font 简写 → 字体匹配 → 字形塑形(HarfBuzz)→ 栅格化(FreeType/Skia)→ GPU 上传合成。塑形和栅格化每帧都要重做,即使文本完全相同也无法复用——这是 Canvas 文本慢于 DOM 文本的核心原因。
实测(M1 Mac):每秒画 1000 个 24px 文本约 8~15ms。动画里画 30 个文本,文本本身就吃掉 25%~50% 的帧时间。
两个最实用的优化
1. 文本预渲染(Text Atlas)—— 适用 80% 场景
文字是典型的"有限集合 + 大量重复"(按钮、菜单、状态栏)。把每种
(字体, 字号, 颜色, 内容)只画一次,缓存为离屏 canvas 或ImageBitmap,后续直接drawImage:const cache = new Map();
function drawCachedText(ctx, text, x, y, font, color) {
const key = `${font}_${color}_${text}`;
let bmp = cache.get(key);
if (!bmp) {
const off = document.createElement("canvas");
const o = off.getContext("2d");
o.font = font;
o.fillStyle = color;
const w = Math.ceil(o.measureText(text).width) + 4;
const h = parseInt(font) + 8;
off.width = w;
off.height = h;
o.font = font;
o.fillStyle = color; // 注意:设 width 后要重设
o.fillText(text, 2, parseInt(font) + 2);
cache.set(key, off);
bmp = off;
}
ctx.drawImage(bmp, x, y);
}后续
drawImage是 0.01ms 级的 GPU 拷贝,比fillText快 50~200 倍。只要文本不变,这就是 ROI 最高的优化。
2. 批量绘制(Batch)—— 减少状态切换
每次
ctx.font/ctx.fillStylesetter 都会让浏览器重置绘制上下文。按状态分组后再画:// 错误:每个 item 都改一次 font/color
items.forEach((i) => {
ctx.font = i.font;
ctx.fillStyle = i.color;
ctx.fillText(i.text, i.x, i.y);
});
// 正确:先按状态分组,同组共享一次 setter
const groups = _.groupBy(items, (i) => `${i.font}_${i.color}`);
Object.values(groups).forEach((g) => {
ctx.font = g[0].font;
ctx.fillStyle = g[0].color;
g.forEach((i) => ctx.fillText(i.text, i.x, i.y));
});
避坑速查
- 每帧改
ctx.font会触发完整流水线 → 预创建有限个字号- 动画里调
measureText也要塑形 → 能缓存就缓存strokeText比fillText贵 2 倍以上 → 能 fill 就别 stroke- CJK / Emoji / RTL 塑形慢 3~5 倍 → 别按英文性能预估
4. 图像处理 (Images)
const img = new Image();
img.src = "logo.png";
img.onload = () => {
// 参数说明:image, sx, sy, sW, sH, dx, dy, dW, dH
// 核心考点:前4个s参数代表“源图片裁剪区”,后4个d参数代表“画布目标区”
ctx.drawImage(img, 0, 0, 100, 100, 200, 200, 50, 50);
};
5. 状态与变换 (State & Transformations)
save() 和 restore() 是高级面试必考点,用于维护状态栈,防止坐标系旋转、缩放等操作污染后续绘制。
ctx.save(); // 1. 保存当前状态 (坐标系原点在左上角)
ctx.translate(100, 100); // 2. 平移原点
ctx.rotate(Math.PI / 4); // 3. 旋转45度
ctx.scale(2, 2); // 4. 放大2倍
ctx.fillRect(-25, -25, 50, 50); // 5. 在新坐标系中心画矩形
ctx.restore(); // 6. 恢复状态 (原点回到左上角,旋转缩放重置)
6. 样式、渐变与阴影 (Styles)
// 创建线性渐变
const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, "red");
gradient.addColorStop(1, "blue");
ctx.fillStyle = gradient;
// 线条末端样式
ctx.lineCap = "round"; // 圆头: butt, round, square
ctx.lineJoin = "bevel"; // 拐角: bevel, round, miter
// 虚线设置
ctx.setLineDash([10, 5]); // 10px实线, 5px空白
ctx.strokeRect(10, 250, 100, 50);
动画实现原理
Canvas 本身没有“动”的概念。实现动画的本质是:擦除画布 -> 重绘 -> 擦除画布 -> 重绘
最佳实践:使用
requestAnimationFrame?
- 同步屏幕刷新率:
requestAnimationFrame会跟随显示器的刷新率(通常是 60Hz,即每 16.6ms 执行一次),动画极其平滑。而setTimeout/setInterval在宏任务队列中,受主线程阻塞影响,极易出现掉帧、卡顿。- 后台暂停:当标签页被隐藏或最小化时,
requestAnimationFrame会自动暂停运行,极大节省 CPU 和 GPU 开销,而定时器仍会在后台疯狂执行。
function drawFrame() {
// 1. 擦除上一帧
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 更新物体状态(坐标、颜色等)
updateObjects();
// 3. 绘制新的一帧
drawObjects(ctx);
// 4. 递归调用,交给浏览器调度下一帧
requestAnimationFrame(drawFrame);
}
// 启动动画
requestAnimationFrame(drawFrame);
常见效果的实现
1. 刮刮卡 (Scratch Card)
// 1. 先铺一层灰色掩盖层(这是"将要被擦除的目标")
ctx.fillStyle = "#ccc";
ctx.fillRect(0, 0, 400, 200);
// 2. 设置合成模式为"擦除"
ctx.globalCompositeOperation = "destination-out";
// 3. 监听鼠标移动,画圆圈进行擦除
canvas.onmousemove = (e) => {
ctx.beginPath();
ctx.arc(e.offsetX, e.offsetY, 20, 0, Math.PI * 2);
ctx.fill();
};
globalCompositeOperation 是 Canvas 2D 上下文中一个容易被忽视却极其强大的状态属性。它定义了新绘制的图形(Source)如何与画布上已经存在的图形(Destination)进行像素级混合。默认值为 source-over(直接覆盖),但通过切换模式,我们可以实现"挖空"、"取交集"、"颜色相乘"等非常规效果。
核心心智模型:Porter-Duff 合成模型
在 Porter-Duff 模型中,每一次绘制都可以拆解为四个区域:
Source (新画的) Destination (已存在的)
┌───┐ ┌───┐
│ S │ │ │
└───┘ │ D │
└───┘
重叠区: S ∩ D 非重叠区: S∖D, D∖SCanvas 2D 提供了 11 种合成模式,本质上就是这四块区域的不同"取舍组合"。
'destination-out' 的工作机制:
这是一个"减法"操作。它的规则很直白:保留画布上不与新图形重叠的像素,将重叠部分的像素 alpha 强制变为 0。用伪代码表达:
// 对每个重叠像素
result.alpha = destination.alpha * (1 - source.alpha);
// 颜色保留 destination 的原色不变这就是为什么画一个圆形能"擦掉"底层的灰色掩盖层——因为圆形覆盖区域的 alpha 被乘以 0,从视觉上等同于被挖空,看到下方的真实内容。
⚠️ 极易踩坑:记得用完恢复默认
globalCompositeOperation是状态机的一部分,一旦设置会永久影响后续所有绘制。生产代码中务必用ctx.save() / ctx.restore()包住,否则下一个不相干的图形也会被"擦除模式"污染。
其他高频合成模式速查:
| 模式 | 效果 | 典型场景 |
|---|---|---|
source-over (默认) | 新图形覆盖在旧图形之上 | 通用绘制 |
destination-out | 用新图形挖空旧图形 | 刮刮卡、橡皮擦、镂空文字 |
source-in | 只保留新旧图形的重叠区,且颜色取 Source | 蒙版裁剪、形状遮罩 |
source-atop | 只在旧图形范围内绘制新图形 | 局部高亮、纹理叠加 |
xor | 重叠区域变透明 | 镂空交集、几何交集 |
multiply | 颜色逐通道相乘(结果偏暗) | 阴影叠加、复古滤镜 |
screen | 反相后相乘(结果偏亮) | 高光、火焰、光线 |
lighter | 颜色相加(超过 255 截断为白) | 烟花、发光粒子、加法混合 |
设计意图:记住上面这张表就足以应对 90% 的 Canvas 面试追问——尤其是
destination-out(擦除)和source-in(蒙版),基本是必考。剩下的multiply / screen多用于滤镜场景,记住"相乘变暗、反相后相乘变亮"的口诀即可。
2. 图片放大镜 (Magnifying Glass)
利用 drawImage 的 9 参数模式,将原图的一个小区域映射到更大的目标区域。
canvas.onmousemove = (e) => {
const size = 100; // 采样区大小
const zoom = 2; // 放大倍数
// 擦除旧的放大镜
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画原图
ctx.drawImage(img, 0, 0);
// 画放大镜:从鼠标周围 size 区域采样,画到 2*size 的区域
ctx.drawImage(
canvas,
e.offsetX - size / 2,
e.offsetY - size / 2,
size,
size, // 源采样区
e.offsetX,
e.offsetY,
size * zoom,
size * zoom, // 目标显示区
);
};
3. 像素级滤镜 (反色效果)
通过 getImageData 拿到每一个像素的 RGBA 值并进行数学运算。
function invert() {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = 255 - data[i]; // Red
data[i + 1] = 255 - data[i + 1]; // Green
data[i + 2] = 255 - data[i + 2]; // Blue
// data[i+3] 是 Alpha,不改动
}
ctx.putImageData(imgData, 0, 0);
}
4. 贝塞尔曲线
// ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
ctx.beginPath();
ctx.moveTo(50, 20);
ctx.bezierCurveTo(230, 30, 150, 60, 50, 100);
ctx.stroke();
// 可视化辅助点 (面试演示很有用)
ctx.fillStyle = "blue"; // 起点和终点
ctx.fillRect(50, 20, 5, 5);
ctx.fillRect(50, 100, 5, 5);
ctx.fillStyle = "red"; // 两个控制点
ctx.fillRect(230, 30, 5, 5);
ctx.fillRect(150, 70, 5, 5);
5. 渐变与阴影
// 线性渐变
const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, "green");
gradient.addColorStop(1, "white");
ctx.fillStyle = gradient;
// 设置阴影 (注意性能开销!)
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.shadowBlur = 4;
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(10, 10, 200, 100);
面试官追问:为什么阴影绘制(Shadows)在 Canvas 中非常耗性能?
- 模糊算法开销:
shadowBlur背后通常使用的是高斯模糊(Gaussian Blur)或类似的卷积算法。这种算法需要对每个像素及其周围像素进行加权平均计算,像素量级越大,计算量呈指数级增长。- 额外的渲染层:为了生成阴影,浏览器通常需要在一个离屏的临时缓冲区(Buffer)中先渲染图形的形状,然后应用模糊算法,最后再将结果偏移并合成回主画布。
- 实时计算:如果是动态动画,每一帧都要重新进行这种复杂的卷积计算,会极大地占用 CPU/GPU 资源。
6. 简易画板 (Simple Drawing Board)
监听鼠标事件,通过 lineTo 连续绘图。
let drawing = false;
canvas.onmousedown = () => {
drawing = true;
ctx.beginPath();
};
canvas.onmouseup = () => {
drawing = false;
};
canvas.onmousemove = (e) => {
if (!drawing) return;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
};
7. 全屏水印 (Full-page Watermark)
利用 createPattern 实现背景平铺。
function drawWatermark(text) {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 200;
tempCanvas.height = 150;
const tempCtx = tempCanvas.getContext("2d");
tempCtx.rotate((-20 * Math.PI) / 180);
tempCtx.font = "16px Arial";
tempCtx.fillStyle = "rgba(200, 200, 200, 0.3)";
tempCtx.fillText(text, 0, 100);
// 将小画布作为模式填充到大画布
const pattern = ctx.createPattern(tempCanvas, "repeat");
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
createPattern(image, repetition) 是 Canvas 2D 中实现图像平铺的核心 API。它接受一个"源"(Canvas 元素、Image、Video 或 OffscreenCanvas),返回一个可被赋值给 fillStyle / strokeStyle 的 pattern 对象,本质上就是一个"可复用的图像画笔"。
四种平铺模式速查
模式 效果
─────────────────────────────────────────────
"repeat" 横向 + 纵向都平铺(默认,最常用)
"repeat-x" 仅横向平铺,纵向只显示一行
"repeat-y" 仅纵向平铺,横向只显示一列
"no-repeat" 不平铺,只在原点处显示一次想象成 CSS 的
background-repeat,概念完全一致。
⚠️ 隐藏特性:Pattern 会继承源画布的坐标系变换
这是
createPattern最反直觉的地方。对源 Canvas 做的rotate / scale / translate会"烙印"在 pattern 上,即使你后续在大画布上重置了状态机。拿上面的水印举例:源画布旋转了 -20°,那么
createPattern之后,每次平铺单元也会跟着旋转 20°,从而实现"斜着的水印文字"。如果你没旋转源画布,水印就会是水平的。这个机制的好处是:无需对大画布做变换,就能实现任意角度的平铺纹理。坏处是:源画布的状态会"污染"最终结果,调试时容易找不到原因。
Pattern 源不仅限于 Canvas,还支持:
<img>元素 /Image对象(最常用)<video>元素(可实现动态视频纹理平铺)- 另一个
<canvas>元素OffscreenCanvas(Web Worker 场景下生成 pattern)这意味着你可以把"一张静态图"、"一段循环视频"作为平铺单元,灵活度比 CSS 的
background-image强很多。
Pattern 不会自动响应源变化的陷阱:
createPattern在调用瞬间会对源做一次快照采样。如果之后源 Canvas 上的内容发生变化(比如动画),pattern 不会自动更新。需要重新调用createPattern才能刷新。实战中如果要做"动态水印",必须每帧重新生成 pattern(性能开销较大),或者改用
ctx.drawImage手动循环绘制(更可控)。
面试高频问题
面试考点:Canvas vs SVG vs WebGL 的区别?
- Canvas 2D:基于位图(像素),适合绘制大量运动图形(如万个小球)、复杂交互和视觉特效。绘制后没有 DOM 节点残留,内存占用可控。但缩放会模糊,且不支持通过 CSS 直接控制样式。
- SVG:基于矢量图和 XML。每个图形都是一个真实的 DOM 节点。缩放不失真,可以用 CSS/JS 直接修改属性,非常利于事件绑定和 SEO。但如果节点数量过大(上万个),会导致严重性能瓶颈。
- WebGL:基于 OpenGL ES 的 3D 渲染上下文,通过 GPU 进行硬件加速,性能最强。适合复杂的 3D 游戏和海量数据可视化(如 ECharts 的 gl 模式)。学习成本极高,需要写着色器(GLSL)。
面试考点:图片、文字在高清屏(Retina)下变模糊怎么解决?
原因分析:在高清屏(如 Mac 的 Retina 屏幕或高分辨率手机)下,1 个 CSS 像素(逻辑像素)实际上对应着多个物理像素(由
window.devicePixelRatio决定)。但 Canvas 默认是按照 1:1 的逻辑像素系进行绘制的,当画布在高清屏上强行拉伸展示时,就会出现模糊和明显的锯齿。历史遗留方案(已不推荐):早年间需要综合判断
devicePixelRatio和backingStorePixelRatio(Canvas 后台存储像素比)的比值来进行放缩。现代标准解决方案:现代浏览器中
backingStorePixelRatio已废弃(恒为 1),只需直接利用window.devicePixelRatio(DPR)。 核心思路:将 Canvas 的绘图缓冲区大小(canvas.width/height)按 DPR 放大,保持DOM 显示尺寸(canvas.style.width/height)不变,最后通过ctx.scale将绘图坐标系缩放回逻辑像素级别。function setupHiDPICanvas(canvas, cssWidth, cssHeight) {
// 1. 获取当前设备的 DPR
const dpr = window.devicePixelRatio || 1;
// 2. 固定 DOM 的显示尺寸(CSS 逻辑像素)
canvas.style.width = cssWidth + "px";
canvas.style.height = cssHeight + "px";
// 3. 放大 Canvas 的实际渲染分辨率(物理像素)
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
const ctx = canvas.getContext("2d");
// 4. 将绘图坐标系放大 dpr 倍,使得后续所有的绘图指令(如 10px)自动映射为 dpr * 10 物理像素
ctx.scale(dpr, dpr);
return ctx;
}注:优化文字锯齿同样适用此高清渲染方案。
面试考点:Canvas 绘制 1px 直线,为什么实际显示看起来比 1px 宽且发虚?
原因分析:Canvas 在绘制线条时,是以指定的坐标点为中心向两侧延展的。如果我们在整数坐标(如
x=10)绘制 1px 宽的垂直线,Canvas 会尝试在9.5到10.5之间绘制。由于屏幕无法显示半个物理像素,浏览器会将其抗锯齿(Anti-aliasing)处理,强行占用左右各 1 个像素并变淡,最终导致原本 1px 的黑线变成了 2px 宽的灰线。解决方案(半像素定位法):当绘制奇数宽度(1px, 3px 等)的线条时,将坐标偏移
0.5px(如将10改为10.5),这样线条的延展边界就会刚好落在整数像素点上(10.0到11.0),从而得到完美清晰的 1px 线条。// 绘制垂直线条的辅助函数
function drawSharpLine(ctx, x, y1, y2, lineWidth) {
// 当线宽为奇数时,坐标追加 0.5px
let sharpX = lineWidth % 2 === 0 ? Math.floor(x) : Math.floor(x) + 0.5;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(sharpX, y1);
ctx.lineTo(sharpX, y2);
ctx.stroke();
}
面试考点:什么是 Canvas 污染(Tainted Canvas)?如何解决跨域图片报错?
现象:当使用
drawImage将一张不同源(跨域)的图片绘制到 Canvas 上时,绘制操作本身能成功显示。但是,由于安全机制,该 Canvas 会被标记为“污染状态(Tainted)”。此后如果你尝试调用toDataURL、toBlob导出图片,或者用getImageData读取像素,浏览器会直接抛出SecurityError异常阻止你的操作,防止跨域数据泄露。标准解决方案(CORS):
- 服务端配合:提供图片的服务器必须在响应头中包含跨域许可头
Access-Control-Allow-Origin: *(或指定域名)。- 前端配合:在 JS 中加载图片时,必须在触发请求前显式设置
crossOrigin属性。const img = new Image();
// 关键:声明请求允许跨域,必须在 src 赋值前设置
img.crossOrigin = "anonymous";
img.src = "https://other-domain.com/image.png";
img.onload = () => {
ctx.drawImage(img, 0, 0);
// 此时 Canvas 不会被污染,可以正常导出
console.log(canvas.toDataURL());
};备选方案:如果服务端无法修改 CORS,只能通过后端代理服务器转发请求,或者直接让服务端返回 Base64 格式的图片数据。