z-index vs 层叠上下文 你知道吗?

273 阅读6分钟

Pasted image 20250113203739.png

背景

熟悉 B 端的同学知道,B 端交互比较复杂,其实就是屏幕太大,产品觉得太空、太白,往往会有很多奇思妙想,像我这种 Vue.js 入行的同学又对 CSS 经验不多(据我组长的侧面反馈大概可以知道 CSS 是一个比较吃经验和场景的语言),所以产品的奇思妙想往往如同铁锤砸在我的小脑袋瓜上,今天就来分享一个实际的 B 端案例

PS: 案例可以在 codesandbox 中找到并调试

案例

先来点伪代码,产品提了一个多行列表的需求,要求列表每行有一个带有输入建议的选择器,这个需求比较简单,但是样式和功能要求复用产品已经沉淀后的组件库,也就是从业务组件里去实现,我们的组件库用的 Semi Design,广告位 链接

Pasted image 20250106232049.png

Select 的下拉的触发器使用绝对定位实现(埋个伏笔,这是 bug 的原因),因为产品希望选择器输入框被内容撑高后不会影响到其它行的布局,也就是不会输入时会上下移动

但是当我实现多行时就会发现一个问题,我的下拉被列表另一行给覆盖掉了,如下

1.gif

分析

上面的代码的 HTML 结构如下

<!DOCTYPE html>
<html>

<head>
    <title>zIndexDemo</title>
</head>
<script>
	// ...
    document.addEventListener('DOMContentLoaded', function () {
        const rows = document.querySelectorAll('.row');
        rows.forEach(function (row) {
            const selectTrigger = row.querySelector('div');
            const textarea = selectTrigger.querySelector('textarea');

            selectTrigger.addEventListener('click', function () {
                if (selectTrigger.classList.contains('active')) {
                    return;
                }
                // 展示绝对定位的输入框
                textarea.style.display = 'block';
                // ...
        });
		// ...
    });
</script>
<style>
    .row {
        position: relative;
        z-index: 1;
        margin-bottom: 10px;
    }
	// ...
	
    .trigger {
        position: relative;
        // ...
    }

    .select__textarea {
        position: absolute;
        top: 0;
        left: 0;
        display: none;
        // ...
    }
</style>

<body>
    <div class="row">
        <div class="trigger">
            select trigger
            <textarea class="select__textarea"></textarea>
        </div>
    </div>
    <div class="row">
        <div class="trigger">
            select trigger
            <textarea class="select__textarea"></textarea>
        </div>
    </div>

</html>

<div class="row" /> 的样式我不太熟悉,所以我一开始的解决思路是去尝试提升绝对定位的 <textarea class="select__textarea" />z-index 的大小,但是后面发现无论调整的多大,也会被覆盖,因此该条路线被抛弃

然后我再次尝试了 overflow: hidden -> visible 的路线,看看是不是有什么意外的样式覆盖了我的 <textarea class="select__textarea" />,然后继续失败,但是没关系,我还有最终的方案

  1. <textarea class="select__textarea" />z-index 的大小:❌
  2. <textarea class="select__textarea" />:❌

最终的方案就是求助在我工位后面、具有多年工作经验的同事😂,在我阐述了上述两条方案后,他再次确定了一个方向,就是 z-index + 层叠上下文的问题,<textarea class="select__textarea" /> 被覆盖的原因是它的父节点和其兄弟节点具有相同的 z-index,作为子节点的 <textarea class="select__textarea" />z-index 不能超过父节点的 z-index

这个原因让我大吃一惊,what?!,还有这种规则约束吗?

真正的 z-index

为了真正了解这个原因,让我们了解下 z-index 究竟什么怎么起作用的

我以前以为的 z-index 是这样的

Pasted image 20250112191850.png

z-index 数字越大,在屏幕的可视区域里,显示的顺序就越前,但在我上面的这个案例中,其实是下面这样的

Pasted image 20250112192051.png

在我的唯 z-index 的世界观里,它的 zx 轴就成这样了

Pasted image 20250112192546.png

我对 DOM 的数据结构长期锁定在树的概念,这种超前的表现形式直接让我炸了🤯

明明是父节点的 row_2 怎么最终渲染和子节点的 select__textarea_1 在一起了?!

层叠上下文 + z-index

树的概念是的没错的,唯 z-index 的世界观也是没错的,错的是我对层叠上下文的理解,其实真实的样子是这样

Pasted image 20250112224032.png

其实有两个层叠上下文

我们假定用户正面向(浏览器)视窗或网页,而 HTML 元素沿着其相对于用户的一条虚构的 z 轴排开,层叠上下文就是对这些 HTML 元素的一个三维构想。众 HTML 元素基于其元素属性按照优先级顺序占据这个空间。 -- MDN

注意⚠️

层叠上下文的概念出现早于 z-index,所以是现有 z 轴再有 z-index,这样的设计是由 HTML + CSS 的历史迭代导致的

历史迭代

类似 PS,HTML 有图层的概念,也就是层叠上下文,比如一开始的 HTML 的应用,新闻类媒体

  1. 按文档流,一层叠一层
  2. 编辑想搞点花活,希望能够实现图片类的文字环绕效果,开始有了浮动元素,即 float: left | right,浮动的块元素的层叠顺序要高于普通流的块元素
  3. 编辑又想搞点花活,把段落上下左右动下,开始有了 position: relative | absolute; top; left...,这种段落就叫做定位元素

结果就是下面偷 MDN 的图

Pasted image 20250112232532.png

再然后,新闻开始多人看了,有广告商,老板决定按照广告给的钱的权重决定广告的层级,因此有了 z-index😂

结果就是下面偷 MDN 的图

Pasted image 20250112232717.png

以上均属作者本人吃拼好饭中毒后的幻想,不负任何相关责任,如需追究真相请自行验证

总结

关于层叠上下文比较吃经验,没有相关实操的同学可以参考迭代的历史并结合自身的 web 使用情况去了解,本文没有过多归纳、分步讲解,因为我觉得 MDN 写得更好,去看文档得了😂,同时也不建议大家一次吃饱,case 太多,看过也不一定能在工作中使用,所以知道有这个小知识点并能够找到解决方案即可~

QA

Q: 为什么不在渲染树里去计算最终的 z-index?比如

z-index
1. 0
  1. 1
    1. 999
  2. 1

一共有两条路径

  1. 0->1->999
  2. 0->1

很容易得出 999 就是这个渲染树里最大的 z-index,对于 z-index 这个可能的实现,豆包给我的答案是

层叠上下文的动态性:层叠上下文的创建是动态的,不仅取决于 z-index 属性,还与其他 CSS 属性(如positionopacitytransform等)有关。元素的样式可能会在运行时通过 JavaScript 或者媒体查询等方式改变,这就使得在某一时刻计算出的路径最大 z-index 值可能在下一时刻就不适用了。

跨层叠上下文的交互:在实际的网页布局中,元素可能会跨越不同的层叠上下文进行交互。例如,一个绝对定位的元素可能会从一个父层叠上下文移动到另一个,或者一个元素的显示和隐藏会影响到其他元素的层叠顺序。这些情况使得简单地计算每条路径的最大 z-index 值变得复杂,因为需要考虑元素在不同层叠上下文中的过渡和交互。

性能考虑:在大型复杂的网页应用中,频繁地计算每条路径的最大 z-index 值可能会带来性能问题。每次元素的样式改变或者页面结构的调整都可能触发重新计算,这对于浏览器的渲染性能是一个巨大的负担。 -- from 豆包

对于豆包的答案是否正确、有没有数据,链接,讨论支撑就不是本文讨论的范围了,也大大超出了我的脑🧠容量,因此交给你们去验证,正如背景所说,我的 CSS 经验不多

Pasted image 20250112211218.png

参考链接

  1. 层叠上下文 - MDN
  2. 层叠与浮动 - MDN
  3. Adding z-index - MDN
  4. Stacking without z-index - MDN
  5. 你问我答 - 豆包