ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Readest 全局高亮翻页卡顿(Issue 4575)修复剖析:基于 WeakMap 记忆化的幂等展开方案

Readest 全局高亮翻页卡顿(Issue 4575)修复剖析:基于 WeakMap 记忆化的幂等展开方案 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 dont filter by location here: every note // with globaltrue 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 调用expandGlobalAnnotationexport 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.tsTreeWalker 遍历章节 DOMfindTextRanges(doc, note.text)用doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, ...)遍历 section 文档中的所有文本节点跳过rt/rpruby 注音与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-4575commitf1404c6b1在globalAnnotations.ts中引入模块级缓存globalAnnotations.tsconst expandedByDoc new WeakMapDocument, Mapstring, 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.tsconst 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 doesnt appear in // this section must not be re-walked every turn either. if (!docMemo) { docMemo new Mapstring, 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);新渲染的 sectionfoliate 每次渲染章节都会创建全新的DocumentexpandedByDoc对新 doc 自然 miss于是重新展开——这正好接住了onCreateOverlay钩子Annotator.tsx中新渲染章节内的全局高亮扇出逻辑。3.4 正确性不变式修复成立依赖一个被验证过的不变式doc与overlayer是随每个 section 内容记录成对创建/销毁的且getContents()在多次调用间返回稳定的 doc/overlayer 引用same doc ⟺ overlays still present。因此doc相同 → overlay 必然还在 → 跳过重绘是安全的section 重新渲染 → 新doc→ memo miss → 重新展开绝不会错误跳过。这正是选择WeakMapDocument, …而非以 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.addAnnotationglobalAnnotations.tsfoliate 的view.addAnnotation会对传入的value执行resolveNavigation(value)而合成值不是合法 CFIresolveNavigation返回 undefined 后解构会抛异常。因此修复直接绘制在 section 的 overlayer 上并重新派发draw-annotation事件让 Readest 自己的onDrawAnnotation样式管线照常运行Annotator.tsxconst 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 全局标注索引与去重预构建索引buildAnnotationIndexannotationIndex.ts把带style且globaltrue的标注单独放进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用一个可计数的 FakeViewgetCFI每次调用自增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 增至 4re-expands after overlays are removedremoveGlobalAnnotationOverlays清 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),仅供参考
返回列表