Skip to main content

canvas

Canvas 上绘制图形是一种即时模式(immediate mode),一旦在 Canvas 上绘制了图形之后,Canvas 将不再知道这个图形的任何信息。被绘制的图形是可见的,但是你不能够操作这个图形,留在上面的只是一些像素。这是 Canvas 和 SVG 不同的地方,SVG 图形是可以被单独操纵的,也可以被重新绘制。在 canvas 中如果你想修改绘制的图形,你需要重新绘制所有的东西

用 canvas 绘制图形的基本步骤:

  1. 等待页面 DOM 元素加载完毕
  2. 获取 canvas 元素的引用
  3. 从 canvas 元素中获取 2D 上下文(context)
  4. 从 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 坐标系,坐标空间参考如下:

canvas坐标空间

图形转换与状态机 (State Machine)

Canvas 2D 的上下文(Context)是一个全局状态机。任何对 fillStylelineWidth 甚至坐标系(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.fillStyle setter 都会让浏览器重置绘制上下文。按状态分组后再画:

// 错误:每个 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 也要塑形 → 能缓存就缓存
  • strokeTextfillText 贵 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∖S

Canvas 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 中非常耗性能?

  1. 模糊算法开销shadowBlur 背后通常使用的是高斯模糊(Gaussian Blur)或类似的卷积算法。这种算法需要对每个像素及其周围像素进行加权平均计算,像素量级越大,计算量呈指数级增长。
  2. 额外的渲染层:为了生成阴影,浏览器通常需要在一个离屏的临时缓冲区(Buffer)中先渲染图形的形状,然后应用模糊算法,最后再将结果偏移并合成回主画布。
  3. 实时计算:如果是动态动画,每一帧都要重新进行这种复杂的卷积计算,会极大地占用 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 的逻辑像素系进行绘制的,当画布在高清屏上强行拉伸展示时,就会出现模糊和明显的锯齿。

历史遗留方案(已不推荐):早年间需要综合判断 devicePixelRatiobackingStorePixelRatio(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.510.5 之间绘制。由于屏幕无法显示半个物理像素,浏览器会将其抗锯齿(Anti-aliasing)处理,强行占用左右各 1 个像素并变淡,最终导致原本 1px 的黑线变成了 2px 宽的灰线。

解决方案(半像素定位法):当绘制奇数宽度(1px, 3px 等)的线条时,将坐标偏移 0.5px(如将 10 改为 10.5),这样线条的延展边界就会刚好落在整数像素点上(10.011.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)”。此后如果你尝试调用 toDataURLtoBlob 导出图片,或者用 getImageData 读取像素,浏览器会直接抛出 SecurityError 异常阻止你的操作,防止跨域数据泄露。

标准解决方案(CORS)

  1. 服务端配合:提供图片的服务器必须在响应头中包含跨域许可头 Access-Control-Allow-Origin: *(或指定域名)。
  2. 前端配合:在 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 格式的图片数据。