Gandi 3.0文档
官网 打开编辑器
文档/性能(技术向)

编辑器性能:几万块积木的工作区

一个真实作品,单个角色里有 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 对比方法和复现脚本)