读一篇五万字的长文,为什么不会卡
一次性塞进 DOM 会爆内存。有谷阅读器把 HTML 分块、视口懒加载,用虚拟未划线 DOM 钉住高亮位置——划线再多也不漂移。
在一篇五万字的技术长文里划几句重点,关掉页面,第二天换一台设备打开——高亮还在原来的句子上。
听起来理所当然,做起来很难。难的不只是「把网页存下来」,而是超长 HTML 怎么显示才不卡、划线的位置怎么钉在文字上、分块加载后坐标怎么还算得对。
有谷大脑内置阅读器(以及「不读」产品线)采用现代稍后读产品常见的工程做法。核心是两件事:视口驱动的分块懒加载,和对高亮透明的坐标系。
问题一:五万字为什么不能一次性渲染
一篇长文可能有几万字、几百张图。如果把整篇 HTML 一次性塞进页面:
- 首屏要等很久
- 内存占用高,移动端容易被杀进程
- 滚动卡顿,尤其是嵌了大量图片的 PDF 转 HTML
解决思路和无限滚动类似,但更讲究:把文档切成一块块 HTML,滚到哪才加载哪。
每个块挂两个 IntersectionObserver:
- 加载观察器 — 块进入视口前约 1000px 时拉真实 HTML,滚到时不空屏
- 卸载观察器 — 块离开视口约 4000px 后清空内容,只留等高的空壳占位
两个「魔鬼细节」:
- 占位高度必须保留。 卸载时不能让高度归零,否则下面内容突然上跳,滚动条乱窜。
- 加载早、卸载晚是故意的不对称。 提前加载保证向下读顺;延后卸载保证快速回滚时不闪。
图片还有额外优化:先按已知宽高撑占位框,进视口再下载——不为看不见的图浪费流量。
问题二:划线为什么是最难的本地算法
给文字加高亮,会改变 DOM 结构。原来 <p> 里一个文本节点 "Hello world",划了 Hello 之后变成高亮标签 + 剩余文本——子节点编号全变了。
最直觉的做法:记「第几个子节点、第几个字符」。划得越多,编号漂移越厉害——这不是 bug,是这套表示方法的根本缺陷。
虚拟未划线 DOM
正解是:每次计算坐标时,先在脑子里把所有高亮标签撕掉,还原成「假如从没划过线」的原始结构,再在这个稳定结构上数节点。
三条规则让坐标系对高亮「透明」:
- 剥标签再数节点 — 去掉高亮标签、重新解析,别人划过多少条都不影响你的编号
- 偏移要把被拆开的前半段加回去 —
Hello world被划成[Hello][ world]后,world的偏移要加上Hello的长度 - 自己插入的控件不算数 — 拖拽手柄、笔记图标不参与计数
理解了这三条,就抓住了阅读器最值钱的一段逻辑:位置不受「已经划了多少线」干扰。
问题三:分块之后,全局坐标怎么算
长文分块加载,部分块可能已卸载、不在内存里——没法直接数「整篇第几个节点」。
做法:块内算局部坐标,加上前面所有块的节点数。
云端下发文档时附带「每块有几个顶层节点」。客户端不需要整篇留在内存,只要知道每块的节点数,就能把局部坐标累加成全局坐标。复原时反过来:判断划线属于哪块 → 块被卸载就临时加载 → 块内精确还原。
卸载省了内存,累加保住了坐标。
三层兜底:宁可不画,也不画错
坐标再稳,也架不住云端重新抽取正文、排版微调。复原划线时走三层:
- 按坐标复原(快路径) — 定位选区,核对还原文字和存库时的划线正文,一致就画
- 按正文搜索(保底) — 坐标失效时用划线文字在当前全文里找,空白换行归一化,容忍细微排版差异
- 放弃 — 正文也找不到,标记「复原失败」,不画
为什么要同时存坐标和正文?坐标快但脆(结构一变就废);正文慢但韧(只要那句话还在就能找回)。 平时走坐标,出问题走正文——快路径 + 慢兜底,是工程里很常见的稳健性设计。
local-first:先写本地,再同步云端
划线的一瞬间先写进设备本地数据库,界面立即更新,断网也能用;随后把增量改动以 patch 形式同步云端,其它设备的 patch 再广播回来。
本地是真正的数据库,不是缓存;同步走补丁而不是整篇覆盖——带宽小,冲突好处理。
和知识库其它能力的关系
阅读器不是孤立功能,它和知识库其它模块衔接:
- 入库 — 网页收藏、URL 抓取 后的 HTML 进入知识库,阅读器负责展示
- 问答 — AG-UI Agent 检索 chunk,阅读器打开引用定位
- 术语 — 长文里的专业词可接 术语 Popover,点击看解释
阅读体验的上限,取决于入库时 HTML 结构是否干净;但再好的正文,如果没有分块渲染和稳定锚点,长文依然不可用。把「存」和「读」当成一条链路来设计,而不是两个独立功能。