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,移动端可以直接扫码打开:

这个页面有两个模式:
- 问题版:左右卡片有
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 规则里的绘制顺序,不等于浏览器最后一定用一张大画布从后往前画完所有东西。
从前端代码到屏幕上的像素,大致会经过这几步:
平时写页面时,我们大多只关心前三步。比如一个元素 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 文档列出了会创建层叠上下文的条件,其中包括 transform、mix-blend-mode、isolation: isolate、will-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 解释成浏览器为了性能创建的独立绘制层。opacity、transform、will-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;需要和背景做混合 | 和 opacity、transform、滚动、裁剪叠加时,容易出现闪烁、白边、混合范围异常 |
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/paint、contain: 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 < 1、mix-blend-mode、transform != none、isolation: isolate,这类属于 CSS 层级规则的一部分。它会影响 z-index 怎么比较,尤其会让子元素被父级上下文限制住。
第二类是「更可能影响合成路径」。比如动画中的 transform、opacity、filter、backdrop-filter、will-change,浏览器可能为了性能把它们放到单独的合成层。web.dev 的 z-index 文章也用 composite layer 解释过这件事:浏览器为了避免每一帧重新绘制整张页面,会把某些变化频繁的元素放到独立层里,再通过 GPU 做合成。
这两个判断经常同时发生,但不是同一件事。transform 会确定创建 stacking context;至于它是不是一定单独成为 compositing layer,则取决于浏览器实现和当时的页面状态。也正因为后者不是完全由 CSS 规范决定,才会出现「Chrome 稳,iOS 闪」这种结果。
还有一类容易被漏掉的是「离屏绘制」。filter、backdrop-filter、mix-blend-mode、mask、复杂 clip-path、圆角裁剪叠半透明背景,经常需要浏览器先把内容画到一块临时 surface 上,再拿这块结果去混合、裁剪或合成。它不一定表现成普通意义上的层级错误,但可能表现成:
- 边缘有白线、黑边、锯齿。
- 滚动或动画时出现残影。
- 半透明区域突然变深或变浅。
- 某一帧混合到了错误背景。
- 只有 iOS WebView / Safari 出问题,桌面 Chrome 看不出来。
另一个常见例子是装饰按钮或复杂角标:纹理、遮罩、圆角、半透明和 WebView 合成路径叠在一起后,某些平台会露出本不该出现的边。最后不一定要用 translateZ(0) 修,可能是把纹理预合成、移除某层 mask、或者让不同层走更一致的渲染路径。关键不是记一个固定答案,而是先判断它落在「层叠上下文 / 离屏绘制 / 合成层」里的哪一段。
为什么 iOS 更容易出现
这个场景里有四个信号叠在一起:
- 外层是 Swiper,切换时会对 wrapper 做
transform动画。 - 左右两张卡片本身有
transform: rotate(...)。 - 中间卡片没有
transform,只靠z-index: 3压住两侧。 - 卡片内部用了
mix-blend-mode和isolation: 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 滑动,子卡片又可能有 rotate、scale、z-index |
同组卡片是不是有的带 transform,有的不带;中间高层级元素是否和两侧走同类路径 |
| 奖励弹窗、升级弹窗、礼物弹窗 | 常见大背景光、半透明纹理、圆角卡片、礼物图层、按钮和关闭按钮 | 装饰层是否能预合成;主内容和装饰层的层级是不是简单;遮罩和弹窗是否各自创建了上下文 |
| 浮动入口、角标、复杂 pill 按钮 | 经常用伪元素、纹理、mask、渐变、半透明边框做质感 | 伪元素是否盖到正文;纹理层是否影响圆角边缘;Android WebView / iOS Safari 是否有多余边线 |
| 卡片堆叠、排行榜前三名、头像框叠头像 | 元素互相覆盖,且图层关系常靠 position + z-index 维持 |
父级有没有 opacity、transform、isolation;头像框和头像是否被不同上下文包住 |
| 抽屉、底部弹层、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 是否支持当前写法 |
| 虚拟列表、懒渲染、容器查询卡片 | contain、content-visibility、container-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 层面被父上下文打包了,或者在合成阶段走了不一样的路径。
实际开发里可以在需求评审或自测前扫一遍这几个问题:
- 这个 UI 有没有元素互相压住?
- 这个 UI 有没有切换、滑动、进出场、自动播放这类动画?
- 被压住和压住别人的元素,是不是同级兄弟?如果不是,它们各自的父级有没有创建 stacking context?
- 同一组重叠元素里,是不是有的写了
transform、filter、opacity,有的没有? - 父级有没有
opacity < 1、isolation: isolate、contain、container-type、mix-blend-mode? - 视觉层是不是靠很多伪元素、渐变、mask、blend、filter 拼出来的?
- 这个效果能不能提前合成成一张图片,而不是运行时叠很多层?
- 如果这是 WebView 弹窗,App 原生层、WebView 背景、H5 遮罩之间有没有额外叠加?
- 桌面 Chrome 正常之后,有没有再看 iOS Safari / iOS WebView?
- 如果加了兼容性修复,代码注释有没有解释为什么不能按普通层级理解?
这份清单不是要求每个页面都做复杂排查,而是给「高风险 UI」一个提前识别方法。普通列表、普通按钮、普通图文卡片没有必要上来就怀疑合成层;但只要出现「移动端 + 动画 + 重叠 + 复杂装饰」这四个关键词,就值得多看一眼。
为什么 translateZ(0) 能修
translateZ(0) 是一个视觉上不移动元素的 3D transform。它不会把卡片往前移动 0 像素,也不会改变布局尺寸。
但它会让 transform 从 none 变成一个真实的 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-index 从 3 改到 9,未必是最稳的修法。当前问题不是 CSS 规则里「3 不够大」,而是动画过程里不同元素进入了不一致的合成路径。
如果把 z-index 改大也碰巧好了,那只能说明当前实现里更大的层级影响了某个临界条件;它没有解释为什么 3 > 2 仍然闪,也没有解决同组元素合成路径不一致的问题。对这次现象来说,translateZ(0) 更贴近原因。
怎么继续排查
后续遇到类似问题,不建议第一步就加 translateZ(0)。它有时能救场,但如果没先判断清楚问题在哪,很容易把一次兼容修复写成新的性能隐患。
更稳的排查顺序是这样:
- 先确认问题是否只出现在 Safari / iOS WebView。Chrome 正常不能证明 WebKit 没问题。
- 先看 CSS 层级是否真的写错。确认有重叠关系的元素是不是同一个 stacking context 里的兄弟;如果不是,先回到父级层级比较。
- 如果
z-index看起来正确,再查祖先节点。重点看祖先上有没有opacity、transform、filter、contain、isolation、mix-blend-mode、container-type。 - 看重叠元素之间是否有「有的元素带 transform,有的元素不带 transform」。同组动画元素的渲染路径不一致,是这次问题最关键的信号。
- 看父级是否正在做 transform 动画,例如 Swiper、carousel、抽屉、弹窗进出场。外层动画会让内部元素在每一帧重新参与合成。
- 看子元素内部是否有
mix-blend-mode、filter、backdrop-filter、mask、clip-path、isolation、overflow: hidden、border-radius这类高风险组合。 - 一次只改一个点:先让同组元素的 transform 形态更一致,再考虑拆复杂视觉效果,最后才去提高
z-index。 - 如果能连接 Safari Web Inspector,就打开 Layers 相关视图。WebKit 自己也把 Layers 作为排查渲染和性能问题的入口,因为 DOM 结构和最终合成层并不总是一一对应。
具体到修法,可以按「越靠前越优先」来试:
| 修法 | 适合场景 | 注意点 |
|---|---|---|
| 让同组重叠元素都进入类似 transform 路径 | 一组卡片、头像、徽章、弹窗元素在动画中互相覆盖 | 比如只给中心元素补 translateZ(0),或者让三张卡都显式写 transform |
| 把复杂装饰预合成成图片 | 纯装饰层里有 mask、blend、filter、半透明纹理 | 前端少画几层,移动端 WebView 通常更稳 |
| 降低混合模式复杂度 | mix-blend-mode、backdrop-filter 和动画叠在一起 |
能不用实时混合就不用,尤其是大面积元素 |
| 缩小隔离范围 | 父级 opacity、isolation、contain 太靠外 |
不要让一个大容器无意中包住很多本该互相比较层级的元素 |
临时加 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: .99、transform: translateZ(0)、filter: blur(0)这类“看起来没变化”的属性。 - 写
z-index时,先确认比较对象是不是同一个 stacking context 里的兄弟。 - 父级如果有
opacity、transform、isolation、contain,默认认为它会把内部层级封起来。 mix-blend-mode、mask、clip-path、backdrop-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-mode 和 isolation,让 WebKit 更容易在过渡帧里先合成两侧卡片。
transform: translateZ(0) 的作用,是让中间卡片进入同类合成路径。它没有改变视觉位置,也不是靠更大的 z-index 硬压,而是把动画过程里的渲染路径弄得更一致。
下次遇到「Chrome 正常、iOS 闪层级」的问题,可以先按这个模型判断:CSS 层级规则是否真的错了;如果 CSS 没错,就看 transform、混合模式、裁剪和合成层。
