Readest 全局高亮翻页卡顿(Issue #4575)修复剖析:基于 WeakMap 记忆化的幂等展开方案
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
导读:本文基于 Readest 项目(
apps/readest-app)针对 Issue #4575「高亮多个主角姓名后翻页卡顿」的完整排查与修复记录,深入讲解全局高亮(note.global,即高亮所有同词出现)功能在每次翻页时对已渲染章节重复执行 DOM 遍历与 CFI 计算所造成的性能灾难,以及通过模块级WeakMap记忆化实现幂等展开、将翻页成本从 2..N 次降为 ~0ms 的工程方案。读完本文,你将理解 foliate-view 渲染器内容生命周期、overlayer 绘制管线、如何用"签名 + WeakMap"在文档重渲染时自动失效缓存,并掌握一套可复用的阅读器级高亮性能优化思路。
一、问题背景:翻页延迟的复现与定位
Issue #4575 描述的现象是:在中文网文 TXT 书籍中,高亮了若干个主角姓名之后,翻页变得非常卡顿(原文:"after highlighting several main-character names, page turning is very laggy")。
排查结论首先澄清了一个关键事实:卡顿的元凶是"全局高亮"(highlight-all-occurrences,即note.global标记),而不是普通高亮。普通高亮只在锚点 CFI 所在位置绘制一次;而全局高亮会在当前已渲染的所有章节中,把同名文本的每一次出现都展开成一条独立高亮覆盖层(overlay)。
在Annotator.tsx的progresseffect 中可以看到触发路径(Annotator.tsx):每次翻页/滚动产生progress变更,effect 就会遍历预构建的annotationIndex.globals数组,对每个全局标注调用expandAllRenderedSections(view, a)——即把每一个全局标注在所有已渲染章节上重新展开一遍:
useEffect(() => { if (!progress) return; const { location } = progress; const { annotations, notes } = selectLocationAnnotations(annotationIndex, location); try { Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation))); Promise.all( notes.map((note) => view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` })), ); // Fan-out for any annotation flagged `global`. Semantics is // book-wide, so we don't filter by `location` here: every note // with `global=true` gets expanded across every section that // happens to be rendered right now. Sections rendered later are // covered by `onCreateOverlay`. for (const annotation of annotationIndex.globals) { if (annotation.deletedAt) continue; if (view) expandAllRenderedSections(view, annotation); } } catch (e) { console.warn(e); } }, [progress, annotationIndex, translationEpoch]);注意依赖数组中的[progress, ...]——每次翻页 progress 都会变化,因此这个 effect 在每翻一页后都会完整跑一遍。
二、根因分析:每次翻页的 O(章节 × 出现次数) 重复劳动
expandAllRenderedSections的实现位于 globalAnnotations.ts,它会遍历 renderer 当前所有 live 的内容记录(section),对每个有doc的 section 调用expandGlobalAnnotation:
export function expandAllRenderedSections(view: FoliateView | null, note: BookNote): void { if (!view || !note.global || note.deletedAt) return; const sections = view.renderer?.getContents?.() ?? []; for (const section of sections) { const sec = section as { doc?: Document; index?: number }; if (sec.doc && typeof sec.index === 'number') { expandGlobalAnnotation(view, note, sec.doc, sec.index); } } }每一次expandGlobalAnnotation展开都包含三段成本(globalAnnotations.ts):
- TreeWalker 遍历章节 DOM:
findTextRanges(doc, note.text)用doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, ...)遍历 section 文档中的所有文本节点(跳过<rt>/<rp>ruby 注音与<script>/<style>),对每个命中位置构造一个Range; - 逐次出现调用
view.getCFI(index, range):为每个命中区间计算 CFI 坐标,文档记录的单次成本约0.2ms,是总开销的主导项; overlayer.add绘制:foliate 的 Overlayer 会移除并重建 SVG 覆盖层,并调用getRects强制触发 layout。
问题是:第一次展开后 overlay 已经存在,而progresseffect 在每次翻页时依然把上述三段全部重跑一遍——对已绘制好的覆盖层而言这是纯粹的浪费。实测数据(dev-web + claude-in-chrome,真实<foliate-view>环境,2 个已渲染章节内 6 个姓名、226 处出现)显示:每次翻页在主线程上有约 25–45ms 的同步开销,移动端再放大 3–5 倍,于是用户感知为明显卡顿。文档中的具体示例为「姜窈(73 次)」「驰厉(66 次)」——每两个章节各出现数十次,正是网文中高频人名高亮的典型场景。
三、修复方案:WeakMap记忆化 + 内容签名
3.1 核心数据结构
修复(PR 分支fix/global-annot-pageturn-4575,commitf1404c6b1)在globalAnnotations.ts中引入模块级缓存(globalAnnotations.ts):
const expandedByDoc = new WeakMap<Document, Map<string, string>>(); const annotationSignature = (note: BookNote): string => `${note.updatedAt ?? 0}:${note.style ?? ''}:${note.color ?? ''}:${note.text ?? ''}`;- 外层
WeakMap以section 的 liveDocument对象为键——文档被卸载/重建时,旧的Document会被 GC 回收,条目自动失效,无需手动清理; - 内层
Map以note.id 为键,值为内容签名updatedAt:style:color:text。updatedAt在每次编辑、改色、全局开关切换时都会递增,因此任何内容变化都会导致签名不匹配,强制重新展开。
3.2 幂等短路
expandGlobalAnnotation开头直接短路(globalAnnotations.ts):
const signature = annotationSignature(note); let docMemo = expandedByDoc.get(doc); if (docMemo?.get(note.id) === signature) return [];命中缓存时不遍历 DOM、不调用getCFI、不触碰 overlayer,直接返回空数组。关键细节:即使某次展开结果为 0 处匹配,也会记录签名——这样"该 note 在此章节没有出现"同样不会被每页重扫(globalAnnotations.ts):
// Recorded even when there were zero matches — a note that doesn't appear in // this section must not be re-walked every turn either. if (!docMemo) { docMemo = new Map<string, string>(); expandedByDoc.set(doc, docMemo); } docMemo.set(note.id, signature);3.3 缓存失效的两个入口
removeGlobalAnnotationOverlays:删除 overlay 时同步删除 memo 条目(globalAnnotations.ts),保证"全局高亮关掉再打开"即使内容签名未变也能重新展开。该函数在 useNotesSync.ts 中被同步流程调用:if (local.global) removeGlobalAnnotationOverlays(v, local);;- 新渲染的 section:foliate 每次渲染章节都会创建全新的
Document,expandedByDoc对新 doc 自然 miss,于是重新展开——这正好接住了onCreateOverlay钩子(Annotator.tsx)中"新渲染章节内的全局高亮扇出"逻辑。
3.4 正确性不变式
修复成立依赖一个被验证过的不变式:doc与overlayer是随每个 section 内容记录成对创建/销毁的,且getContents()在多次调用间返回稳定的 doc/overlayer 引用("same doc ⟺ overlays still present")。因此:
doc相同 → overlay 必然还在 → 跳过重绘是安全的;- section 重新渲染 → 新
doc→ memo miss → 重新展开,绝不会错误跳过。
这正是选择WeakMap<Document, …>而非以 section index 为键的原因——index 在书籍重排后会复用,而Document引用天然携带"是否仍存活"的语义。
3.5 效果
修复后第 2..N 次展开的耗时从几十毫秒降到约 0ms;一次性成本仍保留在章节渲染时(onCreateOverlay)承担——这是合理的,因为渲染新章节本就要绘制高亮。
四、围绕全局高亮的配套机制
4.1 合成 CFI 标记(#g)
每个扇出副本需要一个唯一 overlay key,同时点击时又要能映射回源 note。实现用合成值${originalCfi}#g${sectionIndex}-${occurrence}(globalAnnotations.ts):
#g前缀刻意设计为非合法 CFI 片段,保证不会与真实锚点冲突;isSyntheticGlobalValue(value)判断是否合成副本,sourceCfiFromSyntheticValue(value)还原源 CFI;- 点击事件处理在 Annotator.tsx 的
onShowAnnotation中先把合成值还原为源 CFI,再按源 note 弹出与点击原始锚点完全一致的弹窗。
4.2 为什么不能走view.addAnnotation
注释中明确警告"DO NOT replace withview.addAnnotation"(globalAnnotations.ts):foliate 的view.addAnnotation会对传入的value执行resolveNavigation(value),而合成值不是合法 CFI,resolveNavigation返回 undefined 后解构会抛异常。因此修复直接绘制在 section 的 overlayer 上,并重新派发draw-annotation事件,让 Readest 自己的onDrawAnnotation样式管线照常运行(Annotator.tsx):
const draw = (func: unknown, opts?: unknown) => overlayer.add(value, range, func, opts); const annotationForDraw = { ...note, cfi, value }; const target = view as unknown as EventTarget; target.dispatchEvent( new CustomEvent('draw-annotation', { detail: { draw, annotation: annotationForDraw, doc, range }, }), );4.3 模糊匹配兜底:ruby 注音与行内假名
findTextRanges采用三级匹配策略(globalAnnotations.ts):
- 精确匹配:绝大多数高亮在此轮命中;
- 剥离 ruby 标记:
stripRuby去掉存储文本中的<rt>…</rt>/<rp>…</rp>,应对选区toString()把注音拼进文本、而 DOM 遍历又跳过注音节点的情况; - 折叠行内假名:
collapseInlineFurigana用正则去掉位于两个汉字之间的平假名/片假名序列,应对「漢かん字じ」这类注音与正文交错的选区。
注释明确这是启发式兜底,只在精确匹配全部落空时启用;误匹配只会导致"不扇出",绝不会高亮错误文本(false positives just mean we drop the global fan-out, never that we highlight the wrong text)。
4.4 全局标注索引与去重
- 预构建索引:
buildAnnotationIndex(annotationIndex.ts)把带style且global=true的标注单独放进globals数组,其余按 CFI spine 前缀分桶。annotationIndex用useMemo只在 booknotes 变化时重建,避免每次翻页重扫全量 booknotes(此前 >1k 高亮用户的朴素booknotes.filter(...)是另一个性能瓶颈来源,见注释中的 profile 数据); - node 路径哈希去重:同一文本节点上可能被多次命中同一区间,
rangeNodePathHash生成父链索引路径作为稳定指纹(globalAnnotations.ts),配合seen集合跳过重复 Range; - 跳过源锚点:合成 CFI 与
note.cfi完全相等时跳过绘制,避免在原始高亮上重复叠加(globalAnnotations.ts)。
五、回归测试:幂等性被固化
修复附带 5 个单元测试,位于 global-annotations.test.ts,用一个可计数的 FakeView(getCFI每次调用自增getCfiCalls)作为"昂贵工作"的代理来验证幂等性:
| 测试用例 | 验证点 |
|---|---|
expands every occurrence on first call for a section | 首次展开:3 处出现 → 3 次getCFI,返回 3 个合成值 |
does NO work when re-expanding the same note into the same section | 第二次展开(模拟下一页)返回[],getCfiCalls保持不变 |
re-expands when the note content changes | updatedAt从 10 改到 11(编辑/改色)→ 重新展开,getCfiCalls从 2 增至 4 |
re-expands after overlays are removed | removeGlobalAnnotationOverlays清 memo 后同内容重新展开 |
re-expands into a freshly rendered section (different doc) | 两个不同Document各自独立展开(新渲染章节不误跳) |
六、边界情况与本问题的边界
- 已确认正确:不同章节(不同
Document)互不干扰;编辑后自动失效;开关切换后强制重展;删除/未删除的deletedAt防护在expandGlobalAnnotation与progresseffect 中双重存在(后者还防了 #4773 的孤儿 overlay 问题); - 不在本次修复范围内(文档明确标注为"Separate concerns"):慢速 TXT 导入(
txt.ts解析,"2MB should be instant")以及 TOC/笔记面板打开慢——这些是独立问题,不应与本方案混淆。
七、复现配方与验证方法
若需在 dev-web 中复现(绕开原生文件选择器导入 GBK TXT):
- 将
.txt复制到public/目录下; - 在浏览器控制台用
fetch('/file.txt')→arrayBuffer→new File([buf], '原名.txt')构造文件对象; - 通过
DataTransfer构造拖放事件,用Object.defineProperty(ev, 'dataTransfer', { value: dt })注入dataTransfer,再在.library-page上派发合成的dropDragEvent。
这样原始字节得以保留,应用自身的编码检测能正确识别 GBK;结合真实的<foliate-view>环境与 Chrome 的 performance profile(文档引用了tts-sync-chrome-verification的实时剖测模式),即可量化修复前后每次翻页的主线程耗时差异。
八、可复用的经验总结
- "绘制一次就永久存在"的 UI 必须幂等:任何被高频事件(如翻页 progress)重复驱动的绘制逻辑,都要能在"已是最新状态"时零成本返回;
- 用对象身份而非字符串做缓存键:
WeakMap以 liveDocument为键,天然获得"销毁即失效、重建即 miss"的生命周期语义,这是本次方案正确性的基石; - 签名要能捕获所有影响输出的维度:
updatedAt:style:color:text缺一不可——颜色/样式/文本任何一项变化都意味着旧 overlay 已不准确; - 缓存清理要跟资源回收绑定:
removeGlobalAnnotationOverlays删 overlay 的同时删 memo,两个生命周期严格同步,避免"清了图但没清缓存"或反之; - 性能剖析数据要落到具体数字:文档记录的 ~0.2ms/getCFI、25–45ms/翻页(桌面)、×3–5(移动)让问题可量化、修复可验证,而不是停留在"感觉卡"。
通过这一案例可以看到,Readest 在处理"书本级功能 × 章节级渲染 × 高频翻页"三者交汇的工程问题上,形成了一套以幂等性为核心、以生命周期管理为边界的健壮模式,相关实现细节均可继续在 globalAnnotations.ts、Annotator.tsx 与 global-annotations.test.ts 中深入研读。
【免费下载链接】readestReadest is a modern, feature-rich ebook reader designed for avid readers offering seamless cross-platform access, powerful tools, and an intuitive interface to elevate your reading experience.项目地址: https://gitcode.com/gh_mirrors/re/readest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考