Canvas 与 SVG 深入:选型、渲染循环与离屏优化
本文目录
Canvas 和 SVG 都能"画图",但它们是两种完全不同的东西:SVG 是保留模式的文档(DOM 里每个图形都是节点),Canvas 是即时模式的位图(画完就只剩像素)。这个差异决定了选型、事件、性能和导出的全部行为。这篇文章先给选型决策,再分别深入:SVG 的坐标系统、Canvas 的渲染循环与状态管理、分层与离屏优化、devicePixelRatio 处理、导出与跨域污染,最后是两者的边界场景。
选型:先回答四个问题
| 问题 | 倾向 SVG | 倾向 Canvas |
|---|---|---|
| 图形数量级 | 几十到几千个节点 | 上万到几十万个绘制调用 |
| 事件粒度 | 需要每个图形独立 hover/click | 事件统一处理或自己命中检测 |
| 缩放需求 | 无限缩放、印刷级输出 | 固定像素、照片类处理 |
| 像素级操作 | 不需要 | 需要(滤镜、混合、逐像素读写) |
| 内容结构 | 图表文档、可被选择/搜索的文字 | 粒子、游戏、视频处理 |
落到场景上:组织架构图、流程图编辑器、中等复杂度图表、需要无障碍标签的图形 → SVG;粒子系统、实时波形、大规模散点、图像处理、视频帧叠加 → Canvas。中间地带(如大屏可视化)用 Canvas 绘制 + 一层 HTML 覆盖做交互,是常见的混合方案。
两个决策不要做错方向:
- 不要用 Canvas 画几十个静态矩形。SVG/DOM 的开发效率、事件、可访问性都更好,性能在这个量级上没有差异。
- 不要用 SVG 撑到几万节点。DOM 节点的内存与事件成本随数量线性上涨,滚动和缩放会先于绘制成为瓶颈。
SVG:坐标系统与 viewBox
SVG 的核心是 viewBox:它声明"我的内部坐标系是 0 0 400 300",与元素的 CSS 尺寸解耦。浏览器把内部坐标映射到实际显示区域,preserveAspectRatio 决定比例不一致时怎么处理:
<!-- viewBox 定义用户坐标系;容器多大多小,内部坐标不变 -->
<svg
viewBox="0 0 400 300"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="季度收益示意"
class="chart"
>
<rect x="10" y="220" width="40" height="60" rx="4" />
<rect x="70" y="180" width="40" height="100" rx="4" />
<!-- CSS 控制显示尺寸,内部坐标不受影响 -->
</svg>
/* 响应式:宽随容器,高按 viewBox 比例自动得出 */
.chart {
display: block;
width: 100%;
height: auto;
}
实践中要记住的几条:
viewBox的宽高比和 CSS 尺寸不一致时,xMidYMid meet会保持比例留白(类似object-fit: contain),slice会裁切填满(类似cover)。- 缩放会让
stroke-width一起缩放。希望描边在任何缩放下恒定,用vector-effect="non-scaling-stroke"。 - SVG 里的文字是真实文字:可选中、可搜索、读屏可读。把它换掉之前想清楚,图表的数值标签经常因此留在 SVG 而非 Canvas。
- 需要每个图形的事件,直接在节点上挂监听器,配合 CSS
:hover就够,不用自己写命中检测。
Canvas 渲染循环:rAF 与脏标记
Canvas 是一张位图,改任何东西都要整段重画(clearRect + 全部绘制命令)。渲染循环的设计决定 CPU 占用:
const canvas = document.querySelector<HTMLCanvasElement>('.board')!;
const ctx = canvas.getContext('2d')!;
let dirty = true; // 脏标记:有变化才重画
let rafId = 0;
function invalidate(): void {
dirty = true; // 外部改动状态时调用,不直接画
}
function frame(now: number): void {
rafId = requestAnimationFrame(frame);
if (!dirty) return; // 静止时跳过绘制,只消耗一个回调
dirty = false;
draw(now);
}
function draw(now: number): void {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const shape of shapes) shape.render(ctx, now);
}
rafId = requestAnimationFrame(frame);
// 页面隐藏时暂停循环:后台标签页 rAF 会被节流,但循环还在空转
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelAnimationFrame(rafId);
else { dirty = true; rafId = requestAnimationFrame(frame); }
});
这套"按需渲染 + 脏标记"适合大多数交互式画板:用户操作 → invalidate() → 下一帧重画。需要持续动画(粒子、游戏)时改成每帧必画,并把"上一帧时间差"传给更新逻辑,动画速度才不会随帧率漂移。rAF 的回调频率对齐显示刷新率且随 vsync 节流,不要用它做任何计时假设。
ctx 状态机与 save/restore 成本
ctx 是有状态的:fillStyle、lineWidth、transform、clip 都是"当前状态",save()/restore() 压栈/弹栈的是整个状态集合。滥用时它有成本,且容易掩盖逻辑:
/* 反例:每个图形一对 save/restore,栈最深到 N 层 */
for (const dot of dots) {
ctx.save();
ctx.fillStyle = dot.color;
ctx.translate(dot.x, dot.y);
ctx.beginPath();
ctx.arc(0, 0, dot.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/* 正例:把变换吸收进绘制参数,样式按组设置,一次管一批 */
for (const color of colors) {
ctx.fillStyle = color;
ctx.beginPath();
for (const dot of dotsOf(color)) {
ctx.moveTo(dot.x + dot.r, dot.y);
ctx.arc(dot.x, dot.y, dot.r, 0, Math.PI * 2);
}
ctx.fill(); // 同色批量填充,状态切换最少
}
| 状态操作 | 成本定位 | 建议 |
|---|---|---|
save()/restore() | 整个状态集合的压栈/弹栈 | clip/复杂变换时用,平移尽量吸收进参数 |
换 fillStyle | 触发绘制前状态准备 | 按颜色/描边分组排序绘制命令 |
setTransform | 重置矩阵,比连续 translate 清晰 | 每帧开头重置一次,别靠 restore 恢复 |
beginPath() | 每段新路径必须 | 漏写会把之前的路径再画一遍 |
绘制顺序也影响成本:ctx.filter、阴影这类全局效果在每个绘制调用上都生效,把需要特效的少量元素单独画,不要全局开着。
分层 Canvas:把静态与动态拆开
画面里常常有一块"基本不动"(背景网格、地图底图)和一块"每帧都动"(光标、拖拽框、粒子)。分层把两者拆成两张叠起来的 canvas,静态层只在变化时重画一次:
<div class="stage">
<canvas class="layer layer-static" aria-label="画板背景"></canvas>
<canvas class="layer layer-dynamic" aria-hidden="true"></canvas>
</div>
.stage { position: relative; width: 100%; height: 320px; }
.layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.layer-static { z-index: 1; }
.layer-dynamic { z-index: 2; }
// 静态层:内容变化时才调用 repaintStatic(例如设置网格密度)
function repaintStatic(): void {
drawGrid(staticCtx);
staticDirty = false;
}
// 动态层:每帧清空重画,只放光标、选框、少量运动元素
function drawDynamic(now: number): void {
dynamicCtx.clearRect(0, 0, w, h);
drawCursor(dynamicCtx);
drawSelection(dynamicCtx);
}
层数不是越多越好:每层都是一张完整位图(显存与 clearRect 成本),通常两到三层(静态 / 动态 / 叠加特效)就够。动态层还可以换成 DOM 元素(比如拖拽框用 div),让浏览器自己合成,别教条地全塞进 Canvas。
OffscreenCanvas 与 Worker
绘制本身可能成为主线程瓶颈(几十万个点、逐像素计算)。OffscreenCanvas 允许把 canvas 的控制权转交给 Worker,绘制在独立线程执行,完成后位图直接呈现:
// 主线程:一次性移交控制权,之后 DOM canvas 只负责显示
const canvas = document.querySelector<HTMLCanvasElement>('.board')!;
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
worker.postMessage(
{ type: 'init', canvas: offscreen, width, height, dpr },
[offscreen] // transferable:控制权转移,不可再访问
);
worker.postMessage({ type: 'add', points }); // 之后只传数据命令
// render.worker.ts:在 Worker 里拿到 2d context,循环绘制
let ctx: OffscreenCanvasRenderingContext2D | null = null;
self.onmessage = (event: MessageEvent) => {
const data = event.data;
if (data.type === 'init') {
ctx = data.canvas.getContext('2d');
loop();
}
};
function loop(): void {
if (!ctx) return;
requestAnimationFrame(() => loop()); // Worker 里也有 rAF(仅 OffscreenCanvas 场景)
drawFrame(ctx);
}
这条路的代价要认清:Worker 里没有 DOM,任何事件(指针、resize)都要靠消息传递进来;消息内容要做协议设计,高频数据考虑 transferable 的 ArrayBuffer 而不是结构化克隆大对象;兼容性上桌面主流浏览器已支持,目标环境(尤其老旧 WebView)要先确认再做主路径,否则把它当增强特性。是否需要离屏,先用 Performance 面板确认绘制确实是瓶颈——大多数业务图表到不了这个量级。
devicePixelRatio:清晰度处理
CSS 像素和物理像素是两回事。canvas 的 width/height 是物理像素属性,不处理 DPR 的话,高分屏上一切绘制都是模糊的:
function fitCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2); // 封顶 2:3 倍屏按 2 画,视觉差异小、成本减半
const rect = canvas.getBoundingClientRect(); // CSS 尺寸
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后全部按 CSS 像素坐标绘制
}
配合 ResizeObserver 在容器尺寸变化时重设并整帧重画,尺寸变化时 canvas 会清空,不存在"增量补画"的捷径。DPR 封顶值是画质与性能的权衡点:3 倍屏全开时物理像素数是 1 倍屏的 9 倍,逐像素操作(getImageData)的成本同步放大。SVG 没有这个问题——它按需栅格化,天然清晰。
导出:toBlob、toDataURL 与 taint
导出图片有两条路:
// 推荐:toBlob 异步生成 Blob,不用 base64 膨胀内存,可直接下载或上传
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'board.png';
a.click();
URL.revokeObjectURL(url); // 用完立刻释放
}, 'image/png');
// JPEG 加质量参数:'image/jpeg', 0.85;带透明通道必须用 PNG 或 WebP
toDataURL 是同步的,返回 base64 字符串,大画布会长时间阻塞主线程且体积膨胀约三分之一,只适合小图内嵌(如缩略图预览)。
taint(跨域污染)是最常见的生产事故:一旦向 canvas 画了未经 CORS 授权的跨域图片,canvas 就被"污染",之后调用 toBlob/toDataURL/getImageData 全部抛 SecurityError——画得出、存不下:
const img = new Image();
img.crossOrigin = 'anonymous'; // 前提:图片服务器返回允许的 CORS 响应头
img.src = '/cdn-cors-example/photo.png';
// 若图片服务不允许 CORS,crossOrigin 反而会直接加载失败——
// 这时要走服务端代理同源化,而不是去掉 crossOrigin 硬画(画上去就把画布污染了)
结论:需要导出/读像素的画布,所有图片来源必须同源或明确 CORS;外部 URL 图片在画之前先确认策略,出问题的表现不是画错而是导出时报错。
注意事项与边界
- Canvas 内容对读屏器不可见,
aria-label只能贴在 canvas 元素上;信息图表优先 SVG 或 HTML,或者在旁边给一份表格/摘要。 requestAnimationFrame回调频率与显示刷新率绑定,高刷屏上"每帧逻辑"的执行频率可能是 60Hz 屏的两倍,动画要用时间差驱动。save/restore不配对是常见 bug 来源,异常路径(提前 return)也要恢复状态,否则后续绘制全部跑偏。- Worker 内的绘制无法访问 DOM,交互反馈(tooltip、hover)要么继续留在主线程做命中,要么设计好消息协议。
- 分层后
ResizeObserver一次回调要重设所有层,漏掉一层会出现部分清晰部分模糊的"经典"截图。 toBlob的回调可能为 null(画布为空等边界),空值处理要显式写。- iOS Safari 对 canvas 最大尺寸有限制(宽度与总面积都有上限),超大画布要分块或降低 DPR,具体阈值随系统版本变化,以实测为准。
- 导出前确认没有运行中的动画帧污染画面(残影、未画完的进度),必要时冻结一帧再导出。
总结
选型先看数量级与事件粒度:文档型、可交互、要缩放的图形给 SVG;高频重画、大规模、像素级操作给 Canvas。Canvas 侧的三个关键习惯:脏标记按需重画、状态分组少用栈、DPR 用 setTransform 归一。往上再走两层优化是分层 canvas 拆静态动态、OffscreenCanvas 移入 Worker——但每一层都要先有 Performance 录制证明瓶颈确实在那,导出场景则永远先想 taint。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。