WPS 加载项逐段读文档,一千段要 14.5 秒,一万段直接卡死两分钟。我们最后把「读」整个搬出 WPS,直接解 docx,读一遍降到一秒级。这篇讲清楚为什么慢、怎么改、哪些场景还是不灵。## 点一下「通读」,WPS 就不动了
早期版本里我们做了个「通读」按钮,点下去界面直接失去响应。用户以为崩了,再点一次,卡得更久。
我们一开始的判断是加载项和主线程抢资源,于是加异步、加进度条、把回调拆开,全都没用。后来在几台机器上埋点计时,才发现时间几乎全花在读段落上,跟渲染、跟主线程调度关系不大。
实测的三个数字:Paragraphs.Item(i) 读 1000 段 14.5 秒;Paragraph.Next() 读 2000 段 22.6 秒;Tables.Item(i) 读 231 张表 14.7 秒。一册标书一万多段,逐段扫一遍两三分钟,WPS 全程被占着。
这不是调参数能救的,是路选错了。
Paragraphs.Item(i) 为什么越读越慢
WPS 的 JS 加载项走的是 COM 风格的对象模型,每次调用都是一次跨进程请求。Paragraphs.Item(i) 的问题在于集合没有游标:取第 i 个元素,它要从头数过去。读第 1 段很快,读第 9000 段就要先数 8999 次。整体是 O(n²),段落越多越难看。
Paragraph.Next() 看起来像顺着指针走,实测 2000 段 22.6 秒,也不便宜。每次 Next 仍然是一次跨进程往返,而且拿到的对象还要逐个读属性——样式名、文本、范围,每个属性又是一次往返。表格同理,Tables.Item(i) 取 231 张表 14.7 秒。
所以结论很直接:对象模型适合点对点操作,定位一个标题、替换一处文字、往某个格子里写值。它不适合扫全篇。我们之前把「扫描」和「编辑」混在同一套 API 里做,卡死是必然的。
读的活搬出去,直接拆 docx
.docx 本质是个 zip,正文在 word/document.xml。我们的加载项本来就配了一个本机 Node 服务,于是把「读」整个搬过去:服务端直接打开盘上的 .docx,解压、解析 XML,一次扫出标题树、每张表的每个格子、图名、词频,按文件修改时间缓存。
实测一册约 6MB 的文档,读文件加解压约 1 秒,扫一遍几十毫秒。对比对象模型的两三分钟,不是一个量级。而且这个过程完全不碰 WPS,界面不卡,用户可以继续翻文档。
几个踩过的工程细节:
- WPS 开着文件时也能读。docx 文件本身允许共享读,我们只读不写,不会和 WPS 抢锁。
- 取索引前先让 WPS 执行一次 Save。用户改完没保存就点检查,读到旧版本会得出错误结论,这个坑我们踩过,结论错得还很像那么回事。
- 缓存键用文件修改时间,不用内容哈希。哈希要整读一遍文件,修改时间的代价几乎为零,够用。
- 解析器只取我们需要的节点,不建完整 DOM。一册一万多段全建树,内存和 GC 都不划算。
思路用伪代码大概是这样:
// 服务端:只读解析,不经过 WPS
const zip = await openZip(docxPath); // 共享读,WPS 开着也能打开
const xml = await zip.read('word/document.xml');
const tree = parseHeadingsAndTables(xml); // 标题树 + 表格格子 + 图名
cache.set(docxPath, { mtime, tree }); // 按修改时间失效
这条路不是我们独创。docx4j、python-docx、Aspose 这些库都是直接处理文件的路数,只是我们一开始被「加载项就该用加载项 API」的惯性带偏了,没往这个方向想。
写的时候再回到 WPS
索引建好之后,所有「写」的操作仍然走 WPS 对象模型,因为只有它能保证改动进入 WPS 的编辑状态、能被撤销、能触发重排。
用法都是点对点的:按样式 Find 定位标题,约 0.6 秒一次;在指定范围里 Find 替换;往表格格子里写值。这些操作次数有限——一册文档几十个标题、若干处替换,加起来是秒级,用户能接受。
取舍就一句话:读走文件,写走对象模型。读是 O(全篇),写是 O(改动点),把重的那头放到不依赖 WPS 的一侧。
顺带一个好处:索引在服务端,检查逻辑可以纯函数化——标题层级是否完整、表格是否有空单元格、图名编号是否连续,这些都不依赖 WPS 是否在前台,也方便单独跑测试。
这个办法的边界
不是所有场景都能这么干,说清楚几条:
文档没保存过就不行。 我们依赖盘上的文件,用户新建文档还没落盘,服务端读不到。目前是提示先保存,没做内存态同步。
只认标准 .docx。 WPS 的私有格式不行。老 .doc 要先转换,转换本身有损,公式、域代码、复杂图形可能变形。
修订和批注要单独处理。 document.xml 里修订痕迹是带标记的节点,直接按文本扫会把删除的内容也算进去。我们目前对带修订的文档给提示,不做自动判断。
写操作仍然受对象模型约束。 如果某次改动是「全篇批量替换几百处」,还是会慢。我们限制单次检查的写操作数量,超出的部分让用户分批做。
缓存一致性靠 Save 保证,不是强一致。 用户在 WPS 里改了但没保存,索引就是旧的。这是有意的取舍,换来的是读路径足够快。
还没解决的部分:图片和嵌入对象只解析到图名和位置,没做内容识别;跨文档的样式继承没完全覆盖,个别自定义样式在标题树里会漏。这些不影响主流程,但会在边界文档上出偏差。
回头看,最初的错误不是性能优化没做好,是把「读」和「写」当成了同一类操作。分开之后,卡死的问题自然就消失了。
我们做的是投标文件编制方向的工具,上面这套读写的拆分是产品里真实在跑的做法。同行如果也在做 WPS 加载项处理长文档,欢迎交流各位怎么处理修订和私有格式这两个坑。
以上是我们开发 WPS 加载项「标书神器」时的实际做法,欢迎交流。