文章

SVG 与 Canvas 选型:别问哪个更好,问四个约束

选型题考的不是"你背过几张对比表",而是你能不能说清四个约束:元素数量与更新频率、 单元素交互与可访问性、无损缩放、像素级能力。文章给一套可以当场复述的决策顺序, 外加三种真实项目里的混合方案,和换渲染器之前该做的准备。

SVG 与 Canvas 选型:别问哪个更好,问四个约束

一句话概括

SVG 和 Canvas 不是”两个画图工具选一个”,而是”图形要不要作为文档的一部分存在”。

SVG 里每个图形都是真 DOM 节点,所以样式、事件、无障碍、文字选中、SEO 全都是白送的;Canvas 是一整块像素位图,上面没有”对象”这个概念,浏览器只记得最后一个 <canvas> 元素,别的一概不知。选型就是问:我需不需要”每个图形都还是文档里一个可寻址的东西”?

面试官问”你项目里怎么选”,最差的答案是”数据多就用 Canvas”——这句话没错但没信息量。好的答案是给一串有顺序的约束,撞到哪一条就换档:

1
2
3
4
5
6
7
8
9
问 ① 元素数量 × 更新频率   扛得住 → 继续问 ②
问 ② 每个图元都要独立交互/可访问?
问 ③ 要打印 / 任意缩放不失真?
问 ④ 要读像素 / 混合模式 / 位图处理?

四条都"不需要换" → SVG(默认,开发成本最低)
① 扛不住(上千图元还在动) → Canvas 2D
① 再次扛不住(十万级以上、或要 3D / 着色器) → WebGL
②③④ 里只要有一条命中 → 基本就是 SVG 或 Canvas,没有别的选项

默认从 SVG 开始,因为它省下的是开发成本,而 Canvas 省下的是渲染成本——前者你天天在付,后者往往一辈子都撞不到。下面把四个约束逐个说清,再讲真实项目里的混合方案和迁移成本。

核心知识点

1. 约束一:元素数量 × 更新频率(真正的分界线是”每帧碰不碰”)

先说数字,这是面试会被追问的部分:

场景大致区间常用方案
图标、流程图、几十到几百个图元的图表几十 ~ 千级SVG
静态图表、地图(交互元素少)千级以内SVG 没问题
频繁全量重绘(粒子、实时数据流)千 ~ 十万级Canvas 2D
十万以上、或需要 3D / 着色器十万 ~ 百万级WebGL

但别把这张表当答案背。真实的分界不是”数量”,而是「这些元素每帧会被改吗」:

  • 1000 个静态 <circle> 放在那不动:几乎零成本,浏览器不用重新算样式和布局。
  • 1000 个 <circle> 每帧改 cx:直接卡死,因为你每帧都在触发 style → layout → paint。

为什么改 SVG 属性这么贵?因为你要动的是 DOM:改属性 → 样式重算(Recalculate Style)→ 布局(Layout)→ 重绘(Paint)→ 合成。而 Canvas 改一个点,只是重画像素,但它每次都要把你的整段绘制 JS 从头跑一遍。所以两边各有各的贵,只是贵的阶段不同。

一句话面试口径:

SVG 的成本随”节点数 × 改动次数”涨;Canvas 的成本随”每帧重绘的图元数”涨,但只有一次固定的 DOM 成本。

怎么测:别猜。Performance 面板录一段,看是 Recalculate Style / Layout 占比高(→ 该换 Canvas),还是你自己的 JS 函数占比高(→ 换渲染器没用,先优化算法)。

2. 约束二:单元素交互与可访问性(Canvas 隐性成本最高的一环)

这一条最容易被忽略,但面试官最爱在这里追问,因为它是”能不能省事”的分水岭。

能力SVGCanvas
单元素事件直接 addEventListener只能给整个 canvas 挂,自己做命中检测
CSS 状态样式:hover / :focus / :active 全可用没有元素,只能自己算状态再重画
屏幕阅读器天然可读,可加 aria-label / <title>完全不可读,必须额外提供 DOM 兜底
文字可选中 / 可搜索是(真文本)否(像素)
键盘焦点管理浏览器给(可 tabindex、:focus-visible)全靠自己实现
SEO文本可被索引无内容可索引

所以 SVG 那一列是”浏览器帮你做完的活”,Canvas 那一列是”你要自己造一遍的活”。

标准做法是给 Canvas 配一份 DOM 兜底:屏幕阅读器读不了的图,就在旁边同步渲染一张真的 HTML 表格 / 列表,键盘也能按同一份数据操作。这不是”补 a11y 交差”,而是可访问性规范对 <canvas> 的通用建议。同理,Canvas 图表的 tooltip 不要自己算再画到 canvas 上——用一个绝对定位的 SVG 或 HTML 覆盖层,鼠标进出、样式、文字换行全都变得顺手(见第 5 节的混合方案)。

判断标准很简单:每个图元都需要被人单独点/读/选吗? 是 → SVG 几乎没有悬念。

3. 约束三:无损缩放(SVG 唯一无法被替代的一项)

“SVG 放大不糊”不是玄学,是 viewBox 决定的:

1
2
3
<svg viewBox="0 0 100 100" width="300" height="300">
  <circle cx="50" cy="50" r="40" />
</svg>

viewBox="0 0 100 100" 定义的是用户坐标系——你的圆永远画在 100×100 的逻辑空间里;width/height 只是”把这个逻辑空间映射到多大”。浏览器在渲染时才做这次映射,所以放到 300px、3000px 都是重新算几何,没有”像素被拉大”这回事。

配套的 preserveAspectRatio 控制”逻辑空间和视口比例不一致时怎么办”,值由两部分组成,默认是 xMidYMid meet:

  • meet(默认):等比缩放,保证 viewBox 完整可见,视口多出来的地方留白。
  • slice:等比缩放,保证视口被填满,viewBox 超出的部分被裁掉(做”背景图 cover”效果时用)。
  • 一个前提:这个属性只在元素设了 viewBox 时才起作用;没设 viewBox 时写它等于没写(<feImage> 例外)。

✅ 一句话记住:meet = 看得全(可能留白),slice = 铺得满(可能裁切)。

Canvas 这边正好相反:它是一块固定像素缓冲,不手动乘 devicePixelRatio 一定糊,乘了之后绘制面积按 dpr² 增长(3 倍屏就是 9 倍像素),所以实践中通常封顶到 2。

什么时候这条约束会决定选型?

  • 要打印 / 导出 PDF / 在任意缩放下看:SVG 基本没有替代品。
  • 投屏、大屏、固定分辨率场景,还要频繁动:这条约束让位给约束一,选 Canvas,糊不糊无所谓。
  • 一个高频追问:SVG 是不是”性能也和分辨率无关”? 不是。缩放不影响清晰度,但会影响绘制成本——3 倍屏上浏览器要多光栅化 9 倍像素,元素一多照样会掉帧。

4. 约束四:像素级能力(Canvas 反过来独占的部分)

前面三条都是”SVG 能,Canvas 要自己造”,这一条是倒过来的——Canvas 有四件事 SVG 做不到或做起来很难受:

  1. 逐像素读写:getImageData / putImageData / ImageData,做灰度、阈值、颜色查找表、去背景。SVG 没有”像素”这个概念。
  2. 混合模式:globalCompositeOperation 的 multiply / screen / destination-out 等,做橡皮擦、蒙版、图层叠加。
  3. 位图 / 视频帧混合再输出:drawImage(video) 抽帧、截图、加水印、导出图片。
  4. 性能上限:Canvas 的绘制命令直接进光栅化,不需要为每个图元维护 DOM 节点。

SVG 也有 <filter>(feGaussianBlur 等)能做模糊和阴影,但滤镜是 SVG 里最贵的部分之一,移动端和元素一多时慎用;而”读像素自己算”这类需求,SVG 根本没有对应的路。

所以图像编辑器、涂鸦画板、视频抽帧、滤镜类需求,根本不存在选型问题,只有 Canvas(量再大就往上走 WebGL)。

5. 真实项目都是分层的:三种混合方案

这是这篇文章最想让你带走的部分——生产环境里几乎没人二选一,都是分层的:

① 打底层 + 覆盖层:Canvas / WebGL 画高频变化的密集数据,SVG / HTML 叠在上面画坐标轴、图例、tooltip、选中框。

1
2
3
4
5
6
<div class="chart">
  <!-- 密集点走 Canvas,一次重绘 -->
  <canvas id="points"></canvas>
  <!-- 少量需要交互/清晰的元素走 SVG,叠在上层 -->
  <svg id="overlay"><!-- 坐标轴、选中框、tooltip --></svg>
</div>
1
2
.chart { position: relative; }
.chart > * { position: absolute; inset: 0; } /* 两层严格重叠,坐标才不会错位 */

这套是地图和大屏可视化的主流做法:底图上万个点 → WebGL/Canvas;几十个标记和标签 → SVG,因为标记要能点、要有 hover、要能被读屏软件读到。

② SVG 当”数据源”,Canvas 当渲染器:SVG 的 d 字符串可以直接喂给 Canvas,这是迁移成本最低的一条路。

1
2
3
4
// 同一个贝塞尔路径字符串,两边都能用:SVG 里写进 <path d="...">,Canvas 里这样:
const p = new Path2D('M10 10 h 80 v 80 h -80 Z'); // 接受 SVG path data
ctx.fillStyle = '#f60';
ctx.fill(p); // 路径可以缓存复用,每帧只换变换和样式

记忆点:new Path2D(d) 的参数就是 SVG path data 字符串,支持 addPath 合并、可缓存。做图标或复杂路径时,先在 SVG 里调好 d,再把它搬到 Canvas 里用——一次设计,两处渲染。

③ 同一套 API 挂两套渲染器——ECharts 就是标准案例,面试里提到它非常加分:

1
2
// 默认 Canvas;换 SVG 只需一个参数
const chart = echarts.init(el, null, { renderer: 'svg' }); // 或 'canvas'

ECharts 能做到这一点是因为底层 zrender 把图形抽象成了与渲染器无关的图元树。官方给出的选型经验(可以直接复述):

  • 数据量 > 1k,一律推荐 Canvas;元素多且带视觉特效(热力图、地理/平行坐标上的大规模散点折线)也是 Canvas。
  • 反过来,低端安卓机、需要同时创建大量图表实例(浏览器因 Canvas 数量过多而崩溃)、水球图这类特定图表,SVG 渲染器反而更好——内存占用更小、放大不糊。
  • 从 v5.3.0 起,ECharts 的 SVG 渲染器改用虚拟 DOM 重构,性能提升 2~10 倍(部分场景更多)。
  • 有几个效果目前仍只支持 Canvas:尾迹特效、带混合效果的热力图等。
  • 服务端渲染也吃这个选型:echarts.init(null, null, { renderer: 'svg', ssr: true, width, height }) + renderToSVGString() 直接吐 SVG 字符串,体积比图片小、不模糊、还能带初始动画。

6. 迁移成本:换渲染器之前,先把渲染层隔离出来

上面第 ③ 种方案能成立,前提是“要画什么”和”怎么画”解耦了。所以”以后可能要换”这件事,应该在架构上提前留口子:

  • 把数据模型 + 视图状态(谁被选中、当前缩放、悬停对象)放在渲染器之外,渲染器只负责读状态画出来。这样从 SVG 换成 Canvas,业务侧一行不用改。
  • 先只换最贵的那一层。全量迁移风险大,通常只需把”每帧都在动的那一层”从 SVG 换成 Canvas,静态部分留着,收益就有 80%。
  • 反过来,SVG 的特有特性一旦用上就换不掉了:<use> 精灵、SMIL 动画、CSS 过渡、<filter>。选型时要意识到这一点。

<use> 值得单独说,因为图标系统天天在用,而且它的三个真相很能体现功底:

1
2
3
4
5
6
7
<!-- 定义一次,不渲染 -->
<svg style="display:none">
  <symbol id="icon-home" viewBox="0 0 24 24"><path d="..." fill="currentColor"/></symbol>
</svg>

<!-- 引用多次,只花一次 HTTP 请求 -->
<svg class="icon"><use href="#icon-home"/></svg>
  • <use> 的行为按 MDN 原文是:把节点深度克隆进一个”不对外暴露的 DOM”再贴到 <use> 所在位置。也就是说克隆体在 shadow tree 里,外部 CSS 选择器够不着它内部的元素;想改色只能靠继承——被引用图形里写 fill="currentColor",然后用父级 color 控制。
  • <use> 自身的属性大多数会被被引用元素上的同名属性压掉,只有 x / y / width / height / href 例外;width/height 还只在引用的目标带 viewBox(即 <svg> 或 <symbol>)时才有用。
  • 跨域 href 受同源策略限制,浏览器可以直接拒绝加载。

收益是实打实的:一个真实案例里 50 张图表各自重复声明页脚,光这部分就让页面涨到 5951 个 DOM 节点,抽成 <symbol> 后用 <use> 引用之后大幅下降。但代价是:这套图标系统被锁在 SVG 里了。

7. 面试怎么答(口述版)

面试官:”SVG 和 Canvas 你怎么选?”

“我先看四个约束。 第一,元素数量和更新频率——几百个元素、很少改动,SVG 完全够;一旦上千个元素每帧都要动,我就换 Canvas,因为改 SVG 属性会走 style→layout→paint,成本随节点数涨。 第二,要不要单元素交互和可访问性——SVG 每个图形是真 DOM,事件、CSS hover、读屏软件、文字选中全是白送的;Canvas 只有一块位图,命中检测、键盘焦点、ARIA 都得自己造,所以我会配一份同步的 HTML 表格做兜底。 第三,要不要无损缩放——要打印、导出、任意缩放,SVG 靠 viewBox 直接搞定,Canvas 得自己乘 dpr。 第四,要不要像素级操作——读像素、混合模式、位图处理,那就只能 Canvas,反过来用 WebGL。 现实项目里我基本是分层的:Canvas 画密集数据,SVG 或 HTML 做覆盖层,坐标轴、tooltip、选中框都放覆盖层。像 ECharts 直接把两套渲染器都封好了,renderer: 'svg' | 'canvas' 一个参数切换,官方经验是数据量上千就上 Canvas,但低端机或者要开很多实例的时候 SVG 反而更省内存。”

这个回答里没有一句是背下来的形容词,全是”因为 A 所以 B”,面试官很难继续追问到死角。

其实你每天都在用

  • 后台系统的图标:一份 SVG sprite + 一堆 <use>,改主题时全站图标跟着 currentColor 变色。
  • ECharts / AntV 图表:默认 Canvas,renderer: 'svg' 一改就能导出清晰矢量图给设计。
  • 地图组件:底图切片和高密度点走 Canvas / WebGL,几十个 marker 和 label 走 SVG 或 HTML。
  • 图表 hover 时才出现的 tooltip:基本都是 HTML/SVG 覆盖层,不是画在 canvas 上的。
  • 可编辑的流程图 / 思维导图:节点是 SVG(要拖、要连、要选中),连线多了才考虑把线也画到 Canvas 上。
  • 图片裁剪、涂鸦标注、截图加水印:<canvas> + getImageData / drawImage,无替代。
  • 大屏左上角的装饰边框:大部分是 SVG 或 CSS 画的——静态元素用 Canvas 反而不好维护。

常见误解(FAQ)

❌ 误区1:”Canvas 一定比 SVG 快。” 不是。元素少、改动少时 SVG 往往更快也更好维护。ECharts 官方文档里就明确写了,在低端安卓设备上 SVG 渲染器表现可以更好,因为它内存占用更小。真正的分界是”节点数 × 改动频率”,而且 Canvas 也要你自己控制每帧重绘量,写不好一样掉帧。

❌ 误区2:”SMIL 已经被浏览器删掉了。” 这是把”曾经打算废弃”记成了”已经废弃”。2015 年 Chrome 确实提过 Intent to Deprecate,但在 2016 年底就把这个决定撤回了,至今 Chrome / Firefox / Safari 都还在支持。准确的现状是:SMIL 在 SVG 2 规范里已不再推荐,新项目应该优先用 CSS 动画或 Web Animations API。SMIL 唯一不可替代的场景是——动画要写在 .svg 文件里,通过 <img src> 或 CSS background 引用,那时 CSS 和 JS 都够不到里面。答”SMIL 早被删了”是硬伤。

❌ 误区3:”SVG 是矢量的,所以跟分辨率完全无关。” 只有”清晰度”无关。绘制成本照样受分辨率影响:3 倍屏要多光栅化 9 倍像素,元素多了同样掉帧。别用”矢量”当性能挡箭牌。

❌ 误区4:”用 Canvas 就没法做无障碍了。” 能做,只是要自己搭:一份同步的 HTML 表格 / 列表兜底、aria-live 播报关键变化、别只用颜色编码、给缩放和选中配键盘操作。能做的事不等于免费的事——这才是选型的真实代价。

❌ 误区5:”选型是一次性决定。” 图表从 200 个点长到 20 万个点,选型就该变。可维护的写法是:数据模型和视图状态放在渲染器外层,渲染器当”插件”换。分层替换(只把最热的那层换掉)通常比整体重写划算得多。

❌ 误区6:”WebGL 是 Canvas 的升级版,能替代 Canvas。” WebGL 只画点、线和三角形,没有现成的圆、虚线、文字、路径裁剪、阴影,这些全要自己用三角形拼或用纹理贴。画个折线图用 WebGL 是自找麻烦。它解决的是”Canvas 2D 每帧在 CPU 上循环太重”的问题,不是”Canvas 2D 不够好”的问题。

一句话总结

选型就是问一句:这个图形需不需要”还作为文档里一个能点、能读、能缩放的独立对象存在”——需要就 SVG,只是高频变化的像素就 Canvas,百万级还在动就 WebGL,而真实项目里这三层通常是叠在一起的。

本文由作者按照 CC BY 4.0 进行授权