CSS 定位为什么总让人绕晕:relative、absolute、fixed 到底怎么配合

中后台有个列表页,筛选栏要求滚动到一定位置后吸在顶部,代码写得很标准:

1.filter-bar {
2  position: sticky;
3  top: 0;
4  z-index: 10;
5}

本地联调时效果正常,滚到哪吸到哪。合到主分支之后,同事反馈说筛选栏在详情弹层打开的那个页面完全不吸顶,滚动条拉到底,筛选栏还是老老实实跟着内容一起往上跑,跟没写 sticky 一样。

这类问题最麻烦的地方在于,它不报错,样式表里也挑不出语法问题,position: sticky 就是安安静静地不生效。要把它弄明白,得先把 staticrelativeabsolutefixedsticky 这五个值放在同一套坐标系里看:它还占不占文档流里原来的位置,它的偏移或者定位参照的是谁。

文档流是判断一切定位行为的起点

HTML 元素默认按结构顺序排布,块级元素从上到下、行内元素从左到右,这个自然排布方式就是文档流。一个元素如果还在文档流里,后面的元素排布时会把它的位置和尺寸算进去;一旦它脱离了文档流,后面的元素会当它不存在,直接顶上去补位。

position 的五个取值,区别主要就落在两件事上:占不占文档流的位置,偏移或定位参照谁。static 是默认值,占空间,top/left 这些偏移属性对它无效;relative 占空间,偏移参照自己原本的位置;absolute 不占空间,参照最近的定位祖先;fixed 不占空间,参照视口(这条在特定条件下会失效,后面细讲);sticky 占空间,滚动阈值前后在参照自身位置和参照最近滚动祖先之间切换。

那个吸顶筛选栏的问题,本质上就出在“滚动阈值前后切换”这个环节的前置条件没满足。但要讲清楚为什么没满足,得先把前面几个定位值过一遍。

顺带说一句“包含块”这个词,后面会反复用到。包含块指的是一个元素的尺寸和定位计算所依据的那个参照矩形。普通流里的元素,包含块通常就是最近的块级祖先的 content box;一旦引入 absolute/fixed,包含块的计算规则会变,这也是定位相关问题里大部分“坐标算出来不对”的根源。

relative:偏移不影响别人,也常被当作锚点

relative 元素依然留在文档流里,只是可以相对自己原本的位置做视觉上的偏移。

1.badge {
2  position: relative;
3  top: 4px;
4}

这里有个容易被忽略的细节:relative 的偏移只是视觉上的挪动,不会让周围元素跟着让位或补位。这点和 margin 完全不同,margin 会真的挤开旁边的内容。混淆这两者的后果通常是:想用 relative 微调一点间距,结果偏移量稍微大一点就和相邻元素压在一起,而对方完全不知道自己该让开。

另一个坑是同时写 topbottom:在默认书写方向下只有 top 生效,bottom 会被忽略,left/right 同理。所以不要指望用 topbottom 把一个 relative 元素“拉伸”,那是 absolute 配合四个方向值才能做到的效果。

relative 真正常见的用法其实不是“自己挪一下”,而是给内部的 absolute 子元素建立参照系。很多组件样式表里会看到一个 position: relative 后面跟着一个空的声明块,没有任何偏移,纯粹是为了给里面的绝对定位元素圈一个范围。

还有一种用法容易被忽略:relativez-index 可以在不改变布局的前提下,把一个元素提到别的兄弟元素上层。中后台表格里做行 hover 高亮时,经常需要让当前行的边框或者阴影盖住相邻行,最简单的做法就是给这一行加 position: relative 配合一个不算大的 z-index,既不影响文档流的排布,又能解决遮挡问题,比用 absolute 单独摘出来做浮层轻量得多。

absolute:关键不是“脱离文档流”,是“相对谁”

absolute 元素脱离文档流,定位参照的是最近的非 static 祖先元素;如果找不到这样的祖先,就参照初始包含块,也就是视口那一块区域。

注意这里说的是“非 static”,不一定是 relative——absolutefixedsticky 同样可以充当这个参照系,只是 relative 因为不脱流、不产生副作用,最常被拿来专门当锚点用。看到一段样式里 position: relative 后面没有任何偏移属性,基本可以猜到它是给某个 absolute 子元素准备的。

百分比宽高的计算基准也容易被忽略:absolute 元素写 width: 50%,这个百分比是相对包含块的宽度算的,父元素的 padding 也算在这个宽度里,跟正常流元素“相对 content box 算百分比”的直觉不一致。另外,如果 top/right/bottom/left 四个方向的值都写了,元素会被撑开到这个范围,不需要额外写 width。全屏遮罩层可以直接这么写:

1.overlay {
2  position: absolute;
3  inset: 0; /* 等价于 top/right/bottom/left 全部为 0 */
4  background: rgba(0, 0, 0, 0.45);
5}

角标类的写法更常见:

1.card {
2  position: relative;
3}
4
5.card-badge {
6  position: absolute;
7  top: 8px;
8  right: 8px;
9}

“相对最近的非 static 祖先”这件事在 DevTools 里能直接看到跳变。举个例子:

1<div class="card" style="width:300px; height:120px; margin:80px; border:1px solid">
2  <span style="position:absolute; top:0; right:0; background:#fdd">角标</span>
3</div>

这时 .card 默认是 static,角标会跑到整个页面的右上角。在 Elements 面板里临时给 .card 加上 position: relative,角标会立刻跳回卡片右上角,这一下就是参照系切换的瞬间。想确认当前状态,读 getComputedStyle(document.querySelector('.card')).position 就行。

列表页里角标全跑到页面右上角叠成一团,多半是有人把外层卡片的 position: relative 删掉了,角标失去了原本的锚点,直接找到了 body。写 absolute 之前,先确认它的锚到底是谁,比等渲染完再猜要靠谱得多。

absolute 元素的 margin: auto 也有一个不算冷门的用法:配合四个方向都是 0inset,可以把一个不知道具体宽高的元素在容器内完全居中。

1.center-box {
2  position: absolute;
3  inset: 0;
4  margin: auto;
5  width: 200px;
6  height: 120px;
7}

这个写法比 Flex 居中出现得早,在还需要兼容一些不支持 Flex 完整特性的旧场景时用得上,原理是四个方向的偏移都定死之后,margin: auto 会在剩余空间里均分,效果就是水平垂直都居中。现在大部分场景直接用 Flex 的 justify-contentalign-items 更省心,但遇到那种“父元素已经用 absolute 铺满、子元素还要再居中”的嵌套场景,这个写法依然顺手。

另外一个容易被忽略的规则是:absolute 元素如果祖先链上一个非 static 元素都找不到,它的包含块会一路找到根元素对应的初始包含块,这个初始包含块的尺寸等于视口大小,并且会随页面滚动而滚动——这一点和 fixed 的视口包含块不一样,fixed 的包含块不随页面滚动。日常开发里几乎不会真的让 absolute 找不到任何定位祖先(因为组件库的容器组件基本都会带 position: relative),但一旦出现这种情况,元素会滚动到看不见的地方,排查时容易先怀疑是不是 overflow 裁掉了,其实是包含块本身就跟着页面在滚。

static 切到别的定位值,宽高计算会跟着变

除了参照系变化,切换 position 还有一条容易被忽略的连带影响:元素从 static 切到 absolute 之后,宽度的默认表现会变。

一个 static 的块级元素默认宽度是撑满父容器的可用宽度;同一个元素改成 absolute 之后,如果没有显式写 width,它的宽度会收缩成“刚好包住内容”,跟行内块元素的表现类似。这个变化经常在临时给某个元素加 position: absolute 做小范围调整时被忽略,改完发现元素宽度突然变窄了,第一反应容易怀疑是不是布局代码哪里出错了,其实是切换定位方式本身带来的默认宽度规则变化,跟别的代码没关系。

1.notice {
2  /* 切换前:static,宽度撑满父容器 */
3  /* 切换后:absolute,宽度收缩为内容宽度 */
4  position: absolute;
5  top: 0;
6  width: 100%; /* 想保留原来的撑满效果,得显式补回来 */
7}

同理,display 也会受 position 影响:一个 display: inline 的元素一旦设置了 absolute 或者 fixed,会被强制按块级元素计算(这条规则里专门叫“计算后的显示类型”,跟 display 属性本身写的值不完全是一回事)。也就是说一个 <span> 加上 position: absolute 之后,可以正常设置 width/height,即便它的 display 属性没有手动改成 block。这条规则不常用到,但排查“为什么一个行内元素设置的宽高突然生效了”这类问题时用得上。

fixed 参照视口,但视口不是永远靠得住

position: fixed 同样脱离文档流,通常相对浏览器视口定位,不随普通页面滚动移动,适合做返回顶部按钮、悬浮客服入口、固定顶部导航这类场景。

1.back-top {
2  position: fixed;
3  right: 24px;
4  bottom: 24px;
5}

fixed 的代价是容易遮挡内容,还可能和移动端安全区域、弹窗层级产生冲突。iPhone 底部手势条会压住固定在底部的按钮,得留出安全区:

1.back-top {
2  position: fixed;
3  right: 24px;
4  bottom: calc(24px + env(safe-area-inset-bottom));
5}

iOS Safari 上软键盘弹起、地址栏收缩时视口高度会跳变,bottom: 0 的固定元素容易被顶起来或者飘到奇怪的位置,这种场景下有时候干脆不用 fixed,改成把内容放进单独的滚动容器里处理。

真正容易被忽略、也最容易在评审时被漏掉的一条:祖先元素只要设置了 transformfilterperspective 或者 will-change 这几个属性中的任意一个,它就会变成 fixed 子元素的包含块。也就是说一个本该相对视口定位的 fixed 元素,一旦某层祖先用了 transform 做动画或者过渡,这个 fixed 元素的参照系会从视口切换成那个祖先,效果就是它不再固定在屏幕上,而是跟着那个容器一起滚走了。这个坑在带轮播、带页面级过渡动画的项目里出现频率不低,排查思路是从 fixed 元素往上一路检查祖先的 transform/filter/will-change,而不是怀疑 fixed 本身写错了。

排查这类问题,DevTools 里最直接的做法是在 Elements 面板里从 fixed 元素开始往上逐层选中祖先,看 Computed 面板里 transform 这一栏是不是 none。如果某一层显示的不是 none(哪怕只是 translateZ(0) 这种常见的强制开启硬件加速的写法),这一层就是意外产生的包含块。手动把这个属性临时禁用掉,fixed 元素立刻会跳回相对视口固定的状态,这个跳变和前面 absolute 找参照祖先时看到的现象是同一个道理。

这里顺便说一句容易被搞混的地方:will-change: transform 即便没有真的执行任何变换,只要写了这个属性,同样会触发包含块变化,因为浏览器要为将来可能发生的变换提前建立渲染层。团队里为了让某个区域滚动更顺滑,习惯性给容器加一句 will-change: transform 做性能优化,这句性能优化本身反而是最常见的“fixed 元素突然不固定了”的元凶,而且因为它的意图是“优化”,排查的时候很容易被忽略过去,以为跟布局无关。

包含块的计算规则,值得单独拆开看一遍

前面几节反复提到“包含块”,这里专门拆开算一遍,免得后面遇到坐标对不上的问题时,只能靠猜。

一个元素的包含块,取决于它自己的 position 是什么,而不是它父元素的 position。规则大致是这样:如果元素是 static 或者 relative,包含块是最近的块级容器祖先的 content box;如果元素是 absolute,包含块是最近的“定位祖先”(position 不是 static 的祖先)的 padding box,注意这里是 padding box 不是 content box,也就是说父元素的 padding 会被算进子元素的可用范围里;如果元素是 fixed,包含块通常是视口,但前面讲过的 transform/filter/will-change 这几个属性会让某个祖先变成它的包含块;如果元素是 sticky,在没触发吸附之前按 static 的规则算包含块,触发吸附之后行为上更接近 fixed,但参照的滚动范围是最近的滚动祖先而不是整个视口。

“padding box 而不是 content box”这条规则实际项目里很容易被坑到。举个例子:

1.panel {
2  position: relative;
3  width: 300px;
4  padding: 20px;
5}
6
7.panel-close {
8  position: absolute;
9  top: 0;
10  right: 0;
11}

.panel-closetop: 0; right: 0 是相对 .panel 的 padding box 计算的,也就是说这个关闭按钮会贴着 .panel 内容区域往外 20px 的位置,也就是 .panel 视觉边框的内侧顶点,而不是贴着 .panel 内部文字内容的顶点。这个规则一开始容易理解成“贴着看得见的边框走”,实际上更准确的说法是“贴着 padding 和 border 之间的那条线走”,border 本身不算在包含块内,box-sizing: border-box 也不改变这条规则,只影响宽高怎么算,不影响包含块的基准线。

sticky:吸顶失效回到那个筛选栏的问题

position: sticky 的行为可以理解成相对定位和固定定位的组合:滚动阈值达到之前,表现跟普通元素一样,占着文档流里原来的位置;阈值达到之后,行为切换成固定定位,贴在指定位置不动。

1.filter-bar {
2  position: sticky;
3  top: 0;
4}

它不是任何时候都像 fixed 那样贴在视口上,而是要满足几个前提条件。先说最直接的一个:没写阈值。sticky 必须配一个 top/bottom/left/right 里至少一个,光写 position: sticky 跟普通元素没有区别。

第二个前提,也是那个吸顶筛选栏出问题的根源:sticky 的生效范围被限制在它最近的“滚动祖先”里,一旦这条祖先链上有任意一层设置了 overflow: hiddenautoscrollsticky 的吸附行为就只在那个容器内部生效,甚至直接失效。详情弹层那个页面,外层用了一个 overflow: hidden 的容器来裁剪弹层打开时的过渡动画,这个容器恰好是筛选栏的祖先之一,筛选栏的“滚动祖先”因此变成了这个内部容器,而不是原本预期的整个页面滚动区域,吸顶自然就不对了。

第三个前提是父容器的高度。sticky 只能在父元素的高度范围内吸附,父元素本身滚出可视区域之后,sticky 元素也会跟着一起滚走。这也是为什么 sticky 适合吸表头、吸目录这类“父容器本身很长”的场景,但不适合做全局导航——全局导航的父容器往往就是页面本身,高度不够撑起吸附效果,这种场景还是老实用 fixed

这几个条件可以用一段最小示例验证:

1<div style="height:2000px">
2  <h3 style="position:sticky; top:0; background:#ffd">我会吸顶</h3>
3  <p style="height:1500px">往下滚……</p>
4</div>

往下滚,<h3> 到达顶部就会吸住不动。把 top:0 删掉,吸顶立刻失效;给最外层的 div 加上 overflow:hidden,吸顶同样会失效。想确认当前生效状态,可以读 getComputedStyle(h3).position,正常情况下返回 "sticky"。Chrome DevTools 的 Elements 面板里,sticky 元素旁边会带一个专门的徽章标注,鼠标移上去还会画出它的吸附参照框,这个功能排查 sticky 失效问题时很好用。

那个筛选栏最后的处理办法,是把裁剪过渡动画的 overflow: hidden 从筛选栏的祖先容器上挪走,改成只包裹弹层本身那一小块区域,让筛选栏的滚动祖先恢复成页面级的滚动容器。

排查这类问题有个通用套路:先确认 sticky 元素自身有没有阈值属性,再顺着 DOM 树往上,把每一层祖先的 overflow 计算值过一遍,只要不是默认的 visible,就要怀疑它是不是把 sticky 的生效范围圈小了。Chrome 的 Layout 面板(Elements 侧栏里的 Layout 标签)能直接勾选“显示所有定位元素的边框”,sticky 元素的吸附参照框会用虚线标出来,比纯靠肉眼判断滚动容器边界要快很多。

还有一个容易被漏掉的表现:sticky 元素本身如果被设置了 overflow 不是 visible(哪怕只是 overflow: auto 用来处理内部内容溢出),它自己也会变成自己内部 sticky 子孙的滚动祖先边界,这种嵌套 sticky 的场景在做多级表头、多级筛选面板的时候会遇到,排查方式和前面讲的完全一样,只是要多留意作用范围到底是外层容器还是这个元素自身。

sticky 还有一个和 z-index 搭配时才会暴露的细节:它本身会创建层叠上下文(前面属性列表里提过),如果一个 sticky 的表头下面需要压住普通内容,同时又想让它在被别的浮层遮挡时正确沉底,这两个需求要分别用不同的 z-index 数值和不同层级的容器去满足,不能指望一个 z-index 数值同时解决“压住下面”和“被上面盖住”两件事,这两件事分别取决于两个不同的层叠上下文比较关系。

Vue 组件里定位相关的一个常见误区:v-if 和 v-show 的选择

写弹层、下拉这类需要用到 absolute/fixed 的组件时,还有一个跟定位间接相关的选择容易被忽略:内容用 v-if 控制显隐,还是用 v-show 控制显隐。

v-if 为假时会把节点整个从 DOM 里移除,v-show 只是切换 display: none,节点还在 DOM 里。如果一个 absolute 定位的浮层平时是隐藏状态,用 v-show 实现,意味着这个节点即便不可见,也依然占着它在 DOM 树里的位置,如果它恰好又是别的 absolute 元素的定位祖先,子元素的包含块计算不会因为父元素“看不见”而受影响,该怎么算还是怎么算。用 v-if 就没有这层顾虑,因为节点整个不存在了。

1<div class="dropdown-trigger">
2  触发器
3  <div class="dropdown-panel" v-show="visible">
4    <!-- 面板内容 -->
5  </div>
6</div>
1.dropdown-trigger {
2  position: relative;
3}
4
5.dropdown-panel {
6  position: absolute;
7  top: 100%;
8  left: 0;
9}

这个例子里用 v-show 没问题,因为 .dropdown-panel 本身没有再嵌套需要参照它的定位元素。但如果面板内部还有更深一层的 absolute 子元素依赖这个面板做参照系,同时这个面板经常被频繁切换显隐,用 v-if 让它彻底销毁重建,能避免“隐藏状态下节点还占着一份计算成本”这类细枝末节的开销积累,尤其是列表页里同一个下拉组件被渲染几十份的场景,这点差异会被放大。

为什么过度依赖绝对定位会让布局变脆弱

absolute 最大的特点是脱离文档流,这意味着即便把元素放到了目标位置,其他元素也不会为它留出空间。如果一个区域本来靠内容撑高度,核心内容却被改成了 absolute,父元素的高度可能直接塌陷成 0。

absolute 更适合小范围角标、装饰元素、需要压在内容上层的局部模块这类场景。如果一个区域本质上还是正常的流式布局,用 Flex 或 Grid 去处理往往比硬摆坐标更稳,字体大小变化、内容长度变化、屏幕宽度变化都不需要重新计算坐标。

有个反面例子印象比较深:一个卡片列表页,早期为了让每张卡片右下角的操作按钮组“看起来贴着底部”,把整组按钮写成了 absolutebottom: 12px,卡片本身的高度靠上面的文字内容撑起来。后来产品加了一个可以展开的详情说明,展开之后文字变多、卡片该跟着变高,但因为按钮组是 absolute,卡片高度完全不受它影响,实际渲染出来的效果是文字展开后直接盖住了下面固定位置的按钮组。这类问题的根源不是哪个属性用错了,是把“应该跟着内容撑高度”的东西提前定死成了绝对定位。改法也不复杂:按钮组改回普通流布局,放在卡片内容的下方,用 Flex 让卡片整体纵向排列,.card { display: flex; flex-direction: column; },按钮组自然跟着内容一起撑开卡片高度。

z-index 不是数值竞赛,是层叠上下文的问题

只要涉及定位,几乎必然会碰到 z-index。第一条容易被忽略的规则是:z-index 只对“定位元素”(也就是 position 不是 static 的元素)生效,一个 position: static 的普通块写 z-index: 9999 是完全无效的声明,浏览器直接忽略。给它补一个 position: relative,哪怕不写任何偏移,z-index 才会开始参与层叠计算。

除了这条基础规则,z-index 的比较范围还受层叠上下文限制。层叠上下文可以理解成浏览器为渲染层级划出的一个个独立容器,z-index 的比较只在同一个层叠上下文内部有意义,跨上下文比较数值大小没有意义。

除了 positionz-index 会产生层叠上下文之外,还有一批属性单独出现就会形成新的层叠上下文:opacity 小于 1、transformfilterwill-changeposition: fixedsticky,以及 Flex/Grid 容器里设置了 z-index 的子项。这些属性一旦出现在某个祖先元素上,这个祖先内部所有元素的 z-index 就被“关”在这个上下文里,外面的元素无论 z-index 写多大都无法越过去。

一个下拉菜单调 z-index 怎么调都被旁边一张卡片盖住,加到 9999 也没用,排查下来是那张卡片做 hover 过渡用了 opacity,鼠标悬停时 opacity 变成 0.99,硬生生造出了一个新的层叠上下文,把下拉菜单整个压在了下面。这种情况堆数值没有用,解决办法要么是把下拉菜单用 portal 渲染到 body 下面脱离这个上下文,要么直接把触发层叠上下文的那个属性挪到不影响菜单的地方。遇到弹层、下拉框被遮住的问题,先确认两者是不是在同一个层叠上下文里,比先改 z-index 数值更快找到根因。

DevTools 里判断一个元素是否形成了层叠上下文,Elements 面板的 Layout 标签里勾选“Show stacking contexts”之后,会直接给每个层叠上下文的根元素打上一个类似小旗子的标记,节点旁边还会带缩进层级,比手动去翻 Computed 面板一条条属性核对要快得多。这一年这个功能在 Chrome 稳定版里已经可用,排查层级问题基本可以不用再靠“注释掉某个属性再刷新看效果”这种笨办法。

Vue 项目里用 <transition> 做进场退场动画,也是层叠上下文的高发地带。因为过渡类名通常会给元素加上 opacity 或者 transform 相关的样式,只要过渡还没结束,这个元素就带着一个临时的层叠上下文,如果这个元素内部恰好有个需要盖住页面上其它内容的浮层,浮层的 z-index 会被这个正在过渡的祖先限制住,动画一结束限制就解除了,所以这类问题往往表现成“动画播放的一瞬间层级不对,动画结束后又正常”,比稳定态的层级问题更难复现,排查时得留意是不是卡在了过渡的这几百毫秒里。

CSS 新增的选择器能省掉一部分层级战争

这一年 Chrome 88 之后 :is():where() 已经可以放心用,跟定位场景搭配着写能省不少重复选择器。比如一组不同状态下都需要覆盖 z-index 的弹层:

1.modal-mask:is(.entering, .active, .leaving) {
2  z-index: 1000;
3}

:where():is() 的差别在于优先级::where() 里的选择器优先级永远算作 0,:is() 会取参数列表里优先级最高的那个。写覆盖层级样式时,用 :where() 能避免这条规则的优先级意外压过后面业务代码里更具体的选择器,减少调试 z-index 时“明明数值更大却不生效”这种和优先级绞在一起的排查成本。

这两个选择器对定位类样式还有一个实用的地方:以前要给多个不同的定位场景写同一组“基础定位规则”,经常得写成一长串逗号分隔的选择器,稍微改一下命名规范就要挨个改。现在可以把这些场景收进 :is():where() 里统一维护:

1:where(.dropdown-panel, .popover-panel, .tooltip-panel) {
2  position: absolute;
3  z-index: var(--z-popover);
4}

新增一种浮层类型,只需要在括号里加一个类名,不用再复制整条规则,日常维护这类“层级相关的基础样式”时改动量能小不少。

flex 布局下 gap 能替掉一部分定位类的间距处理

Chrome 和 Firefox 已经支持 flex 容器上的 gap 属性有一阵子了,值得单独提一句,因为它能替掉过去一部分本该用普通间距解决、却因为怕子元素外边距塌陷而改用定位去凑的写法。目前 Safari 这边还没跟上,公司这几个中后台系统内部用户环境统一是 Chrome,可以直接用;面向外部访客的页面眼下还得留一份 margin 方案兜底,等 Safari 补上再考虑收掉降级代码。

以前给一组水平排列的按钮加间距,常见做法是给除了最后一个之外的按钮都加右外边距,或者用负外边距抵消最后一个多出来的间距,写起来啰嗦还容易在按钮数量变化时漏改:

1.btn-group .btn:not(:last-child) {
2  margin-right: 8px;
3}

有了 gap 之后可以直接写在容器上:

1.btn-group {
2  display: flex;
3  gap: 8px;
4}

这跟定位话题看起来不直接相关,但实际项目里,有些间距问题一开始是想靠 relative 加负的 margin 去挪位置凑出来的,本质上是把简单的间距需求绕道用定位去解决,一旦以后要改间距数值或者子项顺序,牵涉的样式规则比用 gap 多得多。判断要不要用定位解决一个布局问题时,gap 提供了一个新的选项:优先看这个需求是不是纯粹的“子元素之间留多少距离”,是的话不需要引入 relative 或者负 margin,写在容器上的 gap 就够了。

判断顺序比记住属性更重要

碰到需要摆位置的需求,判断顺序基本是这样:能用正常文档流解决的,先不引入定位;需要局部覆盖或者角标,再考虑 absolute,同时想清楚它的参照祖先是谁;需要吸顶吸底,先看父容器的高度和滚动祖先是否支持 sticky,支持不了才退回 fixed;需要子元素相对父元素摆放,先把参照系(通常是一个空的 position: relative)建立好,再写子元素的偏移。

写组件库或者公共样式的时候,这几条判断顺序值得固化成团队内部的约定,而不是每次都临时决定。团队现在用 Vue 2.6.x 搭配 Element UI 做中后台,很多组件(弹层、下拉、气泡卡片)内部已经把 positionz-index 的处理封装好了,业务代码里遇到定位问题,多数时候不是组件本身出错,而是业务层给组件的容器额外加了 overflow 或者 transform,把组件内部原本设计好的定位链路打断了。碰到“组件文档说这样写没问题,我这里就是不生效”的情况,第一反应可以是去查自己外层容器有没有多加东西,而不是先怀疑组件库有 bug。

回到最开始那个筛选栏的问题:滚动祖先被一个为了别的目的加上去的 overflow: hidden 意外接管,这类问题的排查路径其实是固定的——先看这个定位值本身要不要占文档流、参照的是谁,再顺着 DOM 树核对每一层祖先有没有意外引入包含块或者截断滚动范围,最后才轮到检查数值本身写得对不对。把这条路径走顺了,遇到“跑偏了”“吸不住”“层级压不过去”这类问题,基本不需要再靠试错去猜。

响应式场景下,定位方案在断点之间要重新审视

同一个组件在桌面端和移动端往往需要不同的定位策略,这条容易在只用一套断点简单调整数值时被忽略,实际上定位方式本身也可能需要跟着断点换。

桌面端常见的一个筛选面板,宽屏下用 absolute 悬浮在触发按钮下方是合理的,因为屏幕空间够,悬浮不会挡住太多内容;到了移动端窄屏,同样的悬浮面板很容易盖住大半个屏幕,这时候更合适的做法是换成从底部滑出的抽屉,实质上是从 absolute 换成了配合 transform 做位移动画的 fixed

1.filter-panel {
2  position: absolute;
3  top: 100%;
4  left: 0;
5  width: 320px;
6}
7
8@media (max-width: 768px) {
9  .filter-panel {
10    position: fixed;
11    top: auto;
12    left: 0;
13    right: 0;
14    bottom: 0;
15    width: auto;
16    transform: translateY(0);
17  }
18}

这里断点切换的不只是宽高数值,是整个定位策略——参照系从“触发按钮所在的定位祖先”换成了“视口”,偏移逻辑也完全不同。写响应式样式时,如果发现同一个组件在不同断点下的视觉行为差异很大(不只是变窄变宽,而是整个交互形态变了),值得先问一句这两种形态背后适合的 position 值是不是本来就该不一样,而不是在同一套定位规则里硬塞不同的数值去凑效果。

用变量把 z-index 的层级关系固化下来

层叠上下文的排查手段之外,团队里更常见的做法是从源头减少层级冲突的概率,具体做法是把 z-index 的数值收进一组 CSS 自定义属性里统一管理,而不是每个组件各写各的数字。

1:root {
2  --z-dropdown: 100;
3  --z-sticky-header: 200;
4  --z-modal-mask: 900;
5  --z-modal: 901;
6  --z-toast: 1000;
7}

每一类浮层对应一个语义化的变量名,业务代码里写 z-index: var(--z-modal),而不是随手写一个 999 或者 9999。这样做的好处不是解决了层叠上下文的问题——变量本身不会绕开层叠上下文的规则,该失效还是会失效——但它能解决另一类更常见的问题:多个开发者各自为了“保证盖住别人”而不断往上加数字,最后出现 z-index: 99999 这种没有意义的数值竞赛。有了这组变量之后,新增一种浮层类型时先看这张表里当前的层级关系,而不是凭感觉抄一个更大的数字。

这组变量当然不能替代前面讲的排查方法。变量解决的是“层级关系写得清楚不清楚”,overflowtransformopacity 触发的层叠上下文问题,还是得回到具体元素、具体祖先链上去看。两者配合起来,遇到层级异常时,至少可以先排除“是不是有人乱写了一个更大的数字”这种最低级的可能性,把注意力集中到层叠上下文本身。

表格场景里 sticky 表头和 sticky 首列的组合

中后台常见的另一种定位需求是同时吸顶表头和吸住首列(一般是勾选框或者序号列),这个场景把前面讲的 sticky 规则和层叠上下文规则同时用上了。

1.data-table {
2  overflow: auto;
3  max-height: 600px;
4}
5
6.data-table thead th {
7  position: sticky;
8  top: 0;
9  z-index: 2;
10  background: #fff;
11}
12
13.data-table tbody td:first-child {
14  position: sticky;
15  left: 0;
16  z-index: 1;
17  background: #fff;
18}
19
20.data-table thead th:first-child {
21  z-index: 3;
22}

这里表头单元格既要吸顶又要在纵向滚动时压住下面的普通单元格,首列单元格要吸左压住右边滚过来的内容,表头和首列交叉的那一个单元格必须同时满足两个方向都吸附,还要在层级上盖过表头和首列各自的其它单元格,所以给了它更高的 z-index。这里如果不写 background,吸附之后滚动到的内容会从半透明的单元格背后透出来,看起来像叠了一层脏东西,这也是写 sticky 表头时很容易漏掉的一步——sticky 只处理位置,不会自动帮忙处理背景遮挡。