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

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

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

只观察 sentinel 不够

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

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

触发器需要同时监听相交、滚动和列表状态变化:

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

composable 只负责触发

我倾向于把分页请求、cursor、错误态和去重留给列表 owner。哪个接口、cursor 怎么更新、失败是否展示重试、tab 切换要不要清空旧数据,这些都强依赖业务。useLoadMoreTrigger 只负责判断当前时机是否应该调用 loadMore()。

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

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

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

多种触底信号,共享同一个加载闸门

用 ESLint 提醒别再手写一套

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

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

这条检查现在已经沉淀到 @sheng/eslint-plugin 里的 @sheng/vue-prefer-load-more-trigger。项目接入时优先看规则文档和插件的使用指南,不要再从这篇文章里手动复制旧规则源码。

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

使用 ESLint 插件

规则源码入口在 src/rules/vue-prefer-load-more-trigger/index.ts。核心实现只看两个信号:当前文件是否从 @vueuse/core 导入了 useIntersectionObserver,源码文本是否同时出现 sentinel 命名和 loadMore / hasMore / nextIndex 这类加载更多语义。两个信号都命中时,规则才提示优先使用统一的 useLoadMoreTrigger。

规则页里保留了可编辑示例、接入配置、维护入口和测试位置。迁移时需要保留加载闸门,并选好重新检查的依赖:

  • useLoadMoreTrigger 里的 loadMore 必须还能被调用方的 canLoadMore 拦住,避免滚动和观察器同时触发时重复请求。
  • recheckSources 要按当前列表状态选择,只包含会影响触底判断的状态,不要为了「保险」传入整个 view model。

触发边界留给触发器

无限滚动的触发信号有三个:相交回调、滚动距离,以及数据变化后的重新检查。useLoadMoreTrigger 只守住触发边界,分页语义继续留给列表 owner;ESLint 规则也只提醒疑似 load-more 的手写观察器,不拦普通曝光和懒加载。