zball

2026-08-27

给排版引擎装上编辑器:VSCode 插件与增量重排

这个博客的每一页都由自研引擎排版,但直到昨天,写 .tsm 还是"盲写":改一行源码,得刷新浏览器才能看到断行怎么变。这篇文章记录一天里做完的两件事——给引擎补一条增量重排的快路径,再把它装进 VSCode 的实时预览。文章本身就是用这个插件写的,算是二次自举。

先量化,再动手

优化前先写基准。bench-edit.mjs 合成一篇中英混排、带代码块与公式的长文,模拟编辑会话:改一段、整篇重排、计时,循环二十四次取中位数。基线令人清醒——延迟随文档长度线性增长:

文档规模改造前改造后
7.8K 字符12 ms4 ms
35K 字符42 ms13 ms
87K 字符99 ms30 ms

分阶段计时揭示了三个出乎意料的事实。其一,最大的开销不在排版算法,而在主线程上整页替换 innerHTML——八万七千字符的文档要花约四十五毫秒重建 DOM。其二,wasm 里的断行与布局约三十毫秒。其三,原以为最重的 canvas 逐词测宽,在 Chromium 里其实相当快。不做测量,三个都会猜错。

三层缓存

针对前两个大头,快路径分三层落刀。

度量器常驻。 词宽与字体竖直度量按字体记忆在 worker 级,跨文档存活;新字体落地时整体作废——用回退字体量出的宽度,在真字体到来那一刻就是脏数据。tree-sitter 的代码高亮 token 同样按(语言,代码体)缓存。

断行结果跨文档共享。 Knuth–Plass 动态规划读的只有块的几何量——宽度、空隙宽、断点宽、断点罚分——不含任何文本内容。于是断行结果可以按几何内容的哈希做进程级缓存:

// KP 只读几何,结果按 FNV 哈希跨文档共享
BreakResult breakLinesRetry(blocks, widths, params);

一次击键之后,整篇文档重新走一遍排版管线,但只有被改动的那一段真正跑 DP,其余段落全部命中缓存。八万七千字符文档的引擎耗时从三十毫秒降到七毫秒。

DOM 只补受损段。 排版输出本来就是一段一个容器、段内绝对定位的结构。新旧两版 HTML 按段落边界切块、串比较,找出公共前后缀,只把中间不同的段落换进 DOM——改一段就只碰一个节点,后面的段落靠普通流自己下移。

一次翻车:绝对偏移的教训

补丁机制上线当天就在 CI 翻了车。排版输出的每一行都带 data-s/data-e 源码偏移(锚定用,后面滚动同步全靠它),而偏移是绝对的:在第二段里多打一个字,第三段之后所有行的偏移全部平移,段落串两两不等,逐段补丁退化成整篇替换——本地恰好测过的场景掩盖了它,CI 的断言没放过。

修法是把偏移改为段相对:行上的 data-s 从本段起点数起,段容器另带一个绝对基址 data-s0。未被改动的段落于是字节级一致;基址平移时只需在保留的节点上改写一个属性。教训很老派:凡是想跨编辑复用的产物,内容里就不能混进绝对坐标——和位置无关代码是同一个道理。

插件本体

引擎侧铺平之后,VSCode 插件是相对薄的一层:

  • 语法高亮:语义 token 直接跑引擎同一份 tree-sitter 文法——站点上代码块怎么着色,编辑器里就怎么着色,一份文法两处消费;再配一个极简 TextMate 文法兜底首帧。
  • 实时预览:VSCode webview 的源不允许跨源模块 worker,所以预览页放在插件内置的回环静态服务器上,用 iframe 嵌进 webview——shell.mjs、worker、wasm 原样运行,零打包。击键防抖后走 handle.update(),预览逐段打补丁。
  • 诊断回流:引擎诊断带字节 span,映射成编辑器里的波浪线。
  • 双向定位:预览里双击任意一行跳回源码;编辑器滚动时预览高亮对应段落——全靠上一节的 data-s0 + data-s
  • 打印:预览里直接调分页渲染导出 PDF。
VSCode 插件实时预览截图
图 1:插件预览页排出的文档:断行、公式、代码高亮与增量重排全部生效。

图 1所示,预览页就是一个普通的引擎宿主页面;插件与引擎之间的全部契约,就是 update() 和几个 postMessage 消息。

写作过程暴露的缺口

用自己的工具写文章,最快暴露它缺什么。这篇文章里遇到的:

  • 绘图。上面的性能表格如果画成延迟-规模的折线图会直观得多,但引擎目前只能排入现成的位图。一个 #!plot 区域——数据内联在源码里、构建期生成矢量图、样式与正文字体统一——大概是下一个值得做的 builder。
  • 表格列宽。三列表格现在均分版心,数字列其实应当收窄。列宽策略(按内容、按权重)还是空白。
  • 基准的可复现披露。性能数字想标注环境(机器、浏览器版本),目前只能写进正文;一个语义化的"实验环境"块会更体面。

收尾

一天的产出:编辑延迟降到原来的三分之一以内、一个能实时预览的 VSCode 插件、以及本文。所有改动照旧走全量测试——三百五十八个 golden、二百一十二个浏览器端断言、一百九十九篇语料回归——CI 绿灯之后才落到你眼前这一页。