把无限滚动触底触发器抽成 composable:虚拟列表、哨兵和滚动兜底

无限滚动看起来很简单:列表底部放一个 sentinel,IntersectionObserver 看到它进入视口,就请求下一页。

这个写法在普通长列表里通常没问题。但一旦遇到虚拟列表、短列表、tab 切换或者首屏接口慢一点,它就开始漏触发。最常见的现象是:列表明明还有下一页,用户已经滚到底了,但下一页没有请求。

只观察 sentinel 不够

IntersectionObserver 的回调依赖「相交状态发生变化」。虚拟列表会不断改写内容高度和 DOM 节点,底部 sentinel 可能一直停在接近视口的位置。它已经是相交状态了,后面用户继续滚动,浏览器未必再派发一次新的相交事件。

短列表也有类似问题。组件 mounted 的首帧,sentinel 已经在视口里,但这时首屏请求还没回来,hasMoreloading 或 cursor 还不允许加载下一页。等数据回来后,DOM 高度变了,sentinel 仍然在底部附近,观察器不会自动替你「重放」第一次相交。

所以真正需要沉淀的是一套触发机制:

  • sentinel 相交时触发一次。
  • 滚动容器接近底部时也触发一次,给虚拟列表兜底。
  • 列表长度、loading、hasMore、cursor 这类状态变化后,等 Vue 完成 DOM patch,再等下一帧布局完成,重新按滚动距离判断一次。

composable 只负责触发

这里很容易犯另一个错:既然已经抽成 composable,那要不要把分页请求、cursor、错误态、去重都放进去?

我更倾向于不要。分页语义属于列表 owner:哪个接口、cursor 怎么更新、失败是否展示重试、tab 切换要不要清空旧数据,这些都强依赖业务。useLoadMoreTrigger 只负责回答一个问题:当前时机是否应该调用 loadMore()

它接收的输入也因此很窄:

  • scrollElement:真实滚动容器。
  • sentinelElement:底部哨兵节点。
  • canLoadMore:调用方计算好的闸门,通常是 hasMore && !loading
  • loadMore:触发下一页的函数。
  • recheckSources:列表长度、cursor、loading、hasMore 等会影响触底判断的状态。

这样做的好处是边界清楚。后续换虚拟列表库、换分页接口、换 tab 结构,都不需要改触发器本身。

用 ESLint 提醒别再手写一套

有了 composable 以后,项目里还会不断出现新的手写 useIntersectionObserver。有些是合理的,比如曝光埋点、图片懒加载;有些则是新的无限滚动列表,应该直接复用统一触发器。

规则 prefer-load-more-trigger 的判断比较保守:只有源码里同时出现 sentinel 命名,以及 loadMore / hasMore / nextIndex 这类加载更多信号时,才会提示 @vueuse/core 里的 useIntersectionObserver。它不自动修复,因为不同列表 owner 需要自己决定 canLoadMorerecheckSources

第一版建议用 warn。它只把「疑似又手写了一套 load-more 触发器」的代码变成编辑器和 CI 里可见的提醒,普通 IntersectionObserver 场景继续保留。

源码包可以直接迁移

请把 Load More Trigger Guardrail 接入当前项目。
工具包根路径:https://shengsheng.fun/files/load-more-trigger-composable-virtual-scroll/kits/load-more-trigger-guardrail/
先读 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md,再按 FILES.json 读取源码;迁移 useLoadMoreTrigger、prefer-load-more-trigger 规则和测试,把规则先用 warn 接入。
无限滚动触底触发器源码正在加载代码工作区...

迁移时注意两点。第一,useLoadMoreTrigger 里的 loadMore 必须还能被调用方的 canLoadMore 拦住,避免滚动和观察器同时触发时重复请求。第二,recheckSources 要贴着当前列表状态选,不要为了「保险」把整个 view model 都塞进去;触发器只需要知道哪些状态变化会影响「现在是否已经接近底部」。

总结

  • 无限滚动不是只有 sentinel,相交回调、滚动距离和数据变化后的重检要配合起来。
  • composable 只负责触发,不持有分页语义,业务状态仍留在列表 owner。
  • ESLint 规则只提示疑似 load-more 的手写观察器,不拦普通曝光和懒加载。