把无限滚动触底触发器抽成 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 等会影响触底判断的状态。
这样可以把接口与分页语义留给列表 owner。后续换库或布局时,仍要核对真实滚动容器、哨兵与重新检查依赖是否一致。
用 ESLint 提醒复用触发器
有了 composable 以后,项目里还会不断出现新的手写 useIntersectionObserver。有些是合理的,比如曝光埋点、图片懒加载;有些则是新的无限滚动列表,应该直接复用统一触发器。
规则 @sheng/vue-prefer-load-more-trigger只检查两个信号:当前文件是否从 @vueuse/core 导入了 useIntersectionObserver,源码文本是否同时出现 sentinel 命名和 loadMore / hasMore / nextIndex 这类加载更多语义。都命中时,才提示优先复用 useLoadMoreTrigger。
源码入口是 src/rules/vue-prefer-load-more-trigger/index.ts。接入配置、可编辑示例、维护入口和测试位置跟随规则文档维护,parser 等配置参考插件的使用指南。
这只是疑似 load-more 的启发式提醒,不拦普通曝光和懒加载,也不自动修复。第一版可以用 warn 观察命中情况;各列表仍须自行确定 canLoadMore 和 recheckSources。
迁移时需要保留加载闸门,并选好重新检查的依赖:
useLoadMoreTrigger里的loadMore必须还能被调用方的canLoadMore拦住,避免滚动和观察器同时触发时重复请求;列表 owner 需要在发起请求前同步设置 loading,或由触发器提供独立的 in-flight 闸门。- 请求失败后,不要仅凭
loading变回false就重新放行。将错误态或重试冷却也纳入canLoadMore,由用户重试或受控退避重新开启;否则哨兵一直可见时,状态复查可能变成连续重试。接口连续返回空页但仍声明hasMore时,也需要无进展保护。 recheckSources要按当前列表状态选择,只包含会影响触底判断的状态,不要为了「保险」传入整个 view model。