编辑器性能:几万块积木的工作区
一个真实作品,单个角色里有 19899 个积木。在原来的编辑器里,平移一下要 140 毫秒一帧、 滚轮缩放一下要 1.7 秒,基本无法使用。现在这些手势都在一帧 17 毫秒以内。
这一篇讲编辑侧做了什么、效果如何、以及哪些地方仍然慢。运行时(跑起来之后的帧率、 克隆体、编译器)是另一篇:运行时性能。
完整报告和复现步骤在积木库仓库里:gandi-blocks/PERFORMANCE.zh-CN.md。下面的数字都出自
那份报告:同一台机器、同一套脚本、前后两棵源码树背靠背测出来的。
结果#
真实作品,单个角色 19899 个积木
| 指标 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| DOM 节点数 | 106690 | 3345 | 32× |
| 平移(帧时间中位数) | 139.7 ms | 16.7 ms | 8.4× |
| 滚轮滚动(中位数) | 149.8 ms | 15.1 ms | 9.9× |
| 滚轮缩放(中位数) | 1679.1 ms | 17.3 ms | 97× |
| 拖动单个积木(中位数) | 16.7 ms | 16.7 ms | — |
| 加载耗时 | 11.8 s | 6.8 s | 1.7× |
单独拖一块积木本来就不慢,所以那一行没有变化,这也说明测的确实是「文档太大」这个问题。
把一个积木拖进 27680 单位高的长脚本并放下
| 指标 | 优化前 | 优化后 | 倍数 |
|---|---|---|---|
| 帧时间 中位数 | 52.9 ms | 16.7 ms | 3.2× |
| 帧时间 p95 | 176.0 ms | 64.4 ms | 2.7× |
| 帧时间 最差 | 1298.3 ms | 193.7 ms | 6.7× |
| 放下(同步阻塞) | 1325.6 ms | 266.4 ms | 5.0× |
| 放下到下一帧 | 4471.6 ms | 330.6 ms | 13.5× |
| DOM 节点数 | 106695 | 3138 | 34× |
另外两个规模
| 作品 | DOM 节点 | 滚轮缩放 | 加载 |
|---|---|---|---|
| 8000 个积木、134 条脚本 | 37868 → 3705(10×) | 520.4 → 16.7 ms(31×) | 2949 → 1349 ms |
| 单串 4000 个积木的长脚本 | 18741 → 598(31×) | 396.8 → 16.6 ms(24×) | — |
单串那个作品还有一条:文档规模不再随滚动深度增长。以前视口往下移,节点数会一路涨到 18741;现在在任意深度都稳定在 372 到 541 之间。
怎么做到的#
看不见的积木有三种#
next 连着的积木在 DOM 上是上一块的子节点,所以「看不见就摘掉」这一句话要拆成三件事:
| 情况 | 机制 |
|---|---|
| 整条脚本在屏幕外 | setIntersects:把脚本的 <g> 整个摘掉 |
| 长脚本视口以下的尾部 | setTailCull:在视口下方第一个积木处摘除,整条尾巴跟着走 |
| 长脚本已经滚过去的上半部分 | setBlanked:剥掉积木自身的轮廓和字段,group 留下来当容器 |
关键是摘出文档,而不是 display: none:浏览器对隐藏的 SVG 子树照样跑文字排版,
那部分开销一点也省不下来。
途中走错过两条路,都留在报告里:
- 只往下裁会把视口上方的东西全留在文档里,于是长脚本越往下滚越卡。
setBlanked正是 为此而来:上方的积木不能摘(下面的都挂在它身上),但它自己的绘制可以去掉。 - 只沿 next 链走对大脚本没用:一个帽子积木的 next 链可能只有两块,三千个积木全在一个
if里面。现在会按偏移递归下降到语句输入,切点可以落在子栈内部。
让「判断该裁谁」本身也便宜#
判断要裁什么得把整条脚本走一遍。7000 个积木的脚本上这一趟是 19 毫秒,而每个滚动帧要跑两次: 一个 60 毫秒的滚轮帧里有 40 毫秒是 JavaScript。两个办法:
- 窗口迟滞:每次多裁一点余量,视口没滚出这个范围就复用上次的结果。以工作区几何代数为 键,所以编辑积木仍然会让它失效。
- 增量应用:应用结果比算结果更贵。旧代码是「全部还原 → 整套重建」,为了把边界挪一两块 积木做了几千次 DOM 操作。现在 blank 用标记 token 做差分,切点没变就完全不动,而大多数 帧都是这种情况。
迟滞值要小。多裁的余量是拿「少走几趟」换「合成器多扛的内容」,一旦差分让走一趟变便宜, DOM 规模就成了主导。在 19899 积木那个作品上滚轮滚动:
| 迟滞 | 平均帧 | p95 | DOM 峰值 |
|---|---|---|---|
| 0(每帧都走) | 33.4 ms | 45.6 ms | 5293 |
| 120 | 21.1 ms | 34.3 ms | 5517 |
| 200 | 19.6 ms | 36.4 ms | 5723 |
| 600 | 20.0 ms | 45.7 ms | 7174 |
| 1600 | 21.3 ms | 69.4 ms | 10461 |
净效果:这个作品的滚轮滚动中位数从 57.8 ms 降到 16.4 ms,每帧 JavaScript 从 40.0 ms 降到 2.5 ms。
另外四件#
- 文字层:同屏的积木文字多到几百个时,字段的文字改画在工作区共享的一层上,不再是每个字段一个
<text>。文字少的时候积木照常自己画字,和积木栏里一模一样。 - 平移和缩放跑在合成层上,手势期间不重排。
- 文字测量用 canvas,不再靠
getComputedTextLength触发布局刷新。这也是裁剪能成立的 前提:一块被摘出文档的积木没有布局,宽度必须能离线算出来。 - 连接点数据库早退:没动过的连接点不再做两次 O(n) splice。
打开大作品#
测试作品 47 MB,5 个角色,1941 个 SVG 造型、73 段音频(共 47 分钟)、25600 个积木。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 从选文件到可以操作 | 50 秒以上 | 4.7 ~ 5.6 秒 |
关键一条:造型等到要显示它的时候才解码成图片。一个角色同时只用一件造型,另外 1935 件 从头到尾没有显示过。
切换角色:点开那个 25600 积木的大角色,工作区按帧填、先近后远(按离视口的距离排序), 0.56 秒就能看到积木并且能拖。
扩展库那一页#
九十多张卡片加一整套动效,在慢一点的机器上会卡。四倍 CPU 降速下逐条量过,有几条结论并不 直观:
- 标题上那条流光是全页最贵的东西。
background-position是重绘属性,背景又被background-clip: text裁成字形,等于每帧把文字重画成遮罩再透一次渐变。它一条就把空闲帧率 从 59 拖到 32。提升图层没有用(成本在重绘不在合成),解法是不让它一直动:9 秒周期里只扫 过 14%。 transition: transform撞上每帧改 transform。卡片的抬起(要过渡)和跟着指针的倾斜 (不该过渡)挤在同一条属性上,指针每动一帧就重起一次过渡。拆成独立的变换属性之后就好了。opacity: 0的装饰元素仍然有开销,九十几份光环和高光条改成display: none,悬停才 显示。backdrop-filter压在会动的背景上要每帧重做,两个元素占掉悬停时 26% 的 CPU;换成稍厚 一点的半透明底色,观感没有差别。content-visibility: auto一开始反而更慢:卡片高度在 316~355 之间飘,contain-intrinsic-size只能给auto,每张卡进出视野都要量一次真高度。把说明固定成两行、 九十三张卡等高之后,这一条才开始有收益。
仍然慢的地方#
- 满屏都是脚本时是 51~60 fps,不是稳定的 60。 剩下的是文字光栅化。隐藏标签能让同一个 手势从 22.1 ms 降到 17.0 ms,但平移时文字消失是肉眼可见的行为改变,所以刻意没有启用。
- 加载 19899 个积木仍要约 5.4 秒,其中约 3.5 秒是积木库自己的 XML 反序列化、约 1 秒是 创建 SVG。
- 密集视口:一个 20299 积木、加载全部角色的作品,0.68 缩放下屏幕上有约 7100 个真实可见的 节点,裁剪无能为力。120 帧的平移里 117 帧低于 20 ms,而所有超过 30 ms 的帧都是裁剪改动了 文档的那些帧:每次挂载或摘除都会让合成层失效、整屏重新光栅化。两个分摊方案(给隐藏方向 加预算、先裁剪再挂载)都实测过,都因为「文档规模涨到三倍」而撤回。
相关#
- 运行时性能 —— 跑起来之后的帧率、克隆体、编译器
- 完整报告:
gandi-blocks/PERFORMANCE.zh-CN.md(含 A/B 对比方法和复现脚本)