返回博客

scroll-view 列表抖动的罪魁祸首:子节点 transform 与合成层打架

给列表页加了一波入场动画和触摸反馈,自测一切正常,上真机一快速滑动:列表项忽隐忽现,点击有漂移,偶尔整行瞬间消失再跳回来。开发者工具里什么报错也没有。回退动画类,症状消失——问题锁定在 CSS,但为什么同样的动画在页面顶部的标题上完全正常?

现象

  • <scroll-view type="list"> 快速惯性滑动时,列表项短暂消失或闪烁
  • :active { transform: scale(0.95) } 触摸反馈触发时,行内容抖动且点击热区偏移
  • 入场动画(translateY(20rpx) → 0)运行到一半被打断,列表项凭空消失后再重现
  • 把同一套动画类挂到 scroll-view 外部的元素上,完全没有问题

根因

scroll-view type="list" 在微信渲染管线里会为内容层单独创建一个合成器层(compositor layer),通过 transform: translate3d(...) 驱动滚动位置和橡皮筋回弹。这个设计依赖一个前提:子节点的布局盒子有稳定的、未经变换的原点,这样渲染器才能:

  1. 精确计算哪些行落在视口内(虚拟化裁剪)
  2. 将每行可预期地提升为图层以实现增量重绘
  3. 把触摸坐标正确映射回行索引

一旦列表子节点自己也声明了 transform,就会出现三种冲突:

  • 新建竞争合成层:子节点的 transform 触发独立 stacking context,与 scroll-view 的内容层形成层竞争,引发重绘抖动
  • 双 transform 叠加:快速滑动时 scroll-view 的 translate 和子节点的 :active scale 同时执行,视觉上表现为元素被"双重变换"而抖动
  • 虚拟化误判:translateY 入场动画把子节点移出了它的布局原点,虚拟列表数学认为该行不在视口内,直接卸载 DOM
坑:这不是普通浏览器 CSS 问题

同一套 transform 动画在普通页面滚动(overflow-y: auto)下完全正常,但放入 <scroll-view> 就出问题。原因在于微信的 Skyline/WebView 混合渲染管线对 scroll-view 做了特殊的合成层处理,而标准 Chrome DevTools 模拟器无法还原这一行为——必须上真机验。

正解

方案 A:彻底剥离 scroll-view 子节点上的 transform

最直接也最推荐的方式。把所有基于 transform 的动画类从列表项上移除:

<!-- 修复后:列表项不带任何 transform 类 -->
<scroll-view type="list" class="list-scroll">
  <navigator class="li flex-box flex-top" wx:for="{{items}}" wx:key="index">
    ...
  </navigator>
</scroll-view>
.li {
  /* 不写 transform,不写 transition: transform */
}
/* 整个 .li:active { transform: scale(...) } 规则删除 */

方案 B:将入场动画提升到 scroll-view 的祖先节点

入场动画本质上只需要执行一次。把它挂到容器的父级,让 scroll-view 子节点的布局保持静止:

<!-- 外层容器做一次入场,scroll-view 内部不动 -->
<view class="page-container entrance-fade">
  <scroll-view type="list">
    <view class="title">...</view>  <!-- 无 transform -->
  </scroll-view>
</view>

注意:如果动画加在 scroll-view 的直接父级,scale 动画同样会传导进去——必须保证 scroll-view 自身及其子树不在任何正在 transform 的祖先的合成层内。

方案 C:用非 transform 属性替换触摸反馈

:active 反馈改用 opacity 或背景色,不触碰 transform 通道:

.li:active {
  background-color: rgba(0, 0, 0, 0.04); /* 颜色加深 */
  /* 或者 */
  opacity: 0.85;                          /* 透明度压暗 */
}

方案 D:入场动画改为纯 opacity 过渡

/* 修复前(在 scroll-view 内部会崩) */
.entrance-item {
  opacity: 0;
  transform: translateY(20rpx);
  transition: opacity 0.4s, transform 0.4s;
}
.entrance-item.visible { opacity: 1; transform: translateY(0); }
 
/* 修复后(scroll-view 内部安全) */
.entrance-item-safe {
  opacity: 0;
  transition: opacity 0.4s ease;
}
.entrance-item-safe.visible { opacity: 1; }
正解:scroll-view 子节点只能用 opacity / 背景色做动画

opacitybackground 的变化不会干扰 scroll-view 的合成器层假设,是在 <scroll-view> 内部做交互反馈和入场动画的唯一安全手段。transform(包括 scale、translate、translateZ(0)、will-change: transform)一律不要出现在列表项上。

常见反向操作(反而更糟)

  • will-change: transform 想"修" jank → 强制再创建一层,冲突更严重
  • 把动画子元素再包一个 <view> → 内部 transform 仍在 scroll-view 内容层内,无效
  • transform: translateZ(0) 做硬件加速 → 同样的合成层冲突根因
  • page-content-transition(含 scale 动画)挂在 scroll-view 的父容器上 → scale 传导进 scroll-view 的变换层,破坏虚拟化数学

一句话外卖

<scroll-view type="list"> 用 transform 驱动滚动,子节点的任何 transform 都会与它的合成层打架——列表项只能用 opacity 和颜色做动画,transform 必须让出来。