iOS WebKit 中 transform 和 z-index 的一次层级闪烁排查

一个移动 H5 弹窗里有三张奖励卡:中间卡片应该压住左右两张,左右两张轻微旋转并露在后面。Android 上切换 Swiper 时一直正常;iOS 上会在切换瞬间从「中间压着两边」变成「两边压着中间」,很快又恢复正常。

最后修复只改了一行:

.reward--center {
  z-index: 3;
  transform: translateZ(0);
}

这行代码没有改变中间卡片的位置,也没有提高 z-index。它真正改变的是 WebKit 在动画过程里对这个元素的合成处理方式。

现象

页面结构可以简化成这样:

<div class="swiper">
  <div class="reward-group">
    <div class="reward reward--left">...</div>
    <div class="reward reward--center">...</div>
    <div class="reward reward--right">...</div>
  </div>
</div>

三张卡片都是绝对定位,层级也写得很明确:

.reward {
  position: absolute;
  width: 109px;
  height: 133px;
}

.reward--left {
  z-index: 2;
  transform: rotate(-4deg);
}

.reward--center {
  z-index: 3;
  transform: none;
}

.reward--right {
  z-index: 2;
  transform: rotate(7deg);
}

按 CSS 层级规则,中间卡片的 z-index: 3 高于两侧的 z-index: 2,它应该盖在两侧上面。静止状态也是这样。问题只出现在 Swiper 来回切换的动画瞬间。

真实代码里,卡片内部还有几类容易增加渲染复杂度的样式:

.reward__card {
  overflow: hidden;
  border-radius: 12px;
  isolation: isolate;
}

.reward__card::before {
  mix-blend-mode: overlay;
}

.reward__card::after {
  mix-blend-mode: color;
}

这几类样式单独看都合理:圆角裁剪、混合模式描边、隔离混合范围。但它们叠在 Swiper 的 transform 动画里,就把问题推到了 WebKit 的图层合成阶段。

最小复现

这里放了一个自包含 playground,移动端可以直接扫码打开:

打开 playground

playground 二维码

这个页面有两个模式:

  • 问题版:左右卡片有 rotate(...),中间卡片没有 transform
  • 修复版:中间卡片额外加 transform: translateZ(0)

正文里只贴最关键的代码。完整页面还包含按钮切换、提示文案和视觉装饰,但触发问题的核心结构可以简化成这样:

<section class="demo-shell">
  <div class="swiper-mock">
    <div class="slide">
      <div class="reward-group">
        <div class="reward reward--left">
          <div class="reward__card">...</div>
        </div>
        <div class="reward reward--center">
          <div class="reward__card">...</div>
        </div>
        <div class="reward reward--right">
          <div class="reward__card">...</div>
        </div>
      </div>
    </div>
  </div>
</section>

CSS 里真正关键的是这几处:

.demo-shell {
  overflow: hidden;
}

.swiper-mock {
  display: flex;
  width: 200%;
  animation: slide 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate;
  will-change: transform;
}

.reward-group {
  position: relative;
  width: 375px;
  height: 260px;
  overflow: hidden;
}

.reward {
  position: absolute;
  top: 48px;
  width: 109px;
  height: 133px;
}

.reward--left {
  left: 35px;
  z-index: 2;
  transform: rotate(-4deg);
}

.reward--center {
  top: 58px;
  left: 138px;
  z-index: 3;
}

.reward--right {
  left: 219px;
  z-index: 2;
  transform: rotate(7deg);
}

.demo-shell--fixed .reward--center {
  transform: translateZ(0);
}

@keyframes slide {
  from {
    transform: translate3d(0, 0, 0);
  }

  to {
    transform: translate3d(-50%, 0, 0);
  }
}

这里特意保留了 overflow: hidden、外层 translate3d 动画和左右卡片的 rotate(...),因为这几项共同决定了元素是否更容易进入合成路径。demo-shell--fixed 只做一件事:给中间卡片补上 translateZ(0)。也就是说,问题版和修复版的核心差异就是这一行。

如果桌面 Chrome 看不出差异,属于正常现象。这个问题主要发生在 iOS Safari / App WebView 这类 WebKit 环境里。

先把渲染链路拆开

这类问题最容易被一句「z-index 失效了」带偏。z-index 确实在描述层级,但它只描述 CSS 规则里的绘制顺序,不等于浏览器最后一定用一张大画布从后往前画完所有东西。

从前端代码到屏幕上的像素,大致会经过这几步:

浏览器从 DOM 到屏幕的渲染链路

平时写页面时,我们大多只关心前三步。比如一个元素 position: absolute,另一个元素 z-index: 3,这已经足够解释大多数重叠关系。但动效、滚动、透明度、transform、filter、混合模式和裁剪出现后,浏览器为了性能可能会把某些内容放进单独的合成层。后面的合成阶段如果临时改变了哪些元素被拆成独立 layer、哪些元素留在父层里,就可能出现「CSS 规则看着没问题,但某一帧视觉结果不稳定」。

WebKit 的 Layers 文章解释过,DOM 元素不是一个个直接画到屏幕上。浏览器会先把元素绘制到一组 surfaces / layers 上,再按顺序合成;CSS 属性、元素类型,以及元素之间的交互,都可能让某个 DOM 元素生成新 layer。这个说法很重要:DOM 树、层叠上下文树和合成层树不是同一棵树。

这次问题就在这个交界处:CSS 层级关系是对的,但 iOS WebKit 在 Swiper 动画中处理合成层时,短暂给出了不符合最终层级预期的视觉结果。

stacking context 负责什么

先看 CSS 规则这边。

MDN 的 stacking context 文档列出了会创建层叠上下文的条件,其中包括 transformmix-blend-modeisolation: isolatewill-change,也包括带非 auto z-index 的定位元素。它还强调,层叠上下文内部的子元素按自己的 z-index 叠放,整个上下文在父级里会作为一个整体参与排序。

换成人话就是:

  • 一个 stacking context 像一个独立的小世界。
  • 小世界内部可以继续用 z-index 排顺序。
  • 小世界作为整体进入父级排序时,内部子元素不能跳出来压过父级外面的兄弟元素。

这也是很多 z-index 问题的真实原因。比如子元素写了 z-index: 9999,但父元素自己所在的 stacking context 比另一个兄弟元素低,那么这个子元素仍然压不过外面的兄弟。

这次不是这种情况。三张奖励卡是同组里的兄弟元素,并且每张卡本身都有定位和明确 z-index

.reward--left {
  z-index: 2;
  transform: rotate(-4deg);
}

.reward--center {
  z-index: 3;
  transform: none;
}

.reward--right {
  z-index: 2;
  transform: rotate(7deg);
}

按 CSS 层叠规则,中间卡片应该压住左右两张。静止状态也确实这样。所以这里不能简单归成「某个父级 stacking context 把中间卡片限制住了」。如果是纯 CSS 层叠上下文问题,静止状态就应该错,而不是只在切换动画的一瞬间错。

transform 额外带来了什么

transform 在这件事里有两层影响。

CSS Transforms 规范也写得很直接:对 CSS box model 管辖的元素来说,transform 只要不是 none,就会创建新的 stacking context,并且这个 transform 创建的层在父级上下文里按类似 z-index: 0 的顺序绘制。

规范还说明,transform 会建立新的局部坐标系,变换发生在元素已经完成布局之后。也就是说,rotate(-4deg) 不会改变元素在普通文档流里的布局尺寸;它改变的是这个盒子最后怎么被映射到屏幕上。

把这两点合起来看,左右卡片和中间卡片虽然都是 .reward,但它们进入渲染管线时已经不完全一样:

元素 CSS 层级 transform 状态 额外效果
左卡片 z-index: 2 rotate(-4deg) 新 stacking context,且更可能进入独立合成路径
中间卡片 z-index: 3 none 只靠 position + z-index 排序
右卡片 z-index: 2 rotate(7deg) 新 stacking context,且更可能进入独立合成路径

注意这里的重点不是「左右卡片有 transform,所以 CSS 层级更高」。规范上不是这样。左右卡片的 z-index 仍然是 2,中间卡片仍然是 3。真正的差异在后面的合成阶段:有 transform 的元素更容易被浏览器当成可独立移动、旋转、合成的对象。

compositing layer 负责什么

再看合成层。

web.dev 的 z-index 课程把 composite layer 解释成浏览器为了性能创建的独立绘制层。opacitytransformwill-change 这类属性很可能让浏览器把元素放进新的合成层,再通过 GPU 做移动或变化。

为什么浏览器要这么做?因为动画每一帧都重新布局、重新绘制整页很贵。把一个正在变化的元素提前画到独立 layer 上,后面只移动或合成这张 layer,成本会低很多。Swiper、轮播、抽屉、弹窗进出场这类 UI 经常依赖这种机制。

但这里也有代价:合成层是浏览器实现层面的优化,不是 CSS 里能完全用 z-index 直接声明的东西。CSS 规范要求最终绘制顺序要符合层叠规则,但浏览器内部为了性能会做 layer promotion、layer squashing、repaint、recomposite 等处理。绝大多数时候这些处理对开发者透明;问题只会在某些复杂组合里露出来。

这次有几个组合一起出现:

Swiper wrapper 正在 translate3d 动画
  + 左右卡片自身 rotate
  + 中间卡片无 transform
  + 卡片内部 mix-blend-mode
  + isolation: isolate
  + 圆角裁剪和半透明视觉层

外层 Swiper 动画会让整个滑动区域在每一帧重新合成。左右卡片因为自己带 rotate(...),更容易先被提升成独立合成层。中间卡片虽然 CSS 层级更高,但它没有 transform,可能仍然在父级绘制结果里,或者被 WebKit 用另一套条件处理。

如果某一帧里 WebKit 先合成了左右卡片对应的 layer,再把父级里已经绘制好的中间卡片作为另一块内容处理,视觉上就可能出现短暂的层级反转。下一帧或动画稳定后,WebKit 又重新回到正确的最终顺序,所以实际现象是「迅速从错变对」,而不是持续错。

这也是为什么这个问题很难用桌面 Chrome 复现。Blink 和 WebKit 的 layer promotion 策略、合成时机、混合模式处理和 GPU 路径都可能不同。Chrome 正常,只能说明 Blink 环境没暴露这个问题,不能证明 iOS WebKit 一定稳定。

这类问题不只和 transform 有关

到这里可以先把一个边界说清楚:stacking context 是 CSS 层面的概念,哪些属性会创建它,规范和文档里有相对明确的规则;compositing layer 是浏览器实现层面的优化,什么时候创建、什么时候合并、什么时候重新排序,更多取决于浏览器引擎、设备能力、当前动画和页面其他元素。

所以不要把这件事理解成「某个 CSS 属性一定会让 Safari 出错」。更准确的说法是:有些 CSS 会改变元素的层叠上下文,有些 CSS 会改变元素的坐标系或绘制方式,有些 CSS 会提高元素进入独立合成层的概率。它们叠在动画里,就更容易出现跨浏览器差异。

MDN 的 stacking context 文档列了完整清单。和移动 H5 动效最相关的,可以先按下面这张表记:

CSS / 场景 主要影响 开发时要盯什么
position: relative/absolute + 非 auto z-index 创建 stacking context 子元素的 z-index 只能在这个上下文内部比较,不能跳出去压外面的兄弟
position: fixed/sticky 创建 stacking context 弹窗、吸顶、底部按钮可能和普通内容不在同一层级规则里
flex item / grid item + 非 auto z-index 创建 stacking context 不写 position 也能让 z-index 生效,排查时不要漏掉
opacity < 1 创建 stacking context;动画时常进入合成路径 半透明父元素会把子元素整体打包,子元素再大的 z-index 也只在里面比较
mix-blend-mode != normal 创建 stacking context;需要和背景做混合 opacitytransform、滚动、裁剪叠加时,容易出现闪烁、白边、混合范围异常
transform / scale / rotate / translate 创建 stacking context;建立新的局部坐标系;动画时常进入合成路径 同组重叠元素里,不要一个走 transform 路径、另一个完全不走
perspective 创建 stacking context;影响 3D 投影 3D 卡片、翻转动画要额外关注层级和背面显示
filter / backdrop-filter 创建 stacking context;通常需要离屏绘制或额外合成 模糊、阴影、玻璃态背景在移动端成本高,叠动画时尤其要测 iOS
clip-path 创建 stacking context;改变可见区域 和 transform 动画叠加时,要注意边缘锯齿、裁剪错位
mask / mask-image / mask-border 创建 stacking context;改变透明度遮罩 Safari / WebView 上要重点看透明边缘、圆角、纹理残影
isolation: isolate 显式创建 stacking context 常用于限制 mix-blend-mode 的混合范围,但也会把内部层级封起来
will-change 提前提示浏览器做优化,可能提前创建 stacking context 或合成层 只在真实性能或兼容问题里局部使用,不要全站常驻
contain: layout/paintcontain: strict/content 创建 stacking context;限制布局或绘制影响范围 虚拟列表、卡片隔离里很好用,但会改变子元素和外部的关系
container-type: size/inline-size 创建 stacking context 容器查询不是纯响应式语法,它也会影响层级边界
@keyframes 动画了会创建 stacking context 的属性,并且 animation-fill-mode: forwards 动画结束后仍可能保留对应层叠状态 不要只看动画过程,也要看动画结束后的常驻状态
top layer,例如 dialog、popover、fullscreen 和 ::backdrop 进入浏览器特殊顶层 普通 z-index 压不过 top layer,弹窗和遮罩要按 top layer 规则理解

这张表里有两类信息不要混在一起。

第一类是「确定创建 stacking context」。比如 opacity < 1mix-blend-modetransform != noneisolation: isolate,这类属于 CSS 层级规则的一部分。它会影响 z-index 怎么比较,尤其会让子元素被父级上下文限制住。

第二类是「更可能影响合成路径」。比如动画中的 transformopacityfilterbackdrop-filterwill-change,浏览器可能为了性能把它们放到单独的合成层。web.dev 的 z-index 文章也用 composite layer 解释过这件事:浏览器为了避免每一帧重新绘制整张页面,会把某些变化频繁的元素放到独立层里,再通过 GPU 做合成。

这两个判断经常同时发生,但不是同一件事。transform 会确定创建 stacking context;至于它是不是一定单独成为 compositing layer,则取决于浏览器实现和当时的页面状态。也正因为后者不是完全由 CSS 规范决定,才会出现「Chrome 稳,iOS 闪」这种结果。

还有一类容易被漏掉的是「离屏绘制」。filterbackdrop-filtermix-blend-modemask、复杂 clip-path、圆角裁剪叠半透明背景,经常需要浏览器先把内容画到一块临时 surface 上,再拿这块结果去混合、裁剪或合成。它不一定表现成普通意义上的层级错误,但可能表现成:

  • 边缘有白线、黑边、锯齿。
  • 滚动或动画时出现残影。
  • 半透明区域突然变深或变浅。
  • 某一帧混合到了错误背景。
  • 只有 iOS WebView / Safari 出问题,桌面 Chrome 看不出来。

另一个常见例子是装饰按钮或复杂角标:纹理、遮罩、圆角、半透明和 WebView 合成路径叠在一起后,某些平台会露出本不该出现的边。最后不一定要用 translateZ(0) 修,可能是把纹理预合成、移除某层 mask、或者让不同层走更一致的渲染路径。关键不是记一个固定答案,而是先判断它落在「层叠上下文 / 离屏绘制 / 合成层」里的哪一段。

为什么 iOS 更容易出现

这个场景里有四个信号叠在一起:

  1. 外层是 Swiper,切换时会对 wrapper 做 transform 动画。
  2. 左右两张卡片本身有 transform: rotate(...)
  3. 中间卡片没有 transform,只靠 z-index: 3 压住两侧。
  4. 卡片内部用了 mix-blend-modeisolation: isolate

在 Blink / Chrome 里,这个组合通常仍然按最终层级稳定合成,所以 Android 上看起来正常。iOS WebKit 在移动动画中更容易把左右两张有 transform 的卡片先放进独立合成层。中间卡片虽然 CSS z-index 更高,但它没有走同一类 transform 合成路径,于是切换瞬间可能出现一帧「左右合成层压到中间上面」的视觉结果。

等这一帧过去,WebKit 又回到稳定状态,z-index: 3 的中间卡片重新压住两边。所以它看起来不是一直错,而是「迅速从错变对」。

实机验证也支持这个判断:只给中间卡片补 transform: translateZ(0) 后,iOS 上的层级闪烁消失了。

这里的「更容易」是工程判断,不是官方确认的 WebKit bug 结论。严谨地说,当前证据只能证明:在这个真实页面里,iOS WebKit 的过渡帧合成顺序不稳定;让中间卡片也进入 transform 路径后,问题消失。除非把最小复现提交给 WebKit 并得到维护者确认,否则文章里不应该直接写「Safari z-index bug」。

哪些场景要提前警惕

这类问题最麻烦的地方,就是它很少出现在「普通盒子盖普通盒子」这种直观场景里。它通常藏在一个设计稿看起来很正常、代码也没有明显错的边界组合里:有动画、有重叠、有半透明装饰、有裁剪,还有某几个元素刚好走了不一样的渲染路径。

可以先记一个判断句:

只要是「移动端 WebView / Safari」里的「动画重叠元素」,
并且元素内部有复杂视觉效果,
就不要只看 z-index,要顺手检查 stacking context 和 compositing layer。

更具体一点,下面这些 UI 形态都值得提前 check。

场景 为什么容易出问题 提前检查什么
Swiper / carousel 里三张卡片互相压住 父级常用 translate3d 滑动,子卡片又可能有 rotatescalez-index 同组卡片是不是有的带 transform,有的不带;中间高层级元素是否和两侧走同类路径
奖励弹窗、升级弹窗、礼物弹窗 常见大背景光、半透明纹理、圆角卡片、礼物图层、按钮和关闭按钮 装饰层是否能预合成;主内容和装饰层的层级是不是简单;遮罩和弹窗是否各自创建了上下文
浮动入口、角标、复杂 pill 按钮 经常用伪元素、纹理、mask、渐变、半透明边框做质感 伪元素是否盖到正文;纹理层是否影响圆角边缘;Android WebView / iOS Safari 是否有多余边线
卡片堆叠、排行榜前三名、头像框叠头像 元素互相覆盖,且图层关系常靠 position + z-index 维持 父级有没有 opacitytransformisolation;头像框和头像是否被不同上下文包住
抽屉、底部弹层、toast、popover 进出场 容器自己在做 translate / opacity 动画,内部还有 fixed / absolute 子元素 关闭按钮、遮罩、内容区是否在同一个层级体系;动画结束后 animation-fill-mode 是否保留上下文
sticky / fixed 顶栏和滚动内容重叠 position: sticky/fixed 本身会创建 stacking context,还会和滚动合成层互动 顶栏是否被滚动内容、半透明背景、filter 背景影响;滚动时是否有闪线或压层
玻璃态、毛玻璃、背景模糊 backdrop-filter / filter 需要额外绘制和合成,移动端成本高 模糊层面积是否过大;是否叠了 transform 动画;iOS 上边缘是否发黑、发白或闪烁
mix-blend-mode 做高光、描边、反色文字 混合模式需要拿背景参与计算,和隔离、透明、动画叠加后更复杂 是否真的需要运行时混合;能否预合成;是否加了 isolation 但又无意中改变了层级
mask / clip-path 做异形裁剪 裁剪和遮罩会改变可见区域,也会影响 stacking context 透明边缘是否有锯齿;裁剪层和被裁剪内容是否一起动画;Safari 是否支持当前写法
虚拟列表、懒渲染、容器查询卡片 containcontent-visibilitycontainer-type 可能改变上下文边界 内部浮层、角标、sticky 子元素是否需要跨出容器;如果需要跨出,容器隔离会不会挡住
canvas / video / iframe 附近的覆盖层 这些元素在浏览器实现里常常有特殊绘制或合成路径 自定义按钮、角标、遮罩是否能稳定盖住它们;WebView 上是否需要调整 DOM 层级或避免覆盖
状态切换时只改某个元素的 class 问题版和修复版可能只差一个 class,但这个 class 改变了 transform / opacity / filter 不要只看最终静态样式,也要看切换瞬间每个元素进入了哪些状态

这些场景有个共同点:它们都不是单纯的「谁的 z-index 大」。设计稿里看到的是一组视觉层;代码里实际可能是好几棵树叠在一起:

DOM 树:
  谁是谁的父子节点

stacking context 树:
  谁被哪个上下文封起来

compositing layer 树:
  哪些元素在浏览器里被单独拿去合成

排查时只盯 DOM 树,容易得出「它们明明是兄弟」;只盯 z-index,容易得出「3 明明比 2 大」。但移动端渲染问题经常出在后两棵树:元素在 CSS 层面被父上下文打包了,或者在合成阶段走了不一样的路径。

实际开发里可以在需求评审或自测前扫一遍这几个问题:

  1. 这个 UI 有没有元素互相压住?
  2. 这个 UI 有没有切换、滑动、进出场、自动播放这类动画?
  3. 被压住和压住别人的元素,是不是同级兄弟?如果不是,它们各自的父级有没有创建 stacking context?
  4. 同一组重叠元素里,是不是有的写了 transformfilteropacity,有的没有?
  5. 父级有没有 opacity < 1isolation: isolatecontaincontainer-typemix-blend-mode
  6. 视觉层是不是靠很多伪元素、渐变、mask、blend、filter 拼出来的?
  7. 这个效果能不能提前合成成一张图片,而不是运行时叠很多层?
  8. 如果这是 WebView 弹窗,App 原生层、WebView 背景、H5 遮罩之间有没有额外叠加?
  9. 桌面 Chrome 正常之后,有没有再看 iOS Safari / iOS WebView?
  10. 如果加了兼容性修复,代码注释有没有解释为什么不能按普通层级理解?

这份清单不是要求每个页面都做复杂排查,而是给「高风险 UI」一个提前识别方法。普通列表、普通按钮、普通图文卡片没有必要上来就怀疑合成层;但只要出现「移动端 + 动画 + 重叠 + 复杂装饰」这四个关键词,就值得多看一眼。

为什么 translateZ(0) 能修

translateZ(0) 是一个视觉上不移动元素的 3D transform。它不会把卡片往前移动 0 像素,也不会改变布局尺寸。

但它会让 transformnone 变成一个真实的 transform 值。对这个案例来说,修复点不是「让中间卡片 z 轴更靠前」,而是「让中间卡片和左右两张卡片进入更一致的 transform / 合成路径」。

修复前后合成路径差异

修复前,同一组兄弟元素里有两类渲染路径:

左卡片 / 右卡片:
  position + z-index + rotate
  -> transform stacking context
  -> 更容易成为独立 compositing layer

中间卡片:
  position + z-index
  -> 没有 transform
  -> 合成阶段可能不跟左右卡片走同一路径

修复后,中间卡片也有 transform:

中间卡片:
  position + z-index + translateZ(0)
  -> transform stacking context
  -> 更接近左右卡片的合成路径

translateZ(0) 不改变视觉位置,但会改变浏览器对这个元素的处理条件。它相当于告诉浏览器:这个元素也是一个 transform 元素,动画期间请把它按同类对象处理。这样左右卡片和中间卡片在 WebKit 合成阶段更容易保持一致的相对顺序。

这里要避免一个误解:translateZ(0) 不是把中间卡片真的往 z 轴前面推了一层。0 就是 0,视觉位置没有变化。它也不是靠更大的 z-index 硬压住左右两边。真正变化的是合成路径,而不是 CSS 层级数字。

修复前:

.reward--left {
  z-index: 2;
  transform: rotate(-4deg);
}

.reward--center {
  z-index: 3;
  transform: none;
}

.reward--right {
  z-index: 2;
  transform: rotate(7deg);
}

修复后:

.reward--left {
  z-index: 2;
  transform: rotate(-4deg);
}

.reward--center {
  z-index: 3;
  transform: translateZ(0);
}

.reward--right {
  z-index: 2;
  transform: rotate(7deg);
}

这也是为什么单纯把中间卡片的 z-index3 改到 9,未必是最稳的修法。当前问题不是 CSS 规则里「3 不够大」,而是动画过程里不同元素进入了不一致的合成路径。

如果把 z-index 改大也碰巧好了,那只能说明当前实现里更大的层级影响了某个临界条件;它没有解释为什么 3 > 2 仍然闪,也没有解决同组元素合成路径不一致的问题。对这次现象来说,translateZ(0) 更贴近原因。

怎么继续排查

后续遇到类似问题,不建议第一步就加 translateZ(0)。它有时能救场,但如果没先判断清楚问题在哪,很容易把一次兼容修复写成新的性能隐患。

更稳的排查顺序是这样:

  1. 先确认问题是否只出现在 Safari / iOS WebView。Chrome 正常不能证明 WebKit 没问题。
  2. 先看 CSS 层级是否真的写错。确认有重叠关系的元素是不是同一个 stacking context 里的兄弟;如果不是,先回到父级层级比较。
  3. 如果 z-index 看起来正确,再查祖先节点。重点看祖先上有没有 opacitytransformfiltercontainisolationmix-blend-modecontainer-type
  4. 看重叠元素之间是否有「有的元素带 transform,有的元素不带 transform」。同组动画元素的渲染路径不一致,是这次问题最关键的信号。
  5. 看父级是否正在做 transform 动画,例如 Swiper、carousel、抽屉、弹窗进出场。外层动画会让内部元素在每一帧重新参与合成。
  6. 看子元素内部是否有 mix-blend-modefilterbackdrop-filtermaskclip-pathisolationoverflow: hiddenborder-radius 这类高风险组合。
  7. 一次只改一个点:先让同组元素的 transform 形态更一致,再考虑拆复杂视觉效果,最后才去提高 z-index
  8. 如果能连接 Safari Web Inspector,就打开 Layers 相关视图。WebKit 自己也把 Layers 作为排查渲染和性能问题的入口,因为 DOM 结构和最终合成层并不总是一一对应。

具体到修法,可以按「越靠前越优先」来试:

修法 适合场景 注意点
让同组重叠元素都进入类似 transform 路径 一组卡片、头像、徽章、弹窗元素在动画中互相覆盖 比如只给中心元素补 translateZ(0),或者让三张卡都显式写 transform
把复杂装饰预合成成图片 纯装饰层里有 mask、blend、filter、半透明纹理 前端少画几层,移动端 WebView 通常更稳
降低混合模式复杂度 mix-blend-modebackdrop-filter 和动画叠在一起 能不用实时混合就不用,尤其是大面积元素
缩小隔离范围 父级 opacityisolationcontain 太靠外 不要让一个大容器无意中包住很多本该互相比较层级的元素
临时加 will-change 动画开始前确实需要提前优化 MDN 的 will-change 文档建议,只在需要时打开,动画后关掉
调整 DOM 结构 stacking context 嵌套已经很乱,CSS 很难救 让真正需要互相覆盖的元素成为同级兄弟,层级关系会简单很多
单纯提高 z-index 只是不小心写低了,且元素确实在同一个上下文里 如果 3 > 2 都会闪,提高到 999 往往只是碰巧改变实现细节,不是根因修复

这里尤其要小心 will-change。它不是「免费开 GPU」。MDN 对 will-change 的说明很明确:这是最后手段,不应该拿来提前优化所有元素;过度使用会增加内存和渲染复杂度。移动端设备内存更紧,WebView 页面如果到处加 will-change: transform,短期可能让某个动画不闪,长期可能带来滚动卡顿、内存升高,甚至新的合成问题。

开发时可以把下面这几条当成 checklist:

  • 做重叠动画时,同一组互相覆盖的元素,尽量保持类似的 transform 形态。
  • 不要在一个大父容器上随手加 opacity: .99transform: translateZ(0)filter: blur(0) 这类“看起来没变化”的属性。
  • z-index 时,先确认比较对象是不是同一个 stacking context 里的兄弟。
  • 父级如果有 opacitytransformisolationcontain,默认认为它会把内部层级封起来。
  • mix-blend-modemaskclip-pathbackdrop-filter 尽量不要和大面积 transform 动画叠在同一层。
  • 纯装饰效果能预合成图片,就不要为了几层光效在运行时叠很多 CSS。
  • iOS WebView、Safari、Android Chrome 都要测;不要只用桌面 Chrome 判断渲染类兼容问题。
  • 兼容性 hack 要写注释。注释要解释为什么加,不要只写「fix iOS」。

不要把 translateZ(0) 当成万能药

translateZ(0) 很适合做局部兼容修复,但不应该全站到处加。

它可能让浏览器创建更多合成层。合成层能减少某些动画的重绘,但也会占用内存。WebKit 的 Layers 文章也提醒过,创建太多 layers 会让内存受限设备付出明显代价。

所以推荐口径是:

  • 只在真实 Safari / iOS WebView 问题里局部加。
  • 注释写清楚为什么要加,避免后续 review 时被当成无意义代码删掉。
  • 如果问题来自 mix-blend-mode、半透明纹理或复杂裁剪,优先评估能不能把视觉效果预合成到图片里。
  • 如果是整组元素都在动画里重叠,尽量让同组元素的 transform 形态一致,而不是一个有 transform、一个没有。

这次最小修复可以写成:

.reward--center {
  z-index: 3;

  /* iOS WebKit 在 Swiper 动画中容易先合成两侧旋转卡片。
     给中间卡片也建立同类 transform 层,避免切换瞬间层级反转。 */
  transform: translateZ(0);
}

这句注释的重点是「为什么不能按普通写法」。没有这句说明,translateZ(0) 很容易被误认为是旧时代的性能玄学。

结论

这次问题的表面现象是 z-index 闪了一下,实际原因更接近 iOS WebKit 在 transform 动画中的合成层顺序不稳定。左右卡片有 rotate(...),中间卡片没有 transform,父级又在做 Swiper 位移动画,再叠加 mix-blend-modeisolation,让 WebKit 更容易在过渡帧里先合成两侧卡片。

transform: translateZ(0) 的作用,是让中间卡片进入同类合成路径。它没有改变视觉位置,也不是靠更大的 z-index 硬压,而是把动画过程里的渲染路径弄得更一致。

下次遇到「Chrome 正常、iOS 闪层级」的问题,可以先按这个模型判断:CSS 层级规则是否真的错了;如果 CSS 没错,就看 transform、混合模式、裁剪和合成层。