上个月我们在做一个微服务拓扑可视化需求:在一个面板里画出整个集群的服务依赖图,节点数从初版的 200 个一路涨到 5000 个,每条边代表一次调用关系。初版我们顺手用了 SVG + D3,开发很快,但节点超过 800 个后页面开始明显卡顿,拖拽时帧率掉到 12 FPS。于是我们重新做了一次选型评估,最终切到 Canvas + 自研拾取层,5000 节点稳定 60 FPS。
这篇文章整理这次选型的全过程:两种渲染机制的差异、不同节点量级下的性能基准、各自的优劣、以及什么场景该选什么,最后聊聊折中方案。
一、渲染机制:保留模式 vs 立即模式
Canvas 和 SVG 最本质的区别是渲染模式,理解这一点比记住任何性能数字都重要。
1.1 SVG 是保留模式(Retained Mode)
SVG 把每个图元作为 DOM 节点保留在文档树里。你画一个圆,就是往 DOM 里塞了一个 <circle>,浏览器维护它的状态、样式、事件。你改它的属性,浏览器自动重绘对应区域。
<svg viewBox="0 0 800 600">
<circle cx="100" cy="100" r="20" fill="#6d5efc"
onclick="selectNode('svc-a')"/>
<line x1="100" y1="100" x2="300" y2="200" stroke="#ccc"/>
</svg>
好处是“声明即所得”,事件、样式、可访问性都天然继承 DOM 能力。坏处是每个图元都是 DOM 节点,DOM 节点是有成本的:一个 <circle> 在 V8 里大约占 200–500 字节,5000 个节点加 8000 条边就是 1.3 万个 DOM 节点,光内存就上百 MB,更别说每次重排重绘的开销。
1.2 Canvas 是立即模式(Immediate Mode)
Canvas 是一块位图画布,你用命令往上面“画”,画完之后浏览器不保留任何图元状态,只保留最终像素。要交互,就得自己实现“这个点落在哪个图元上”的判定(即拾取 hit-testing)。
const ctx = canvas.getContext('2d');
function drawNode(node) {
ctx.beginPath();
ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2);
ctx.fillStyle = node === selected ? '#6d5efc' : '#a9a4ff';
ctx.fill();
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
edges.forEach(drawEdge);
nodes.forEach(drawNode);
}
好处是没有 DOM 开销,画 5 万个图元也只是一次像素写入;坏处是你得自己管一切:状态、事件、可访问性、文字测量。
二、性能基准对比
我们在同一台机器(M2 Pro,Chrome 124,2x DPR,800×600 画布)上跑了一组对比,用力导向布局稳定后的静态帧渲染 + 一次拖拽重绘。节点为圆,边为直线,无文字。
| 节点数 / 边数 | SVG FPS(静态 / 拖拽) | Canvas FPS(静态 / 拖拽) | SVG 内存 | Canvas 内存 |
|---|---|---|---|---|
| 100 / 150 | 60 / 60 | 60 / 60 | 12 MB | 8 MB |
| 500 / 800 | 58 / 41 | 60 / 60 | 28 MB | 9 MB |
| 1000 / 1800 | 42 / 18 | 60 / 59 | 54 MB | 11 MB |
| 2000 / 3500 | 24 / 8 | 60 / 56 | 108 MB | 14 MB |
| 5000 / 8000 | 9 / 2 | 60 / 52 | 276 MB | 22 MB |
结论很清晰:
- ≤ 500 节点:两者都流畅,SVG 开发成本更低,选 SVG。
- 500 – 1000 节点:SVG 静态还行,拖拽开始掉帧,看交互复杂度决定。
- > 1000 节点:SVG 已不可用,必须上 Canvas 或 WebGL。
SVG 卡顿的根因不是绘制本身,而是 DOM 树过大导致的 layout/paint 开销,以及 CSS 属性变更触发的样式重计算。Canvas 内存基本只随画布尺寸增长,与图元数关系不大。
三、SVG 的优势与劣势
3.1 优势
- DOM 可访问性:天然支持屏幕阅读器,图元可加
<title>、<desc>、role,对无障碍场景友好。 - CSS 样式:能用类名、伪类、媒体查询统一控制样式,和设计系统对接顺畅。
- 事件绑定简单:
addEventListener直接绑到图元上,hover、click、focus 开箱即用。 - 矢量缩放:任意放大不糊,适合打印、导出 PDF。
- 声明式可调试:DevTools Elements 面板能直接看到每个图元,排查问题快。
3.2 劣势
- 节点多时性能差:DOM 操作是性能黑洞,1000 节点以上基本不可用。
- 复杂滤镜昂贵:
filter、mask、阴影在大量图元上会触发逐帧重绘。 - 动画成本高:每个动画图元都要走 CSS/SMIL,大量并发动画会拖垮主线程。
四、Canvas 的优势与劣势
4.1 优势
- 大数据量:5000 节点轻松 60 FPS,瓶颈在算法不在渲染。
- 像素级控制:渐变、合成、自定义着色都能精确控制,适合热力图、粒子效果。
- 内存占用低:不维护图元树,只存原始数据和最终像素缓冲。
- 动画性能高:
requestAnimationFrame整体重绘,主线程压力可控。
4.2 劣势
- 无 DOM:DevTools 看不到图元,调试要靠日志或自绘调试层。
- 可访问性差:画布对屏幕阅读器是不可见黑盒,需额外的 ARIA 描述层。
- 交互需自己实现拾取:点击一个点,要自己判断落在哪个节点上。
- 缩放会糊:位图放大有锯齿,需配合
devicePixelRatio或重绘策略。
4.3 Canvas 拾取实现
拾取是 Canvas 选型后绕不开的活。最简单的是几何判定,对每个节点做距离比较:
function hitTest(px, py) {
// 倒序遍历,后绘制的在上层
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
const dx = px - n.x, dy = py - n.y;
if (dx * dx + dy * dy <= n.r * n.r) return n;
}
return null;
}
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) * dpr;
const y = (e.clientY - rect.top) * dpr;
const hit = hitTest(x, y);
if (hit) selectNode(hit);
});
节点 5000 个时线性扫描每帧约 0.05ms,可接受。更复杂场景(不规则图形、重叠图元)可以用 颜色编码拾取:在离屏 canvas 上用唯一颜色重绘每个图元,点击时读 getImageData 的像素颜色反查图元 ID,复杂度 O(1)。
五、决策清单:什么场景选哪个
把上面的分析收敛成一张可执行的清单。按你的需求对号入座:
| 需求特征 | 推荐方案 | 理由 |
|---|---|---|
| 图元数 < 500,强可访问性 | SVG | DOM 事件和无障碍原生支持,开发快 |
| 图元数 500–2000,中度交互 | SVG + 虚拟化 / Canvas 分层 | 视口外图元不渲染,降低 DOM 压力 |
| 图元数 > 2000,需流畅交互 | Canvas 2D + 自研拾取 | 立即模式渲染,无 DOM 开销 |
| 图元数 > 5 万 或需 3D | WebGL(deck.gl / PixiJS) | GPU 并行渲染,CPU 方案已到极限 |
| 需要打印 / 矢量导出 | SVG | 矢量缩放不糊,导出 PDF 质量高 |
| 热力图 / 粒子 / 像素效果 | Canvas / WebGL | 像素级控制,逐像素写入高效 |
六、折中方案:分层与 WebGL
现实里很少是纯 SVG 或纯 Canvas 的二选一,更多是混合。我们最终方案是“Canvas 主体 + SVG 覆盖层”:节点和边用 Canvas 画(扛量),交互高亮、悬浮 tooltip、临时标注用 SVG 覆盖在 Canvas 上层(扛交互)。两者坐标系对齐,用户感知不到分层,但开发上各取所长。
6.1 Virtual DOM 渲染层
如果团队已经在用 React,又不想直接写命令式 Canvas,可以选 React Lazy Grid 这类把可视区图元虚拟化的库。核心思路和长列表虚拟滚动一致:只把视口内的图元渲染成 DOM/SVG,视口外的用替身图元。能在不切 Canvas 的前提下把 SVG 的可承载量从 1000 推到 5000 左右,代价是滚动时有重绘抖动,需要配合 content-visibility: auto 优化。
6.2 WebGL:PixiJS 与 deck.gl
当节点数奔着 10 万、100 万去时,Canvas 2D 也撑不住,得交给 GPU。两条主流路线:
- PixiJS:2D 渲染引擎,封装了 WebGL 的复杂度,API 接近 Canvas 2D 但走 GPU。适合游戏化可视化、复杂 2D 交互场景,5000–50 万节点量级。学习曲线平缓,能渐进迁移。
- deck.gl:Uber 开源的地理/科学可视化框架,基于图层(Layer)抽象,内置 scatterplot、arc、hexagon 等图层,适合地图叠加、大规模点云。百万级点也能流畅,但抽象偏重,简单拓扑图用它有点杀鸡用牛刀。
我们评估后没有上 WebGL,因为 5000 节点 Canvas 2D 还能稳 60 FPS,多引入一层 GPU 依赖会带来首屏体积(deck.gl 压缩后约 350KB)和兼容性成本。但如果未来节点量翻十倍,WebGL 是必然路径,现在拾取层和坐标系的抽象已经为迁移留好了口子。
七、最终架构与一些经验
我们最终的拓扑图架构:
- 渲染层:Canvas 2D,双层 canvas(一层静态节点/边,一层动态高亮/选区),减少全量重绘。
- 状态层:选中的节点、hover 态、过滤条件存在纯 JS model 里,渲染读 model。
- 拾取层:几何距离判定 + 颜色编码兜底,用于不规则图形。
- 交互层:SVG 覆盖层负责 tooltip、右键菜单、临时标注线。
- 无障碍:在 canvas 外维护一个 visually-hidden 的 ARIA live region,拓扑变化时播报节点数和选中节点名。
几条踩出来的经验:
- 不要一上来就 Canvas:500 节点以下 SVG 开发效率碾压,过早切 Canvas 是给自己挖坑。
- 分层重绘:静态层只在数据变化时重绘,动态层每帧重绘,避免无谓的 60 FPS 全量绘制。
- resize 必须处理 DPR:
canvas.width = rect.width * dpr再ctx.scale(dpr, dpr),否则高分屏糊成马赛克。 - 拾取优先用几何,别用 getImageData:后者每帧读 GPU 数据,性能比几何判定差一个数量级,只在形状复杂时用。
- 无障碍不能忘:Canvas 的可访问性不是“做不到”,是要额外做。给关键图元补 ARIA 描述,比事后被合规找上门强。
八、写在最后
选型没有银弹,只有“在你的节点量、交互复杂度、团队栈下最不痛的方案”。SVG 赢在开发效率和可访问性,Canvas 赢在性能和可控性,WebGL 赢在极限规模。先把需求里的节点量级和交互要求量化出来,对照上面的决策清单,答案基本就出来了。
下一篇会写 Canvas 拾取的进阶:颜色编码拾取在 10 万节点下的工程实现,以及如何用 Web Worker 跑布局算法把主线程解放出来。