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.tsx);flatItems:把分组树拍平成{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](即"恰好在阅读位置之前或等于它的那条"),并处理lo===0与lo>=length两个边界。值得注意的实现细节:该函数运行在 render 阶段的useMemo中,因此它主动过滤掉非字符串/空字符串的 CFI 条目——同步往返可能把null塞进BookNote.cfi,不加防护的抛错会直达应用错误边界,把整个阅读器替换成崩溃页。
nearestIndex则是nearestCfi在扁平列表中的下标(-1表示无目标),见 BooknoteView.tsx。它被useMemo缓存,作为"唯一事实来源"同时供滚动 effect 和 OverlayScrollbars 的initialized回调读取。
两条失败路径与各自修复
#4352 的回归表现为:打开侧边栏标注面板后,列表停留在顶部显示"第 1 章",而不是自动滚到当前阅读位置最近的标注。根因拆成两条相互独立的失败路径,修复方式也完全不同。
路径 1:重载场景——进度在挂载后才到达
场景:刷新页面时标注标签页恰好处于激活态,BooknoteView先挂载(此时progress.location为null,nearestIndex为-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 里对刚挂载、尚未测量行高的列表同步调用scrollToIndex:
behavior: 'smooth':调用直接 no-op(Virtuoso 还没有可滚动的量程);behavior: 'auto':更糟,会把 Virtuoso卡死在渲染空白状态。
修复是照搬 TOCView 的设计:让 Virtuoso 在首次渲染时就原生居中,彻底跳过"挂载后立刻滚动"这一步:
// 挂载时快照一次最近下标;>0 才需要居中 const [initialTopIndex] = useState(() => nearestIndex); const initialScrollHandledRef = useRef(initialTopIndex > 0);initialTopMostItemIndex传给 Virtuoso:
<Virtuoso 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回调的activeHrefRef+flatItemsRef读取,以及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-behavior); behavior:'auto'时补一发 rAF 重断言:与initialized回调的双 rAF 同理——远跳发生在目标行被测量之前会落偏,下一帧测量完成后重滚一次才能真正居中。
与 TOCView 的镜像对照
本次修复刻意"镜像"了同目录下 TOCView 的既有设计,两者共享同一套竞态模式:
| 关注点 | TOCView | BooknoteView |
|---|---|---|
| 延迟初始化重置 scrollTop | initialized回调内用activeHrefRef/flatItemsRef读取当前目标并 rAF 重滚 | initialized回调内用nearestIndexRef读取当前目标并双 rAF 重滚 |
| 挂载时进度已知 | initialState(() => getInitialScrollTarget(...))+initialTopMostItemIndex | useState(() => nearestIndex)+initialTopMostItemIndex |
| 首次跳转门控 | initialScrollHandledRef | initialScrollHandledRef |
| 可见中心跟踪 | rangeChanged→visibleCenterRef | 同 |
| 远近分流 + E-ink | isEink \|\| distance > 16 → 'auto' | 同 |
| 容器高度测量 | .scroll-container+ResizeObserver,下限 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,并捕获传入的initialTopMostItemIndexprops;vi.mock('overlayscrollbars-react'):捕获events.initialized,由测试在act()内按需触发;requestAnimationFrame被vi.stubGlobal同步执行,让回调里的滚动发生在act()内,避免异步泄漏。
四个核心断言(对应该文档描述的完整修复语义):
- 重载 + 进度迟到:先以
progress=null挂载,随后 rerender 注入进度触发常规滚动,mockClear后触发initialized,断言scrollToIndex以{index: 9}(扁平列表最后一个"组头+条目"对中的笔记行)被重新调用——证明重载路径不滞留顶部; - 无进度时不滚动:
initialized触发后scrollToIndex未被调用; - 挂载时进度已知:断言
initialTopMostItemIndex === {index: 9, align:'center'}且此时scrollToIndex一次都没被调用(避免对未测量列表发起竞态滚动),initialized后再断言一次{index: 9}重放; - 远跳瞬时:12 条跨章节笔记使最近标注落在 index 23,
distance>16断言调用为{index:23, behavior:'auto'}且从未出现behavior:'smooth'。
TOCView.test.tsx(TOCView.test.tsx)使用完全相同的 mock 骨架,两套测试互为镜像,任何一方破坏都能快速定位是哪一侧的"初始化重置/首跳门控"逻辑退化。
开发验证的实战经验
该记忆文档特别记录了三条耗费数小时的开发环境陷阱,对任何在此类代码上工作的人都值得保留:
- Dev-server 来自错误 worktree:
localhost: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 会无条件重置scrollTop,Virtuoso 对未测量行高的scrollToIndex要么 no-op 要么卡死渲染。可靠的解法是三条防线并用——initialTopMostItemIndex原生居中首帧、initialized回调经 ref 重放定位、lastScrolledCfiRef+initialScrollHandledRef双守卫避免重复/竞态滚动——并通过 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),仅供参考