微信小程序吸顶幽灵:scroll-view 状态残留三层根因
一个 C 端小程序列表页,顶部有一条搜索栏,设计上要求滚过一定阈值后吸顶固定。逻辑很常见:hidden="{{!tabFix}}" 控制吸顶元素,class="{{tabFix ? 'op0' : ''}}" 让原始位置的搜索栏透明。
上线后收到反馈:冷启动进页面,或切 tab 回来,吸顶搜索栏就已经出现了,和正常流内的搜索栏同时显示,叠在一起。
本地怎么复现都没问题,在测试机上概率出现,换个设备又消失。典型的"我这里没事"bug。
现象
四种触发路径,任意一条都会留下幽灵:
- 冷启动进入列表页 → 吸顶头部已激活,未滚动
- 在页面已滚(
tabFix: true)时切换 tab,再切回来 → 上次的tabFix还在 - 下拉刷新后内容回到顶部 → 吸顶头部还浮在那里
- 数据加载期间快速切 tab → 幽灵随机出现
根因
三层缺陷叠加,缺一不可。
第一层:tabBar 页面不销毁,onShow 没有重置
tabBar 页面在 tab 切换时不会被销毁,onLoad 只在首次创建时执行,onShow 每次切回都会触发,但通常只做业务刷新。tabFix 这个表示"是否吸顶"的状态,跟着用户上一次的滚动位置一起保活了:
// 问题代码:onShow 没有重置 tabFix
onShow: function () {
// tabFix 保留上次的值,若用户之前滚过就是 true
that.store.data.headSearch = that.data.tabFix ? true : false // 传播了过期状态
}第二层:onRefresh 忘记重置 tabFix
下拉刷新会清空列表、让 scroll-view 滚回顶部,却漏了重置吸顶状态:
// 问题代码:onRefresh 缺少 tabFix 重置
onRefresh() {
this.setData({
typeChange: true,
// tabFix: false, ← 漏了这行
dataList: [],
})
}scroll-view 位置回到了零,但 tabFix 还是 true,吸顶头部继续浮着。
第三层:内容变更触发伪滚动事件 + typeChange 锁死
这是最隐蔽的一层。setData 改变列表数据时,scroll-view 会触发滚动事件,此时 boundingClientRect 可能返回 null 或位置不准。再加上为了防止 tab 切换闪烁而设计的 typeChange 标志,滚动处理器里有一段 OR 条件:
if (rect.top <= threshold || (that.data.tabFix && that.data.typeChange)) {
// tabFix 被锁定为 true,无法进入 else 分支重置
}一旦 tabFix 为 true 且 typeChange 也为 true(刷新期间),就形成死锁:即便元素位置已经在阈值之上,吸顶状态也无法被清除。
typeChange 设计初衷是"tab 切换内容时防闪烁",但它会让 tabFix 在刷新期间完全无法被 else 分支清除。加上 setData 刷新列表本身触发伪滚动事件,boundingClientRect 值不可信,两者叠加就把吸顶锁死。快设备可能来不及触发伪滚动,慢设备或弱网环境必现。
正解
三层防御,逐一击破。
Fix 1:onShow 强制重置
onShow: function () {
let that = this;
that.setData({ tabFix: false })
that.store.data.headSearch = false
// 后续业务刷新逻辑...
}每次进页面先归零,由滚动事件重新判断是否应该吸顶。
Fix 2:onRefresh 补齐重置
onRefresh() {
this.setData({
typeChange: true,
tabFix: false, // 补上
dataList: [],
isPulling: true,
})
this.store.data.headSearch = false // 补上
}Fix 3:scrollTop 硬守卫(纵深防御)
在滚动处理器最顶部加一个 scrollTop <= 10 的早返回。位置是地面,不允许被 boundingClientRect 的偶发不准值绕过:
pageScroll: function (e) {
let that = this;
const scrollTop = e.detail.scrollTop;
// 硬守卫:没滚就不吸顶,无条件
if (scrollTop <= 10) {
if (that.data.tabFix) {
that.setData({ tabFix: false, isMinH: false });
that.store.data.headSearch = false;
}
return;
}
query.select('#list-sticky-tab').boundingClientRect((rect) => {
if (!rect) return; // 元素还没渲染,跳过
// 原有阈值判断逻辑...
}).exec();
}scrollTop 来自事件本身,始终可信。boundingClientRect 在渲染过渡期(setData 刷新列表、动画进行中)可能偏差。用 scrollTop <= 10 做硬守卫,保证"接近顶部 = 不吸顶"不会被任何异常值击穿。
验收清单
- 冷启动 → 无吸顶 → 向下滚 → 吸顶激活 → 向上滚回顶 → 吸顶消失
- 已滚(吸顶中)→ 切 tab 再切回 → 吸顶消失,重新滚才激活
- 已滚(吸顶中)→ 下拉刷新 → 吸顶消失,内容回顶部
- 数据加载期间快速切 tab → 无幽灵吸顶
一句话外卖
tabBar 页面的任何"跟随滚动位置"的瞬态 UI 状态,都必须在 onShow、onRefresh、每个内容重置路径 里显式归零,并用
scrollTop做硬守卫兜底——boundingClientRect在渲染过渡期是不可信的证人。