菜虫虫

努力爬行的菜虫虫

基于开源 Oxi:浏览器里做上千页 Word 预览与定位跳转

本文基于开源项目 Oxi(规范仓:GitLab — Ryujiyasu/oxi)做下游产品层优化实践。作者不是原项目核心贡献者,相关优化尚未回馈上游。文中样例仅描述页数与耗时,不包含任何真实业务文档正文。


一、问题从哪来

政企、测评、档案类系统里,Word 经常不是「几十页说明书」,而是数百到一千五百页量级的报告。业务方要的通常是三件事:

  1. 浏览器里直接预览,尽量别先整本转 PDF
  2. 首屏尽快能看,不能干等全文排完
  3. 能定位跳转:按页、按段落、按目录点一下就到

如果仍走「整本文档一次 layout + 一次把全部页甩给 JS + 串行画完所有 Canvas」,在约 1500 页 的样例上,我们实测基线是:

场景 耗时(约) 体感
冷启动(含 WASM 编译 / 字体表初始化) ~25s 长时间白屏或 Loading
热启动(WASM 已热) ~6s 仍要等整本结束才能舒服地看/跳
渲染模型 单线程串行 滚动、跳转容易被排版卡住

这不是「网速慢」能解释的:瓶颈在 parse + 全文 layout + 整本序列化 + 全量 canvas。大文档预览如果不改路径,产品体验会长期卡在「能打开,但不好用」。


二、我们要达成的效果

在同一量级(约 1500 页)上,下游优化的成功标准定为:

指标 目标 说明
热启动首屏可交互(TTFP) < 1.5s 首批可见页已排版并可绘制
全文 layout 完成(中高配) 尽量 < 3s hardwareConcurrency ≥ 4 为参照
弱机 不卡死、可滚动 不硬追 3s,优先保交互
能力 页码 / 段落定位跳转可用 依赖布局索引,而不是靠滚轮碰运气

同时明确边界:

  • 短期做的是 预览 + 定位,不是完整在线 Word 编辑器
  • 不牺牲与 Word 对齐的分页精度去换速度(同一节内的连续分页不能乱拆多线程)

三、为什么底座选 Oxi

Oxi 是一套 Rust + WebAssembly 的浏览器原生 Office 引擎路线:.docx 在客户端解析与排版,文档不必先送到「渲染农场」。公开材料里它还强调用 Word 渲染作对照做分页/像素级回归——这对「跳到第 N 页」这件事很关键:页码如果和 Word 差很远,定位产品就没有信任基础。

我做的是下游产品层优化:在 Oxi 能力之上,把大文档预览的交互路径做对。这部分尚未贡献回原仓库。


四、优化思路:别一次画完,先让人能跳

核心不是再抠一个行距,而是把链路拆开:

docx 字节
  → open_document(parse 一次,缓存 IR)
  → layout_next_batch(按批产出物理页)
  → 视口附近才绘制 Canvas
  → 页/段索引支撑跳转

1. 流式排版,而不是整本同步 layout

旧路径大致是:layout_document 同步排完全文 → 整本结果序列化进 JS → 再串行画完全部页。

新路径改为:

  • open_document(bytes):解析并准备会话,不做全文 layout
  • layout_next_batch(max_pages):从断点继续排,按批返回
  • 首批页一到,UI 就可以结束 Loading,进入可浏览状态(TTFP)
  • 后台继续追加页;全文完成后再更新「排版耗时」之类的总指标

这样,「打开 1500 页」不再等于「必须先等 1500 页全部排完才给看」。

2. 视口渲染,而不是全量 Canvas

A4、较高 DPI 下,单页 Canvas 体积很大。若一次性为上千页分配位图,标签页很容易内存打爆。

产品层做法:

  • 只绘制 视口 ± 缓冲 内的页
  • 远处页用占位高度撑起滚动条
  • 滚出视口后可回收 JS 侧页缓存,元素仍可留在 WASM 侧按需再取

这解决的是可打开、可滚动;流式解决的是尽快首屏

3. 跳转依赖布局索引

「跳到第 856 页 / 某段」需要的是布局坐标系里的位置,而不是 scrollTop = page * 固定页高。真实文档页高并不恒定(分节、表格跨页等都会破坏固定高假设)。

因此预览产品会消费布局结果中的页几何与段落锚点(或服务端预计算的 layout 索引),形成:

点击目录 / 输入页码 / 点段落
  → 查索引得到目标页与页内偏移
  → 滚动到目标
  → 确保目标页进入渲染窗口并高亮

引擎负责排得准;产品负责跳得快。两者分工清楚,才不容易把业务字段和排版引擎搅在一起。


五、实测效果(约 1500 页样例)

下列数据来自本地优化过程中的可复现计时(浏览器状态栏 / 探针拆分)。样例为约 1500 页量级文档;不公开文件名与正文。

1. 优化前基线

场景 结果
冷启动全文路径 ~25s
热启动全文路径 ~6s
模型 整本 layout + 整本进 JS + 串行画页

2. 优化过程中的关键拐点

流式刚接上、但 open_document 仍做整本 clone/prepare 时,首屏会被开门拖死:

指标 含义
open 15.3s 几乎等于 TTFP
TTFP 15.5s 首屏被 open 拖死
total 23.8s 甚至差过原先热启动 ~6s

这说明:光有流式 API 还不够,必须把「首屏前的准备工作」压下去。

随后去掉冗余 clone、惰性准备、默认主线程流式 + 视口虚拟化后,主线程流式曾到:

指标
TTFP 2.7s
prepare 0.78s
total 7.1s

中间还验证过 Layout Worker:因第二份 WASM 冷启动 + 每批 postMessage 克隆,一度退步到 TTFP 5.5s / total 11.4s,因此默认改回主线程流式,Worker 仅作可选路径。

3. 热启动达标情况(浏览器)

WASM / 字体表热起来之后:

指标 热启动实测 目标 结论
TTFP(首屏可交互) 626ms < 1.5s 达标
parse 566ms 占 TTFP 大半
prepare 7ms 已明显压下
全文 layoutDone / total ~4.7s 尽量 < 3s 未完全达标,但已优于基线 ~6s,且首屏不再等待全文

拆开看,墙钟里大头已经很清楚:

阶段 大约耗时 说明
parse ~0.6–2.1s(冷/热差大) 冷启动还会叠 WASM/字库
prepare 热启动可到个位数 ms 曾是 TTFP 杀手,已优化
layout CPU ~4s 量级(浏览器热路径) 当前全文耗时主因
视口绘制 / 序列化 相对较小 视口与 stub 主要省内存与卡顿

Node 侧热路径探针(warmup 后)也曾测到:total 2.75s(parse 0.77s + layout 1.97s)。浏览器与 Node 绝对值不同,但结构一致:热启动后,layout CPU 才是全文墙钟的主矛盾;首屏则主要由 parse 决定。

4. 产品体验层面的变化

能力 优化前 优化后
打开约 1500 页 冷 ~25s / 热 ~6s 才像「整本就绪」 热启动 亚秒级首屏(~0.6s)可开始看
滚动 易被整本渲染拖死 视口绘制,远页占位
内存 全量 Canvas 易爆 视口窗口内绘制,可回收
页码 / 段落跳转 等整本或靠盲滚 索引定位 + 目标页进入视口再绘制
弱机策略 容易长时间冻结 小 batch、保滚动;不硬追全文 3s

一句话总结:

从「打开就要等整本排完」变成「先看先跳,后台继续排」;热启动首屏从数秒级进入亚秒级,全文总时长仍受 layout CPU 约束,但交互体验已经跨过可用门槛。


六、定位跳转是怎么接上的

大文档预览里,跳转失败常见有两类原因:

  1. 索引与画布不是同一套几何(例如旧 layout 索引 + 新 WASM 分页)
  2. 跳转时流式尚未排到目标页,或跳转后又被整页 reset 清掉滚动

产品侧需要约定:

  • 页码跳转:按布局得到的页列表 / 页高累加定位
  • 段落跳转:用段落锚点(页号 + bbox)对齐高亮框与滚动位置
  • 流式未完成时:首次精确定位等待目标页产出,或先跳近似位置再校正

对外可以这样描述能力,而不展示任何业务正文:

  • 支持按页跳转
  • 支持按段落 / 目录条目跳转并高亮
  • 上千页场景下跳转依赖索引,而不是 DOM 全文扫描

七、踩过的坑

1. 冷启动和热启动必须分开报

同一份约 1500 页文档,冷 ~25s、热 ~6s(优化前)差的是 WASM 编译与字体表,不是「排版算法突然变慢」。

2. 流式不等于首屏快

open_document 里仍做整本 clone / 全量 prepare,TTFP 会等于 open(我们测到过 15s+)。流式只保证「开门后能分批出页」,不自动保证「开门够便宜」。

3. Worker 不是免费午餐

把 layout 丢进 Worker,看似不堵 UI,但可能:

  • 再冷启动一份 WASM
  • 每批结果 postMessage 结构化克隆上千页

我们实测过 TTFP / total 双双变差,因此默认回到主线程流式 + 视口绘制。

4. 省内存不等于省 CPU

stub 页、按需 get_layout_page、去掉热路径全量 base64,对 JS 内存很有效,但对 parse/layout 的 CPU 墙钟帮助有限。要再砍全文 4.7s → 3s,必须继续动 layout 热路径(例如节内可中断续排),而不是只改前端。

5. 跳转质量受分页精度约束

产品可以做很漂亮的目录,但若引擎分页与 Word 差一截,「第 800 页」对用户就没有意义。精度与性能要分开治理,不能用糊页换速度。


八、架构一览

┌──────────────┐
│  获取 docx    │  (业务系统下发字节;预览在浏览器完成)
└──────┬───────┘
       ▼
┌──────────────┐     ┌─────────────────────┐
│ open_document │ ──► │ IR + LayoutSession  │
└──────┬───────┘     └──────────┬──────────┘
       │                        │
       │              layout_next_batch(按批)
       ▼                        │
┌──────────────┐               ▼
│ 首屏 TTFP     │ ◄── 首批页 ──┤
│ 可滚动可跳转  │               │
└──────┬───────┘               ▼
       │              后台继续排到 layoutDone
       ▼
┌──────────────┐     ┌─────────────────────┐
│ 视口 Canvas   │ ◄── │ 页/段索引(跳转)     │
└──────────────┘     └─────────────────────┘

九、结语

上千页 Word 预览的难点,不在「再找一个能打开 docx 的库」,而在于三件事同时成立:

  1. 布局足够可信,页码与段落定位才有意义
  2. 渲染按视口进行,内存与滚动才接得住
  3. 排版可流式交付,首屏才不必等于全文完成

基于开源 Oxi,我在下游把约 1500 页场景的热启动首屏做到了 约 0.6s(目标 <1.5s),并补齐页/段定位跳转所需的产品路径;全文 layout 仍大约 4.7s 量级,低于「尽量 <3s」的进取目标,但已优于优化前热启动约 6s 的整本等待模型,且交互上不再被整本渲染绑死。

这部分工作目前仍是本地实践,尚未贡献回 Oxi 原项目。若你也在做同类预览器,优先把「冷/热启动数据、TTFP、layoutDone」拆开看——比只报一个「打开要多久」更接近真相。


参考

posted on 2026-08-19 16:56  菜虫虫  阅读(1)  评论(0)    收藏  举报

导航