返回博客

微信小程序吸顶幽灵:scroll-view 状态残留三层根因

一个 C 端小程序列表页,顶部有一条搜索栏,设计上要求滚过一定阈值后吸顶固定。逻辑很常见:hidden="{{!tabFix}}" 控制吸顶元素,class="{{tabFix ? 'op0' : ''}}" 让原始位置的搜索栏透明。

上线后收到反馈:冷启动进页面,或切 tab 回来,吸顶搜索栏就已经出现了,和正常流内的搜索栏同时显示,叠在一起

本地怎么复现都没问题,在测试机上概率出现,换个设备又消失。典型的"我这里没事"bug。

现象

四种触发路径,任意一条都会留下幽灵:

  1. 冷启动进入列表页 → 吸顶头部已激活,未滚动
  2. 在页面已滚(tabFix: true)时切换 tab,再切回来 → 上次的 tabFix 还在
  3. 下拉刷新后内容回到顶部 → 吸顶头部还浮在那里
  4. 数据加载期间快速切 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 分支重置
}

一旦 tabFixtruetypeChange 也为 true(刷新期间),就形成死锁:即便元素位置已经在阈值之上,吸顶状态也无法被清除。

坑:OR 条件 + 伪滚动事件 = 状态死锁

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

scrollTop 来自事件本身,始终可信。boundingClientRect 在渲染过渡期(setData 刷新列表、动画进行中)可能偏差。用 scrollTop <= 10 做硬守卫,保证"接近顶部 = 不吸顶"不会被任何异常值击穿。

验收清单

  1. 冷启动 → 无吸顶 → 向下滚 → 吸顶激活 → 向上滚回顶 → 吸顶消失
  2. 已滚(吸顶中)→ 切 tab 再切回 → 吸顶消失,重新滚才激活
  3. 已滚(吸顶中)→ 下拉刷新 → 吸顶消失,内容回顶部
  4. 数据加载期间快速切 tab → 无幽灵吸顶

一句话外卖

tabBar 页面的任何"跟随滚动位置"的瞬态 UI 状态,都必须在 onShow、onRefresh、每个内容重置路径 里显式归零,并用 scrollTop 做硬守卫兜底——boundingClientRect 在渲染过渡期是不可信的证人。