概览
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`);
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)
利用 globalCompositeOperation = 'destination-out' 将新绘制的路径变成“透明”,实现擦除效果。
// 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();
};
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);
}
面试高频问题
面试考点: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 格式的图片数据。