前言
这篇文章的重点是 z-index 管理方案,主标题是标题党吸引点流量,请谅解,希望我用高质量的方案对比来消解你对标题党的怒火 😅。
有些人可能觉得 z-index 有啥难的,这其实是一个很经典的前端难题了。我们先看看以下几个组件库如何让它们的 z-index 管理出现异常。
以下问题在国内 3 个知名组件库,阿里的 ant design,腾讯的 tdesign 和 semi-design 出现。后续也会简单说一下他们的 z-index 管理方案的原理以及出现问题的原因。
这里字节的 arco design 是解决了这个问题的。后面也会讲 arco 是怎么设计的。
我们先看如何复现问题,有在线案例。
首先我们把一个两个 Button 组件放在一起,如下图:
然后第一个 Button 组件是触发弹出 Modal 框的,第二个按钮是类似 Tooltip 组件,我们让这个弹层永远显示,这样好复现 bug。
然后点击 Open Modal 的 button 按钮,出现 Tooltip 把 Modal 的黑色蒙层遮盖了的问题。
你可能说这算啥 bug,出现几率很低,我们再来一个?
看看 ant vue 有没有问题(只要你知道他的 z-index 方案,你能想出 n 个方法让他出问题)
在分析产生问题的原因前,大家是否想过一个问题,你可以看看你用的组件库,把一些弹出框组件(例如,modal 组件,tooltip 组件,message 组件等等)都渲染在了 dom 流的哪个地方?
答案是 body 下,如下图
你思考过为什么要这么做吗?比如上图,正常情况,不是应该按钮在哪里,这个对应的弹框跟按钮在一起吗,渲染到 body 下干嘛?
这其中一个重要的原因就是为了管理 z-index。
层叠上下文
为了说明这个问题,我们还要弄清楚一个概念,叫层叠上下文?我想问大家,zIndex 越大一定就在最高的层级吗?
答案是 no!
举个例子
<style>
.box1,
.box2 {
position: relative;
z-index: 1;
}
.child1 {
width: 200px;
height: 100px;
background: #168bf5;
text-align: right;
position: absolute;
top:0;
z-index: 99999;
}
.child2 {
width: 100px;
height: 200px;
background: #32c292;
position: absolute;
top:0;
z-index: 1;
}
</style>
</head>
<body>
<div class="box1">
<div class="child1">child1</div>
</div>
<div class="box2">
<div class="child2">child2</div>
</div>
</body>
以上代码,我们可以看到 child2,zIndex 是 1,child1 的 zIndex 是 99999,按道理来说,child1 的 zindex 更大,它应该展示在 child2 上面,可是结果如下:
原因就是 box1 和 box2 都创造了层叠上下文(如果有 zindex 为数字 + 非 postion:static 布局会产生层叠上下文,还有很多条件也能创造层叠上下文,这里就不细说了)
box1 和 box2 的层叠等级一样,所以遵循谁后写谁在上面,所以 box2 永远在 box1 上面
所以 box2 里面的元素,是永远比 box1 里面的元素层级更高的。
那么 child1 和 child2 比较根本没有意义,因为他们并不在一个层叠上下文中,只有在一个层叠上下文中,比较 zindex 才有意义。
为什么放到 body 下
因为我们可以看到,业务代码有可能会在很多隐蔽的地方产生层叠上下文,这个组件库是无法控制的,所以如果大家把很可能产生遮盖效果异常的组件都放在 body 上,就相当于大家在一个层叠上下文中了,可以更好的控制遮盖问题。
特殊情况
从上面来看,放在 body 下,大家都在一个层叠上下文中,那么就会遵循谁后出现,谁在层级之上的效果,但是总有一些常见的情况是不想要这样的,比如:
- 我有一个 message 组件,弹出消息,3 秒之后消失,在 1 秒的时候我就点击 modal 框,但是我们遵循谁后点击,谁在层级上,那么 modal 组件就把 message 组件覆盖了,这并不是我们想要的。
所以一般情况,对于 message 和 notification 的弹出消息,我们总是希望他们层级是最高的。
- 还有就是我在文章开始复现的一个问题,就是因为 modal 的 z-index 没有 tooltip 的层级高
这下大家知道为什么产生问题的原因了吧。如何解决呢?我们看看 bootstrap5 的方案:
bootstrap zindex 设计
$zindex-dropdown: 1000;
$zindex-sticky: 1020;
$zindex-fixed: 1030;
$zindex-modal-backdrop: 1040;
$zindex-offcanvas: 1050;
$zindex-modal: 1060;
$zindex-popover: 1070;
$zindex-tooltip: 1080;
这个简单看看就好,我觉得有点过时了,因为 bootstrap 是在 jquery 那个年代的流行产物,并不会将所有弹出框类似的组件渲染到 body 下.
但是这起码说明一个问题,就是按照 bootstrap 这个标准,至少很少有弹出层异常的问题,为什么是很少有呢?
因为特殊情况基本上都是层叠上下文导致的,这种特殊情况只有组件单独导入 zindex 适配业务需求。
通过设置 z-index 层级的方案
类似 bootstrap,通过对特殊弹框类组件设置不同的 z-index 来避免遮盖问题,我们列举了以下方案:
ant design
// ant-design/components/style/themes/default.less
/* z-index列表, 按值从小到大排列 */
@zindex-badge: auto;
@zindex-table-fixed: 2;
@zindex-affix: 10;
@zindex-back-top: 10;
@zindex-picker-panel: 10;
@zindex-popup-close: 10;
@zindex-modal: 1000;
@zindex-modal-mask: 1000;
@zindex-message: 1010;
@zindex-notification: 1010;
@zindex-popover: 1030;
@zindex-dropdown: 1050;
@zindex-picker: 1050;
@zindex-popoconfirm: 1060;
@zindex-tooltip: 1070;
@zindex-image: 1080;
在我的组件库里,因为 popover,dropdown,tooltip,selelct 类型的下拉框都属于 popup 组件,所以跟 ant design 略有不同,他们都是一个层级。
为什么我能试出来 ant vue 的 bug,大家可以看 ant design 中@zindex-dropdown: 1050,然后@zindex-popover: 1030,那么意味着,在同一个层叠上下文中,我先触发 dropdown,再触发 popover,那么 popover 一定是在 dropdown 底下的,所以会产生 bug。
后来我看 ant design5 学聪明了,不支持传入组件,只能传入数组了。。。我的组件也是这么做滴,嘿嘿,当然不仅仅是因为这个 bug,后期要为低代码平台做铺垫,所有传入的数据最好都是普通数据,比如数组,对象,而不是 react 组件。
全局管理器方案
elementUI 将弹窗层级管理收敛到了一个入口 PopupManager 中,涉及 zIndex 层级的弹窗组件实例都需要注册到 PopupManager 中。
简单来说,就是用一个全局的对象记录当前最高的 zindex,然后下一个比这个更高,简单来说如下:
class ZIndexManager {
constructor() {
this.zIndex = 1000; // 初始的 z-index 值
this.zIndexMap = new Map(); // 用于存储元素和对应的 z-index 值
}
getNextZIndex() {
this.zIndex += 1;
return this.zIndex;
}
registerElement(element) {
const nextZIndex = this.getNextZIndex();
this.zIndexMap.set(element, nextZIndex);
this.updateElementZIndex(element, nextZIndex);
}
unregisterElement(element) {
this.zIndexMap.delete(element);
}
updateElementZIndex(element, zIndex) {
element.style.zIndex = zIndex;
}
}
const zIndexManager = new ZIndexManager();
export default zIndexManager;
但是我觉得,很多场景并不是说我需要后面出现的弹层一定要比前面的层级高。
我们之前也说了,message(也就是 toaster)肯定是最高层级的,我们不希望 modal 比它还高,所以这个方案我觉得还能更好。
改进 ant design 方案
在我看来,ant deisgn 的方案稍微改一下,基本上就使用百分之 95%以上的场景了,特殊场景用户自己去单独给组件传入 z-index 或者改变层叠上下文的层级,也就是自定义设置了。
以下层级由低到高:
- Affix
- Drawer, Message, Modal,modal-mask, popup 相同层级(从而让后出来的在层级最上面)
- notification
- message
上面的 popup 包含很多,比如 select 所有的类似下拉框组件(比如 picker),tooltip,dropdown 等等
arco design 方案
字节的 arco design 在这方面我觉得是国内做的比较好的,文章初的两种 bug 均对它无效。
字节的处理基本上跟我上面改进的方案差不多,但是它只对 Modal 和 Drawer 组件内部的所有组件的 z-index 进行了+1 处理
为什么要这么做,我们要看下 arco 的 z-index 方案。
// z-index
'--z-index-popup-base': 1000,
'--z-index-affix': 'calc(var(--z-index-popup-base) - 1)', // 999
'--z-index-popup': 'var(--z-index-popup-base)', // 1000
'--z-index-drawer': 'calc(var(--z-index-popup-base) + 1)', // 1001
'--z-index-modal': 'calc(var(--z-index-popup-base) + 1)', // 1001
'--z-index-tooltip': 'var(--z-index-popup-base)', // 1000
'--z-index-message': 'calc(var(--z-index-popup-base) + 3)', // 1003
'--z-index-notification': 'calc(var(--z-index-popup-base) + 3)', // 1003
'--z-index-image-preview': 'calc(var(--z-index-popup-base) + 1);', // 1001
以上是我的最开始的 z-index 方案,就是从 arco 借鉴而来,但是我们发现上面有什么问题呢?modal 的 zindex 是 1001,popup 的 zindexshi 1000,意味着我先打开 modal 框,然后 modal 框里有一个 popup 按钮,再触发 popup 按钮后,显示的文字居然回到 modal 框后面(我的组件库目前有这个 bug)
所以 acro 怎么避免这个情况呢,例如,在 modal 框里,会把 modal 框里传入的组件所有 index 重新设置为当前 modal 的 zindex + 1,所以 arco 避免了这种 bug。
而我怎么做呢,我只要把 modal 的 z-index 改成和 popup 一样,这不就天然是谁后出现,谁在上面了吗,巧妙的达成了和 arco 一样的效果。
求个 star
做组件库教程不易,求个 star,哈哈,