基于开源 Oxi:浏览器里做上千页 Word 预览与定位跳转
本文基于开源项目 Oxi(规范仓:GitLab — Ryujiyasu/oxi)做下游产品层优化实践。作者不是原项目核心贡献者,相关优化尚未回馈上游。文中样例仅描述页数与耗时,不包含任何真实业务文档正文。
一、问题从哪来
政企、测评、档案类系统里,Word 经常不是「几十页说明书」,而是数百到一千五百页量级的报告。业务方要的通常是三件事:
- 浏览器里直接预览,尽量别先整本转 PDF
- 首屏尽快能看,不能干等全文排完
- 能定位跳转:按页、按段落、按目录点一下就到
如果仍走「整本文档一次 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):解析并准备会话,不做全文 layoutlayout_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 约束,但交互体验已经跨过可用门槛。
六、定位跳转是怎么接上的
大文档预览里,跳转失败常见有两类原因:
- 索引与画布不是同一套几何(例如旧 layout 索引 + 新 WASM 分页)
- 跳转时流式尚未排到目标页,或跳转后又被整页 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 的库」,而在于三件事同时成立:
- 布局足够可信,页码与段落定位才有意义
- 渲染按视口进行,内存与滚动才接得住
- 排版可流式交付,首屏才不必等于全文完成
基于开源 Oxi,我在下游把约 1500 页场景的热启动首屏做到了 约 0.6s(目标 <1.5s),并补齐页/段定位跳转所需的产品路径;全文 layout 仍大约 4.7s 量级,低于「尽量 <3s」的进取目标,但已优于优化前热启动约 6s 的整本等待模型,且交互上不再被整本渲染绑死。
这部分工作目前仍是本地实践,尚未贡献回 Oxi 原项目。若你也在做同类预览器,优先把「冷/热启动数据、TTFP、layoutDone」拆开看——比只报一个「打开要多久」更接近真相。
浙公网安备 33010602011771号