资讯动态

Readest BooknoteView 虚拟化后的自动滚动回归修复:基于 Virtuoso 与 OverlayScrollbars 的最近标注定位实践

发布时间:2026/9/20 8:24:16 来源:尧图企业网站定制
Readest BooknoteView 虚拟化后的自动滚动回归修复基于 Virtuoso 与 OverlayScrollbars 的最近标注定位实践【免费下载链接】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 侧边栏标注/书签列表BooknoteView在引入窗口化虚拟化issue #4352后出现的列表不再随阅读位置自动滚动到最近标注这一回归完整还原两条独立失败路径的根因并深入讲解最终的修复方案如何在initialized回调中通过 ref 重新应用scrollToIndex、如何用initialTopMostItemIndex让 Virtuoso 原生居中挂载、以及如何用lastScrolledCfiRef/initialScrollHandledRef双守卫避免竞态。读完本文你将掌握在React 虚拟列表 延迟初始化滚动容器组合下实现可靠自动定位的完整方法论并看到对应的单元测试如何通过 stub 强制ref 式修复。背景BooknoteView 是什么BooknoteView 是 Readest 阅读器侧边栏中的标注/书签面板apps/readest-app/src/app/reader/components/sidebar/BooknoteView.tsx展示当前书籍的全部划线标注与书签。它以章节TOC item为分组将组头 组内条目扁平化为单一可虚拟化列表并通过react-virtuoso渲染——这意味着屏幕上只挂载可见行动辄上千条的标注不会一次性全部渲染。其核心数据流全部由useMemo串联保证派生数据引用稳定filteredNotes从config.booknotes中筛出当前类型annotation/bookmark且未删除的笔记annotation 标签页还会叠加注释中枢Annotation Hub的种类/关键词/颜色/样式过滤sortedGroups用findTocItemBS将每条笔记归入其章节分组组内按CFI.compare升序组间按 TOC id 排序见 BooknoteView.tsxflatItems把分组树拍平成{kind:group-header}与{kind:note}交替的扁平行nearestCfi/nearestIndex用阅读进度progress.location二分查找距离当前阅读位置最近的标注并映射到扁平列表中的下标。自动滚动到最近标注这一功能就建立在第 4 步之上。原始实现给每个列表项挂useScrollToItem每个条目一次滚动定位代价是触发大量 layout 读取#4352 虚拟化后用单个virtuosoRef.scrollToIndex取代但遗漏了 TOCView 已经积累的配套机制于是产生回归。最近标注的定位findNearestCfi 与 nearestIndex在 cfi.ts 中findNearestCfi对有序CFI 数组执行二分查找先用CFI.collapse(location)归一化目标位置再找第一个cfi target的下标lo返回cfis[lo-1]即恰好在阅读位置之前或等于它的那条并处理lo0与lolength两个边界。值得注意的实现细节该函数运行在 render 阶段的useMemo中因此它主动过滤掉非字符串/空字符串的 CFI 条目——同步往返可能把null塞进BookNote.cfi不加防护的抛错会直达应用错误边界把整个阅读器替换成崩溃页。nearestIndex则是nearestCfi在扁平列表中的下标-1表示无目标见 BooknoteView.tsx。它被useMemo缓存作为唯一事实来源同时供滚动 effect 和 OverlayScrollbars 的initialized回调读取。两条失败路径与各自修复#4352 的回归表现为打开侧边栏标注面板后列表停留在顶部显示第 1 章而不是自动滚到当前阅读位置最近的标注。根因拆成两条相互独立的失败路径修复方式也完全不同。路径 1重载场景——进度在挂载后才到达场景刷新页面时标注标签页恰好处于激活态BooknoteView先挂载此时progress.location为nullnearestIndex为-1随后阅读器的首次 relocate 事件才把进度送达。此时常规滚动 effect 收到新的nearestCfi执行scrollToIndex把列表滚到目标但 OverlayScrollbars 以defer: true延迟初始化其initialized回调触发时会把被包裹的 viewport 的scrollTop重置为 0把刚才的自动滚动抹掉lastScrolledCfiRef守卫nearestCfi lastScrolledCfiRef.current则跳过此时已经记下了目标 CFI导致后续 effect 不再重试——列表就此滞留顶部。修复的核心是在initialized回调里主动重放一次滚动。回调在挂载时创建、延迟后触发闭包里的nearestIndex是过期的初始值-1因此必须通过 ref 读取当前值const nearestIndexRef useRef(nearestIndex); nearestIndexRef.current nearestIndex; // ... events: { initialized(instance) { // 覆盖 OverlayScrollbars 设置的 overflow CSS 变量 const { viewport } instance.elements(); viewport.style.overflowX var(--os-viewport-overflow-x); viewport.style.overflowY var(--os-viewport-overflow-y); const reapply () { const index nearestIndexRef.current; if (index 0) return; virtuosoRef.current?.scrollToIndex({ index, align: center, behavior: auto }); }; // 双 rAF第一次等 reset 落定第二次等行被测量后再断言一次 requestAnimationFrame(() { reapply(); requestAnimationFrame(reapply); }); }, }双重 rAF 是刻意的第一次让 OverlayScrollbars 的scrollTop重置先落定第二次针对刚挂载的行尚未测量的情况——对一个很远的、未测量的目标行只调用一次scrollToIndex会落偏详情见下文 TOCView 同款竞态。路径 2标签切换场景——进度在挂载时已知场景阅读过程中切换侧边栏标签打开标注面板此时progress.location在挂载时就已存在。若在 effect 里对刚挂载、尚未测量行高的列表同步调用scrollToIndexbehavior: smooth调用直接 no-opVirtuoso 还没有可滚动的量程behavior: auto更糟会把 Virtuoso卡死在渲染空白状态。修复是照搬 TOCView 的设计让 Virtuoso 在首次渲染时就原生居中彻底跳过挂载后立刻滚动这一步// 挂载时快照一次最近下标0 才需要居中 const [initialTopIndex] useState(() nearestIndex); const initialScrollHandledRef useRef(initialTopIndex 0);initialTopMostItemIndex传给 VirtuosoVirtuoso initialTopMostItemIndex{ initialTopIndex 0 ? { index: initialTopIndex, align: center } : 0 } ... /同时滚动 effect 用initialScrollHandledRef作为一次性门控跳过这第一次跳转原生定位已处理并让initialized回调在 OverlayScrollbars 重置scrollTop后再把位置还原。注意initialScrollHandledRef是取反即消耗的用法——if (initialScrollHandledRef.current) { initialScrollHandledRef.current false; return; }见 BooknoteView.tsx。两条路径的修复合在一起等价于 TOCView 早已实现的模式见 TOCView.tsx 中initialized回调的activeHrefRefflatItemsRef读取以及initialTopMostItemIndex原生居中。滚动行为策略远近分流与 E-ink 兼容常规场景进度推进、标注增删导致的nearestCfi变化下滚动 effect 还有一层行为策略const distance Math.abs(nearestIndex - visibleCenterRef.current); const behavior isEink || distance 16 ? auto : smooth; virtuosoRef.current?.scrollToIndex({ index: nearestIndex, align: center, behavior }); if (behavior auto) { requestAnimationFrame(() { virtuosoRef.current?.scrollToIndex({ index: nearestIndex, align: center, behavior: auto, }); }); }visibleCenterRef由 Virtuoso 的rangeChanged持续刷新记录当前可视窗口中心下标用于计算距离远距离16 行用瞬时跳转虚拟列表在平滑动画中途会空白闪烁远跳必须瞬时E-ink 设备强制瞬时电子墨水屏在 JS 平滑滚动动画中会残留上一帧残影TOCView 源码注释明确说明这是 CSS 无法修复的因为scrollTo({behavior:smooth})会覆盖 CSSscroll-behaviorbehavior:auto时补一发 rAF 重断言与initialized回调的双 rAF 同理——远跳发生在目标行被测量之前会落偏下一帧测量完成后重滚一次才能真正居中。与 TOCView 的镜像对照本次修复刻意镜像了同目录下 TOCView 的既有设计两者共享同一套竞态模式关注点TOCViewBooknoteView延迟初始化重置 scrollTopinitialized回调内用activeHrefRef/flatItemsRef读取当前目标并 rAF 重滚initialized回调内用nearestIndexRef读取当前目标并双 rAF 重滚挂载时进度已知initialState(() getInitialScrollTarget(...))initialTopMostItemIndexuseState(() nearestIndex)initialTopMostItemIndex首次跳转门控initialScrollHandledRefinitialScrollHandledRef可见中心跟踪rangeChanged→visibleCenterRef同远近分流 E-inkisEink \|\| distance 16 → auto同容器高度测量.scroll-containerResizeObserver下限 400px同TOCView 侧的历史背景见记忆文档 toc-expand-and-autoscroll.md折叠默认化issue #4059后当前章节不再滚入视野根因同样是同一 commit 内列表增长数十行 单次scrollToIndex在测量前触发落偏修复手段是userInputRef区分真实手势与合成滚动、以及behavior:auto时的 rAF 重断言——BooknoteView 的initialized双 rAF 正是这一思路的延续。过滤/搜索时的行为约定有一个容易被忽略的交互约定当标注面板处于过滤/搜索状态时列表从阅读位置的镜像变成结果集此时必须抑制自动滚动否则每次敲键都会把用户的滚动位置拽走。实现上isFiltering为真时 effect 直接 return并顺带把lastScrolledCfiRef清空——这样用户清除过滤后列表能重新以阅读位置为中心而不是被旧的 ref 相等性守卫跳过。滚动行为定义在 BooknoteView.tsx。测试如何强制ref 式修复单元测试 BooknoteView.test.tsx 的设计非常讲究它刻意只捕获第一次挂载时的initialized回调模拟 OverlayScrollbars 在延迟初始化时绑定事件处理器的真实时序。如果修复依赖更新的 render 闭包测试会通过但真机仍会坏——所以测试迫使实现走 ref 路径。具体手段vi.mock(react-virtuoso)用forwardRef桩替换 Virtuoso通过useImperativeHandle暴露可 spy 的scrollToIndex并捕获传入的initialTopMostItemIndexpropsvi.mock(overlayscrollbars-react)捕获events.initialized由测试在act()内按需触发requestAnimationFrame被vi.stubGlobal同步执行让回调里的滚动发生在act()内避免异步泄漏。四个核心断言对应该文档描述的完整修复语义重载 进度迟到先以progressnull挂载随后 rerender 注入进度触发常规滚动mockClear后触发initialized断言scrollToIndex以{index: 9}扁平列表最后一个组头条目对中的笔记行被重新调用——证明重载路径不滞留顶部无进度时不滚动initialized触发后scrollToIndex未被调用挂载时进度已知断言initialTopMostItemIndex {index: 9, align:center}且此时scrollToIndex一次都没被调用避免对未测量列表发起竞态滚动initialized后再断言一次{index: 9}重放远跳瞬时12 条跨章节笔记使最近标注落在 index 23distance16断言调用为{index:23, behavior:auto}且从未出现behavior:smooth。TOCView.test.tsxTOCView.test.tsx使用完全相同的 mock 骨架两套测试互为镜像任何一方破坏都能快速定位是哪一侧的初始化重置/首跳门控逻辑退化。开发验证的实战经验该记忆文档特别记录了三条耗费数小时的开发环境陷阱对任何在此类代码上工作的人都值得保留Dev-server 来自错误 worktreelocalhost:3000可能是另一个 worktree 在服务原文场景为/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed改动不编译进去就不会生效。排查方式ps aux | grep next-server查看服务进程的 cwd且图书数据按 origin 隔离OPFS/IndexedDB 绑定 localhost:3000换端口无法验证Fast Refresh 会腐蚀已挂载标签页的状态约 10 次快速文件同步后原本正常的代码会开始渲染 0 条——必须在全新标签页中验证关闭旧标签页再开新页虚拟化列表的 DOM 计数不可信Chrome MCP 的javascript_tool同步查询.booknote-item数量会撞上 Virtuoso 渲染中帧返回 0应以截图为准已绘制帧不要相信同步 DOM 计数。总结BooknoteView 的自动滚动回归修复本质上是虚拟列表 延迟初始化滚动容器组合下的定位竞态治理OverlayScrollbars 的 deferred init 会无条件重置scrollTopVirtuoso 对未测量行高的scrollToIndex要么 no-op 要么卡死渲染。可靠的解法是三条防线并用——initialTopMostItemIndex原生居中首帧、initialized回调经 ref 重放定位、lastScrolledCfiRefinitialScrollHandledRef双守卫避免重复/竞态滚动——并通过 stub 测试把必须走 ref这一约束固化下来。这套模式已在 TOCView 与 BooknoteView 两处独立验证可直接作为同类侧边栏虚拟列表如 Bookshelf的参考实现。【免费下载链接】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),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价