
微信小程序里做导航栏吸顶看起来就是“页面往下滚动一段距离后把导航栏从文档流里捞出来固定到顶部”这种效果在 Web 端用一行position: sticky就能搞定但放进小程序里就完全不是那么回事。我在一个资讯类小程序里给首页的分类导航做吸顶改造时最初也觉得“这有什么难的”结果被页面跳动、scroll-view 不触发滚动、iOS 下 sticky 失灵这几个坑轮流教育了一轮最后才整理出一套稳定的实现思路。这篇文章就围绕“导航栏滑动一定距离后固定到顶部”这个需求从需求拆解、核心 API 准备、页面实现到排坑经验完整过一遍适合正在做信息流或列表页导航吸顶的同学参考也适合想把手头代码优化成可复用组件的开发者。内容以微信原生框架为主但思路在 uni-app、Taro 里同样适用。1. 先拆解需求吸顶导航栏的实现方案为什么不能照搬 Web1.1 页面结构里“吸顶”到底吸的是什么在讨论方案之前先把页面结构说清楚。大多数需要吸顶的页面是这种结构顶部一张轮播图或搜索框下面跟着一排分类导航Tab 栏再往下是内容列表。页面的滚动发生在最外层 page 节点上导航栏一开始处于文档流靠前的位置当用户向下滑动导航栏随着内容一起往上走走到视口顶部时我们希望它停住后面的列表继续滚动。换句话说导航栏从“参与页面流动的普通元素”变成了“悬停在视口顶部的固定元素”这种切换的本质是定位方式从文档流内的 static/relative 变成了脱离文档流的 fixed。这个描述很关键因为后面所有坑基本都和“脱离文档流”这四个字有关。一旦元素脱离文档流它原本占据的空间就空了出来如果没有人接管这块空白页面内容就会瞬间上跳一旦元素脱离文档流它就不再遵循父容器的宽度约束必须手动设置left:0; right:0才能撑满屏一旦元素脱离文档流它和其他悬浮元素的层级关系就需要重新梳理。理解这一层再去看各种实现方案思路会清晰很多。1.2 Web 一行 CSS 的道理小程序里为什么走不通Web 端最直接的方案是position: sticky给导航栏一个top: 0它自己会根据滚动位置粘住不需要监听滚动事件也不用操心文档流和占位符。小程序基于 WebView 渲染理论上支持 sticky但实际落地时会遇到几个问题。第一小程序页面的滚动容器是 page 而不是 html/body部分 iOS 低版本 WebView 和某些小程序基础库版本里sticky 在 page 上的表现并不稳定容易出现粘到一半失效、或者与其他定位元素叠加后层级错乱的情况。第二小程序很常见的做法是页面内再加一层 scroll-view 来做局部滚动这种情况下 sticky 的参照容器变成 scroll-view使用范围又受了一轮限制。第三如果页面有下拉刷新、分享时弹出的面板、自定义导航栏等结构fixed 定位和 sticky 的叠加关系在真机上容易出现视觉错位。所以虽然很多博客会告诉你 sticky 一行搞定但我个人的经验是在微信小程序里除非你的页面结构非常简单、不需要兼容老版本否则别把吸顶的核心逻辑押在 sticky 上而是要主动监听滚动位置程序化地切换定位方式。这样做的代价是多写几十行代码换来的是跨机型、跨基础库版本的稳定表现。1.3 三种主流方案的对比与结论我把小程序吸顶常用的三种方案整理了一下可以对比着看方案核心思路优点缺点onPageScroll fixed 切换监听页面滚动滚动距离超过临界值后给导航栏加 fixed兼容性好、可控性强、支持自定义动画需要处理占位符、需要动态获取临界值position: sticky样式上直接粘住代码量少、无占位符问题iOS 兼容性不稳、scroll-view 内表现怪异IntersectionObserver观察导航栏或其上方标记元素是否离开视口回调不频繁、性能好触发时机有延迟直接观察导航栏会晚于预期需要额外标记元素配合对比后结论也很明显多数情况下我推荐第一种动态获取临界值 fixed 切换 占位符这是最稳、最可控、最能应对各种页面结构的方案。IntersectionObserver 适合对性能要求高且能接受额外埋点元素的场景第五章节我会展开讲。2. 核心 API 准备onPageScroll 与 createSelectorQuery 的正确用法2.1 onPageScroll 是页面级滚动事件的唯一入口在微信小程序里如果你滚动的不是 scroll-view而是整个页面那么能监听到滚动位置变化的核心回调就是onPageScroll。它写在页面的 Page 配置里Page({ onPageScroll(e) { console.log(当前页面滚动高度, e.scrollTop) } })这里有两个容易混淆的点。首先是onPageScroll只对页面本身的滚动生效如果你在页面里放了一个纵向的 scroll-view 并且内容是它滚动的onPageScroll根本不会被触发这时候要去 scroll-view 上绑bindscroll事件对象里的detail.scrollTop才是那个局部容器的滚动距离。其次无论在 Android 还是 iOS 上onPageScroll都可能在短时间内被高频触发滚动时可能一帧回调好几次。这条信息看着不起眼实际上决定了你下面setData的写法——如果每次回调都无脑 setData 一个布尔值页面会有肉眼可见的掉帧风险。小程序的数据更新走的是逻辑层和渲染层的通信通道频繁触发会在窄桥上反复排队列表越长、页面节点越多卡顿越明显。2.2 用 createSelectorQuery 拿到导航栏真实的初始位置临界值到底怎么定很多人会直接写死一个数字比如scrollTop 200就吸顶。这样写能跑但非常脆弱一旦轮播图高度变了、不同机型的 rpx 换算有误差、或者导航栏上面多了个提示条这个数字就得跟着改而且你还很难第一时间察觉。更稳的做法是动态测量。在页面onReady时用wx.createSelectorQuery查询导航栏节点相对视口顶部的位置onReady() { const query wx.createSelectorQuery() query.select(#categoryNav).boundingClientRect(rect { // rect.top 是导航栏初始时距离视口顶部的距离 if (rect) { this.navTop rect.top } }).exec() }这里有几个细节必须说明。第一查询必须在onReady之后执行onLoad阶段页面可能还没完成渲染节点可能查不到rect 会是 null所以要等到渲染完成再去拿位置。第二如果需要查询的导航栏在自定义组件内部需要改成query.in(this)并且把查询和节点都限定在组件内部const query wx.createSelectorQuery().in(this) query.select(.nav).boundingClientRect(rect { this.navTop rect.top }).exec()第三这里拿到的rect.top是“导航栏还没开始滚动时它距离视口顶部的距离”。如果页面顶部就是导航栏top 会是 0这种情况吸顶毫无意义因为它在初始状态下就已经贴着顶部了。通常 top 大于 0意味着它上面还有轮播图、搜索区、或一个自定义头部恰恰是因为这些内容把导航栏往下推了一截我们才需要等它滚上来之后吸顶。2.3 临界值的本质滚动距离与元素位置的关系理解临界值其实就是一个简单的坐标系问题。导航栏初始距离视口顶部是navTop当页面向下滚动scrollTop时视觉上导航栏被向上推了scrollTop的距离所以导航栏在视口内的实时位置等于navTop - scrollTop。要让导航栏刚好贴住视口顶部就要求navTop - scrollTop 0也就是scrollTop navTop。当scrollTop大于navTop时导航栏已经跑到视口上方去了这时再不给它加 fixed 就会继续往上滚出视线。所以判断条件最好是if (e.scrollTop this.navTop) { // 吸顶 } else { // 还原 }如果项目为了省事想用一个固定阈值比如简单的 200阈值代表的是“导航栏距离页面顶部的高度”在结构固定不变时写死是没问题的但如果上游的内容高度会动态变化比如轮播图是接口返回的、公告条会根据活动展示固定数字就隐藏了 bug。我一般把阈值做成动态测量只有极少数永远不变的结构才写死。3. 完整实现过程从零写出可复用的吸顶导航栏3.1 页面结构设计用一个包裹层解决占位问题先看 WXML 结构。最关键是导航栏外面包一层这一层在导航栏固定时负责“占位”view classpage view classbanner轮播图/view !-- 吸顶触发区导航栏被推出的初始位置 -- view classnav-wrapper style{{isFixed ? height: navHeight px; : }} view classnav {{isFixed ? nav--fixed : }} view classnav__item wx:for{{tabs}} wx:keyindex>onReady() { const query wx.createSelectorQuery() query.select(.nav).boundingClientRect(rect { if (rect) { this.navTop rect.top this.setData({ navHeight: rect.height }) } }).exec() }这样navHeight是动态的不管导航栏是单行还是换行成两行占位高度都不会出错。3.2 JS 逻辑判断临界值并切换吸顶状态页面逻辑的核心非常短Page({ data: { isFixed: false, navHeight: 0, activeIndex: 0, tabs: [推荐, 热门, 最新, 关注] }, onReady() { const query wx.createSelectorQuery() query.select(.nav).boundingClientRect(rect { if (rect) { this.navTop rect.top this.setData({ navHeight: rect.height }) } }).exec() }, onPageScroll(e) { if (!this.navTop this.navTop ! 0) return const shouldFixed e.scrollTop this.navTop if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }) } }, onTabTap(e) { const index Number(e.currentTarget.dataset.index) this.setData({ activeIndex: index }) // 切换 Tab 时的数据请求逻辑写在这里 } })这里的onPageScroll里有一个很容易被忽略的关键优化只有当shouldFixed和当前data.isFixed不一致时才调用setData。因为滚动过程中onPageScroll会被高频触发但isFixed这个变量在绝大多数时刻都是稳定的比如导航栏还没到顶时一直是 false到了顶之后一直是 true真正发生变化的只有跨越 navTop 的那一两帧。这个判断直接把setData的调用次数从“每帧若干次”降到了“整个滚动过程几次”性能差别非常明显。3.3 WXSSfixed 之后要补齐宽度和层级导航栏常规状态下是普通文档流元素fixed 状态下要脱离文档流所以样式要注意几个点.nav { display: flex; height: 100rpx; background: #fff; border-bottom: 1rpx solid #eee; } .nav--fixed { position: fixed; top: 0; left: 0; right: 0; z-index: 999; }有几个细节我在真机上吃过亏专门说一下。第一fixed 后元素默认宽度按内容收缩flex 主轴上的子项排列会变形所以必须显式加上left: 0; right: 0;宽度才会撑满屏幕。第二如果页面在吸顶时还有一个自定义的搜索框或其他 fixed 元素要注意 z-index 的层级关系导航栏的 z-index 最好高于普通内容但不要高到把弹窗也盖住具体要看项目里的弹层设计通常设到 999 左右已经够用。第三吸顶时导航栏背景色必须是实底色透明度太高的导航栏在列表内容滚动经过时会出现文字重叠视觉上是灾难。也别只设背景色不设 border脱离文档流后如果导航栏在文档流中原本靠下边框和其他元素分离fixed 后那个边框可能被内容盖住需要保留并提升层级。3.4 自定义导航栏场景状态栏高度和胶囊按钮的账要算清如果你的页面用了自定义导航栏也就是 app.json 或页面 json 里配置了{ navigationStyle: custom }那么页面的原生顶栏被干掉你需要在页面顶部自己撑出状态栏高度再放导航内容。常见做法是用微信提供的 API 拿到高度信息const { statusBarHeight } wx.getSystemInfoSync() const menuButton wx.getMenuButtonBoundingClientRect() // 自定义导航栏总高度 状态栏高度 胶囊按钮高度 上下留白 const navBarHeight statusBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height这种结构下如果你的“吸顶导航栏”其实指的就是整条自定义导航栏那你需要判断的是页面滚动超过某个距离后给这条自定义导航栏加 fixed 让它贴在最顶部而不是让它跟着页面内容一起滚走。要注意临界值应该如何计算如果自定义导航栏一开始就是 relative 放在页面顶部那么它初始 top 就是 0滚动任何距离它都已经在顶部谈不上吸顶如果它位于一个下拉头部的下方那么它的临界值就是下拉头部的高度测量方式依然用boundingClientRect。考虑到自定义导航栏涉及胶囊按钮的坐标计算吸顶后 fixed 定位的 top 不能只写 0因为状态栏高度也是页面内容的一部分。如果顶部没有 fixed 定位的其他东西top: 0 加在 page 上时导航栏会直接贴到物理屏幕顶端但会盖住状态栏区域。我一般的做法是fixed 的 top 仍然写 0因为状态栏区域本身应该是透明或者由导航栏背景色接管导航栏内容部分需要在内部padding-top: 状态栏高度让文字和胶囊按钮从状态栏下方开始排版。4. 实际接入后必踩的坑我逐个替你排掉4.1 页面内容“蹭”地往上跳占位高度的账不能省最常见的现象是滚动到临界点导航栏固定住了但下一瞬间整个列表顶部突然往上跳了一段滚动条位置也出现明显抖动。原因就是导航栏脱离文档流后腾出来的空白没有被填补下面的内容顺着这个缺口全部上移了。解决办法就是 3.1 里的nav-wrapper占位法。但这里有一个很隐秘的坑如果navHeight测量不准确比如测量时导航栏还是两行滚动固定后又因为宽度变化变成一行或者反过来占位高度和实际导航栏高度不匹配页面还是会出现小幅度跳动。所以我建议固定状态下的导航栏样式和正常状态保持完全一致尤其不要改变 padding、font-size、flex-wrap确保高度在切换前后一致。如果你用单独占位 view 的方案也就是在导航栏后面加一个高度等于导航栏高度的空 view那么占位 view 在非固定时期高度应该是 0固定时期才有高度这样才不会多占一段空白。这里最容易让人忽略的还有一点占位高度不要用 rpx 写死后再参与判断因为不同屏幕宽度下 rpx 换算成 px 之后和你用boundingClientRect量出来的高度可能差上几个像素这几个像素就足够让页面产生一次肉眼可见的跳动。4.2 滚动时掉帧setData 的节流策略不能只靠感觉onPageScroll高频触发的问题前面已经提过。除了只在状态变化时 setData还有一个实践技巧是给逻辑加一个滚动方向的判断。比如用户从底部快速回顶途中会经过临界点很多次但其实只有跨越临界点时状态才需要变化利用上文的 isFixed 对比已经天然覆盖。如果还是觉得性能有压力可以用定时器做 50ms 节流把高频滚动事件压成低频处理onPageScroll(e) { if (this.scrollThrottle) return this.scrollThrottle true setTimeout(() { const shouldFixed e.scrollTop this.navTop if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }) } this.scrollThrottle false }, 50) }但说实话在吸顶这个场景里因为我用状态变化对比已经过滤掉绝大多数无效 setData节流不是必需项反而可能让吸顶在快速滚动时多延迟 50ms。所以我的建议是先做状态变化过滤如果真机测试时发现掉帧再考虑加节流而且 50ms 已经是一个比较靠谱的平衡点。如果项目用的是 Skyline 渲染引擎滚动事件的行为和 WebView 渲染有一些差异需要另行测试不过绝大多数现网小程序还是以 WebView 渲染为主上面的方案足够覆盖。4.3 页面没滚动而是 scroll-view 在滚事件源都变了这是最容易让新手蒙圈的情况。在页面里放了一个纵向 scroll-view 作为列表容器列表滚动时onPageScroll不触发导航栏自然纹丝不动地跟着列表滚出了顶部。此时正确的做法是监听 scroll-view 的bindscroll事件scroll-view scroll-y styleheight: calc(100vh - 100rpx); bindscrollonScrollViewScroll !-- 列表内容 -- /scroll-viewonScrollViewScroll(e) { const scrollTop e.detail.scrollTop const shouldFixed scrollTop this.navTop if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }) } }注意在这种页面结构下导航栏和 scroll-view 是并排或上下关系导航栏吸顶后通常 position: fixed 到页面顶部但 scroll-view 的滚动范围要相应地调整否则导航栏虽然 fixed 了scroll-view 内容却可能在原本的位置继续滚两者视觉上对不齐。复杂度比整页滚动高不少所以如果不是业务必须用 scroll-view我建议优先选择整页滚动方案让页面自身的滚动和吸顶逻辑天然对齐。4.4 sticky 方案在 iOS 下的失灵现场虽然第一章节我建议了 onPageScroll 方案但实际团队里总有人为了省事把导航栏写成 position: sticky。如果页面结构非常简单确实能跑但我在 iOS 真机上遇到过几次诡异现象。一是 sticky 的导航栏会“粘”在容器底部而不是顶部检查后发现是因为上级容器的 overflow 设置了非 visible 值比如 overflow: hidden 截断了 sticky 的定位上下文。二是 iOS 某些低版本 WebView 中sticky 在小程序 page 根节点上偶尔失效表现是滚动到临界点后导航栏继续往上走完全没有停顿。三是如果导航栏上有 transform 动画sticky 位置会在一瞬间错乱因为 transform 改变了元素的包含块。所以如果团队里有人提议用 sticky我的态度是可以留作低风险的备选方案但核心逻辑必须兼容回退到 onPageScroll 方案。代码评审时看到 sticky我都会多加一层“真机微信版本覆盖测试”的要求特别是 iOS 端。4.5 下拉刷新、分享面板与 fixed 定位的遮挡纠葛吸顶导航栏固定后它是悬浮在页面最上层的z-index 通常不低。如果页面启用了原生下拉刷新或者要拉起分享面板、登录面板这类半屏弹层这些弹层组件本身是微信原生组件或处于更高层级一般不会和导航栏冲突。但如果你在小程序中自定义了一个下拉刷新的动画层一定要留意它的 z-index 是否被导航栏盖住否则下拉时看到的加载状态会被导航栏遮一块。分享面板同理微信用的是原生分享面板天然在页面之上没有困扰。但如果你自己的项目里用 view 仿了一个半屏弹层且这个弹层从顶部滑入它的 z-index 必须比吸顶导航栏高否则导航栏会悬在弹层上方露出一个难看的横条。4.6 还有一类容易被忽视的问题页面初次进场时导航栏高度没测到最后一个坑很隐蔽。如果导航栏区域的数据是异步加载的比如 Tab 文案和数量由接口返回那么onReady时测量到的 navHeight 可能是 0 或是不稳定的值等数据渲染完成后再测量才是真实高度。解决方式是在数据请求成功并 setData 完成之后用一个setTimeout或者wx.nextTick再做一次节点测量requestTabs().then(() { this.setData({ tabs: data }, () { wx.nextTick(() { const query wx.createSelectorQuery() query.select(.nav).boundingClientRect(rect { if (rect) { this.navTop rect.top this.setData({ navHeight: rect.height }) } }).exec() }) }) })否则一旦高度测错后面所有临界值判断都会跟着错。这个坑在我第一次接入动态 Tab 数据时困扰了很久写出来提醒大家少走弯路。还有一种情况是页面通过wx.navigateTo跳转后返回部分机型不触发 onReady 而是走 onShow如果导航栏高度在返回后才渲染出来临界值还是旧的也容易出现吸顶位置不对。稳妥做法是在 onShow 里也做一次节点测量成本和收益相比完全值得。5. 进阶扩展多吸顶元素、吸顶 Tab 切换与 IntersectionObserver5.1 一个页面有多个吸顶模块时怎么定临界值有些信息流页面不止一个导航栏比如顶部一个大分类导航内容区中间还有一个子分类导航两个都要在滚动到顶部时吸顶。这种情况不能只维护一个isFixed变量而是要为每个模块维护独立状态每个模块单独测自己的 navTop单独判断。onPageScroll(e) { const scrollTop e.scrollTop const changes {} if (scrollTop this.navTopMain) changes.navMainFixed true else changes.navMainFixed false if (scrollTop this.navTopSub) changes.navSubFixed true else changes.navSubFixed false this.setData(changes) }注意多个 fixed 导航栏同时出现在顶部时层级关系和上下顺序要预先设计好。比如大分类固定在顶部时子分类再 fixed 到大分类下方就需要子分类固定时的 top 值等于大分类的高度也就是两个固定元素依次堆叠。这个排布逻辑如果手动写容易乱我建议把所有吸顶元素抽象为一个数组配置统一测量、统一计算位置。比如this.stickyItems [ { key: navMain, top: 0 }, { key: navSub, top: 50 } ]滚动回调里遍历这个数组依次判断每个模块是否应该固定以及固定后它的 top 是多少。这样做的好处是后续再加第三个吸顶模块时只需往数组里加一项不用在回调里堆叠一堆 if-else。5.2 吸顶 Tab 栏的联动切 Tab、请求数据、更新位置导航栏吸顶后如果它本身是带 Tab 切换功能的比如“推荐、热门、最新”吸顶状态下用户切换 Tab导航栏要保持 fixed 不动但内容区要切换到对应列表。这里有几个联动细节。第一切换 Tab 后数据请求返回列表内容变化但如果列表变短页面滚动高度可能小于当前滚动位置小程序会自动调整滚动位置吸顶状态理论上不需要主动复位但最好在数据加载完成后重新确认一下当前 scrollTop 与 navTop 的关系避免状态错乱。第二吸顶状态下点击 Tab点击事件照常触发因为这个导航栏仍然是当前组件渲染的节点事件冒泡和正常视图没有区别。需要留意的是fixed 状态下的点击区域如果和其他 fixed 元素重叠比如底部安全区弹层会产生点击穿透通常用 z-index 规避。第三如果内容区使用了页面滚动切换 Tab 后你可以选择把滚动位置恢复到顶部这时页面 onPageScroll 会触发一次吸顶状态会由 scrollTop 和 navTop 的关系自动判断不需要额外干预。这种联动还有一个细节值得注意如果切换 Tab 后列表数据变短页面最大滚动高度小于当前 scrollTop小程序会自动回收滚动位置此时 onPageScroll 会被触发如果navTop是固定不变的吸顶状态会自动保持稳定。但如果导航栏上方有折叠区域折叠区域的高度因为数据加载改变了临界值就变了这时候需要重新测量 navTop。5.3 用 IntersectionObserver 代替滚动监听更优雅但要注意触发时机前面我们提到IntersectionObserver 直接观察导航栏时触发时机偏晚不适合“刚碰到顶部就固定”的精确需求。但我们可以换个思路在导航栏正上方放一个高度恰好等于 navTop 的标记节点 trigger观察 trigger。当 trigger 完全滚出视口顶部时恰好说明导航栏已经到达视口顶部下方一点点此时执行固定时机比直接观察导航栏准确得多。代码示例this.observer wx.createIntersectionObserver(this) this.observer .relativeToViewport({ top: 0 }) .observe(#scrollTrigger, res { const shouldFixed res.intersectionRatio 0 if (shouldFixed ! this.data.isFixed) { this.setData({ isFixed: shouldFixed }) } })其中#scrollTrigger是导航栏之前的一个标记节点。这个方案的好处是触发时机和滚动事件解耦不会每帧回调性能上更干净。缺点是标记节点必须在导航栏之前且高度精准一旦上方内容动态变化需要同步调整 trigger 的高度复杂度会上升。综合来看我觉得性能敏感型业务可以上这个方案通用业务用 onPageScroll 就足够了。如果要用记得在页面 onUnload 时调用this.observer.disconnect()断开观察避免内存泄漏。5.4 组件化封装的最终建议与我的实践体会最后说说组件化。吸顶逻辑跨页面复用率很高我建议封装成一个通用吸顶容器组件对外暴露几个参数参数作用threshold吸顶临界距离可传数字不传则自动测量top固定时的 top 值默认为 0多吸顶场景可传上方已固定元素的高度zIndex吸顶后的层级默认 999组件内部用前面整套逻辑onReady 测量、pageLifetimes.pageScroll 监听滚动、状态切换时同步占位高度。这样业务页面只需把导航栏塞进组件里声明 threshold 或留空吸顶效果就自动生效。如果用的是自定义组件监听页面滚动需要在组件里配置 pageLifetimesComponent({ pageLifetimes: { pageScroll(e) { // 此时可以拿到 e.scrollTop逻辑和页面里写的完全相同 } } })我在实际项目中用这套封装后后续新页面接入吸顶基本就是几十行配置的事而且因为临界值动态测量轮播图高度调整、运营位增减都不需要再改页面代码。这是我个人认为这套方案最大的价值——它把一次性的“改样式”变成了可持续复用的“基础设施”。整篇文章的场景以微信原生小程序为主如果你在用 uni-app 或 TaroAPI 层面差异不大只需要把wx.createSelectorQuery换成对应平台的方法名思路完全一致。真机上如果还有奇怪的吸顶问题建议先确认基础库版本和 iOS 版本再回头检查 navTop 和占位高度这两个数字——根据我的经验八成以上的吸顶异常最后都落在它们身上。