前言:吸顶交互为什么总是写得又复杂又不稳?
在移动端(尤其是 UniApp 多端)做 Tab 吸顶,经常会遇到这些痛点:
- 状态判断难:什么时候进入吸顶?什么时候退出?临界点经常抖动或算不准
- 临界值难算:导航栏高度、状态栏安全区、不同机型差异,导致
scrollTop阈值到处写魔法数 - 性能与维护差:
onPageScroll / scroll高频触发,回调里反复计算元素位置,逻辑越写越“重”
更好的思路是:不用监听滚动,不用自己算临界点,让浏览器/小程序引擎帮我们判断“某个元素是否越过了一条线”。
核心思路:一条 1px 的“哨兵线”
在 Tab(或需要吸顶的内容)附近放一个几乎不可见的节点(例如 1px 高),把它当成“哨兵”。
然后做两件事:
- 用
IntersectionObserver监听哨兵是否还“在观察区域内”,从而判断是否吸顶 - 通过
rootMargin(UniApp 对应relativeToViewport/relativeTo的top参数)把“顶部触发线”下移到offsetTop(通常是导航栏 + 安全区高度)
你可以把它理解成这样(示意):
1 | [系统状态栏/安全区] |
当页面滚动导致哨兵线越过这条“虚拟触发线”,我们就认为 Tab 需要吸顶。
原理拆解:IntersectionObserver 在这里解决了什么?
IntersectionObserver的本质:异步观察“目标元素”与“参考区域(viewport 或容器)”是否相交- 我们要的信号:哨兵元素一旦不相交(例如
intersectionRatio === 0),就说明它已经滚过了触发线,从而触发showSticky = true
相比 scroll 事件,这种做法:
- 性能更优:避免高频回调 + 手动计算
- 语义更清晰:吸顶判断变成“哨兵是否可见”
- 更好维护:触发线统一由
offsetTop控制
封装实现:useStickySentinel(Vue3 + TypeScript + UniApp)
下面是一个可复用的组合式函数:
- 你只需要提供:
- 哨兵节点选择器
sentinelSelector - 顶部偏移
offsetTop(支持 Ref/Computed/函数) - 可选参照容器
relativeToSelector(用于容器滚动场景)
- 哨兵节点选择器
1 | import type { ComputedRef, Ref } from "vue"; |
关键点解释(为什么它“稳”)
offsetTop支持 Ref/Computed/函数
- 导航栏高度、安全区高度往往是动态的(例如异步计算、机型差异、横竖屏切换)
- 统一在
offsetTopPx里转成数字,调用方只管把“真实高度”喂进来即可
- 用
topMargin = -offsetTop定义“虚拟触发线”
relativeToViewport({ top: -offsetTop })的效果是:把观察区域顶部向下“推”了offsetTop像素- 当哨兵滚过这条线,它就会被判定为不相交,从而触发
showSticky = true
- 兼容 observer 上下文
- UniApp 的
createIntersectionObserver需要正确的上下文对象 - 先用组件实例
instance.proxy,再兜底用当前page,能减少不同端/不同写法下的兼容坑
watch(offsetTopPx)变化后重建 observer
offsetTop变化意味着触发线变化- 重建比“试图在原 observer 上修修补补”更稳、更直观
- 销毁时
disconnect
- 页面/组件销毁不清理 observer,可能出现内存泄漏或回调继续触发的问题
怎么用:页面里接入吸顶
1)模板里放一个哨兵节点(建议用 id)
1 | <view id="home-category-nav-sentinel" style="height: 1px;"></view> |
2)脚本里调用
1 | const offsetTop = computed(() => { |
3)容器滚动(比如 scroll-view)
如果不是页面滚动,而是某个容器滚动,就传 relativeToSelector:
1 | const { showSticky } = useStickySentinel({ |
常见坑与实战建议
- 哨兵节点未渲染就开始 observe
- 建议:
onMounted + nextTick后再setupStickyObserver
- 建议:
offsetTop变化后触发点不准- 建议:保留
watch(offsetTopPx)的重建逻辑(尤其是自定义导航栏高度异步计算场景)
- 建议:保留
- 重复创建 observer 导致多重监听
- 建议:每次重建前先
disconnect()(本实现已处理)
- 建议:每次重建前先
- 不同端回调字段差异
- 建议:用
intersectionRatio === 0做判断相对更稳(本实现已采用)
- 建议:用
总结
通过“哨兵线 + IntersectionObserver”,我们把吸顶实现从:
- 高频滚动监听 + 手动计算位置
变成:
- 监听哨兵可见性 + 一个统一的 offsetTop 触发线
这套方案的核心优势是:逻辑更清晰、性能更友好、在多端场景下更可控。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 吴星喜的博客!

