Readest 全局高亮翻页卡顿(Issue 4575)修复剖析:基于 WeakMap 记忆化的幂等展开方案
2026/9/20 10:56:57 网站建设 项目流程

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.tsxprogresseffect 中可以看到触发路径(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):

  1. TreeWalker 遍历章节 DOMfindTextRanges(doc, note.text)doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, ...)遍历 section 文档中的所有文本节点(跳过<rt>/<rp>ruby 注音与<script>/<style>),对每个命中位置构造一个Range
  2. 逐次出现调用view.getCFI(index, range):为每个命中区间计算 CFI 坐标,文档记录的单次成本约0.2ms,是总开销的主导项
  3. 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 ?? ''}`;
  • 外层WeakMapsection 的 liveDocument对象为键——文档被卸载/重建时,旧的Document会被 GC 回收,条目自动失效,无需手动清理;
  • 内层Mapnote.id 为键,值为内容签名updatedAt:style:color:textupdatedAt在每次编辑、改色、全局开关切换时都会递增,因此任何内容变化都会导致签名不匹配,强制重新展开。

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 每次渲染章节都会创建全新的DocumentexpandedByDoc对新 doc 自然 miss,于是重新展开——这正好接住了onCreateOverlay钩子(Annotator.tsx)中"新渲染章节内的全局高亮扇出"逻辑。

3.4 正确性不变式

修复成立依赖一个被验证过的不变式:docoverlayer是随每个 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):

  1. 精确匹配:绝大多数高亮在此轮命中;
  2. 剥离 ruby 标记stripRuby去掉存储文本中的<rt>…</rt>/<rp>…</rp>,应对选区toString()把注音拼进文本、而 DOM 遍历又跳过注音节点的情况;
  3. 折叠行内假名collapseInlineFurigana用正则去掉位于两个汉字之间的平假名/片假名序列,应对「漢かん字じ」这类注音与正文交错的选区。

注释明确这是启发式兜底,只在精确匹配全部落空时启用;误匹配只会导致"不扇出",绝不会高亮错误文本(false positives just mean we drop the global fan-out, never that we highlight the wrong text)。

4.4 全局标注索引与去重

  • 预构建索引buildAnnotationIndex(annotationIndex.ts)把带styleglobal=true的标注单独放进globals数组,其余按 CFI spine 前缀分桶。annotationIndexuseMemo只在 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 changesupdatedAt从 10 改到 11(编辑/改色)→ 重新展开,getCfiCalls从 2 增至 4
re-expands after overlays are removedremoveGlobalAnnotationOverlays清 memo 后同内容重新展开
re-expands into a freshly rendered section (different doc)两个不同Document各自独立展开(新渲染章节不误跳)

六、边界情况与本问题的边界

  • 已确认正确:不同章节(不同Document)互不干扰;编辑后自动失效;开关切换后强制重展;删除/未删除的deletedAt防护在expandGlobalAnnotationprogresseffect 中双重存在(后者还防了 #4773 的孤儿 overlay 问题);
  • 不在本次修复范围内(文档明确标注为"Separate concerns"):慢速 TXT 导入(txt.ts解析,"2MB should be instant")以及 TOC/笔记面板打开慢——这些是独立问题,不应与本方案混淆。

七、复现配方与验证方法

若需在 dev-web 中复现(绕开原生文件选择器导入 GBK TXT):

  1. .txt复制到public/目录下;
  2. 在浏览器控制台用fetch('/file.txt')arrayBuffernew File([buf], '原名.txt')构造文件对象;
  3. 通过DataTransfer构造拖放事件,用Object.defineProperty(ev, 'dataTransfer', { value: dt })注入dataTransfer,再在.library-page上派发合成的dropDragEvent

这样原始字节得以保留,应用自身的编码检测能正确识别 GBK;结合真实的<foliate-view>环境与 Chrome 的 performance profile(文档引用了tts-sync-chrome-verification的实时剖测模式),即可量化修复前后每次翻页的主线程耗时差异。

八、可复用的经验总结

  1. "绘制一次就永久存在"的 UI 必须幂等:任何被高频事件(如翻页 progress)重复驱动的绘制逻辑,都要能在"已是最新状态"时零成本返回;
  2. 用对象身份而非字符串做缓存键WeakMap以 liveDocument为键,天然获得"销毁即失效、重建即 miss"的生命周期语义,这是本次方案正确性的基石;
  3. 签名要能捕获所有影响输出的维度updatedAt:style:color:text缺一不可——颜色/样式/文本任何一项变化都意味着旧 overlay 已不准确;
  4. 缓存清理要跟资源回收绑定removeGlobalAnnotationOverlays删 overlay 的同时删 memo,两个生命周期严格同步,避免"清了图但没清缓存"或反之;
  5. 性能剖析数据要落到具体数字:文档记录的 ~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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询