🤖
AI审核中

position: fixed 为什么不固定了:包含块实测与排查

给博客的手机菜单做右侧抽屉时,一个 position: fixed、top 和 bottom 都是 0 的抽屉,只要写在顶栏里面就只有 64px 高,顶栏向上收起时还会被一起带走——毛玻璃用的 backdrop-filter 让顶栏成了它的包含块。本文先说明 fixed 元素的包含块什么时候不再是视口,再用 Chrome 154 实测 20 种写法:transform、translate、filter、backdrop-filter、perspective、will-change: transform、contain、content-visibility 会“截胡”,opacity、isolation、mix-blend-mode、z-index、container-type 只建立层叠上下文、不会截胡;全站置灰的滤镜放在 html 上安全,放在 body 上会让所有 fixed 元素跟着页面滚走。最后给出用 offsetParent 一行找到“截胡者”的排查方法,以及挪出 DOM、把属性挪到伪元素上两种修法。
9519 字 12 分钟

文章摘要

给博客的手机菜单做右侧抽屉时,一个 position: fixed、top 和 bottom 都是 0 的抽屉,只要写在顶栏里面就只有 64px 高,顶栏向上收起时还会被一起带走——毛玻璃用的 backdrop-filter 让顶栏成了它的包含块。本文先说明 fixed 元素的包含块什么时候不再是视口,再用 Chrome 154 实测 20 种写法:transform、translate、filter、backdrop-filter、perspective、will-change: transform、contain、content-visibility 会“截胡”,opacity、isolation、mix-blend-mode、z-index、container-type 只建立层叠上下文、不会截胡;全站置灰的滤镜放在 html 上安全,放在 body 上会让所有 fixed 元素跟着页面滚走。最后给出用 offsetParent 一行找到“截胡者”的排查方法,以及挪出 DOM、把属性挪到伪元素上两种修法。

一、一个只有 64px 高的抽屉

流萤主题的手机菜单,原来是从顶栏右上角弹出的一张小卡片,后来照着参考站改成了右侧抽屉:宽 70%,从屏幕右边滑出,高度铺满整屏。

菜单按钮在顶栏里,原来的下拉卡片也写在顶栏里,所以第一版很自然地把抽屉也放进了顶栏:

<header class="ff-nav">              <!-- 毛玻璃顶栏 -->
    <a class="ff-brand">…</a>
    <button id="ff-menu-btn">…</button>
    <aside class="ff-drawer">…</aside>  <!-- 抽屉 -->
</header>
.ff-nav {
    position: fixed; top: 0; left: 0; right: 0; height: 64px;
    backdrop-filter: blur(20px) saturate(1.6);   /* 毛玻璃 */
    transition: transform .3s ease;
}
.ff-nav.is-hidden { transform: translateY(-100%); }   /* 往下滚时收起顶栏 */

.ff-drawer {
    position: fixed; top: 0; right: 0; bottom: 0;
    width: 70%;
}

按直觉,position: fixed 再加上 top: 0 和 bottom: 0,抽屉就应该铺满整屏。

在 390 × 844 的手机视口里实测,结果是这样的:

抽屉放在哪 抽屉的尺寸和顶边 offsetParent
顶栏里面 273 × 64,顶边 0 顶栏
顶栏里面,顶栏收起后 273 × 64,顶边 -64 顶栏
挪到顶栏外面 273 × 844,顶边 0 null

抽屉的高度刚好等于顶栏的 64px;页面往下滚、顶栏收起时,抽屉还跟着顶栏一起被移出了屏幕。

问题不在抽屉本身,而在它的祖先:顶栏的 backdrop-filter 让顶栏成了抽屉的包含块。

二、fixed 元素的包含块,什么时候不是视口

top、right、bottom、left 这几个值,都是相对“包含块”计算的:

  • position: absolute:包含块是最近的、position 不为 static 的祖先;
  • position: fixed:一般情况下包含块是视口,所以页面滚动时它不动。

“一般情况下”这几个字很关键。规范还规定了一组例外:当某个祖先满足下面这些条件时,它会为自己的所有后代建立包含块,fixed 后代也不例外。

这时 fixed 元素就不再相对视口,而是相对这个祖先:尺寸被它限制,滚动时也跟着它走。

graph LR
    A["fixed 元素"] --> B{"逐个往上看祖先"}
    B -->|"某个祖先满足条件"| C["包含块 = 这个祖先<br/>尺寸受它限制,跟着它滚动"]
    B -->|"一直到根都没有"| D["包含块 = 视口<br/>滚动时不动"]

这些条件分散在好几份规范里:

规范 会让祖先成为 fixed 后代包含块的写法
CSS Transforms transform、translate、rotate、scale、perspective 不为 none
Filter Effects filter、backdrop-filter 不为 none(filter 用在根元素上除外)
CSS Will Change will-change 里写了上面这些会建立包含块的属性
CSS Containment contain 含 layout 或 paint;content-visibility: auto 也会带上这两种约束

注意判断标准是值是不是 none,而不是这个属性有没有产生可见的效果。下面的实测里,filter: blur(0) 和 translate: 0px 什么都没改变,照样把 fixed 后代截了下来。

三、实测:20 种写法,哪些会“截胡”

规范读起来很分散,所以干脆把常见写法逐个测一遍。

测试页很简单:父元素高 72px、距页面顶部 150px;子元素 position: fixed; inset: 0;页面下面再垫 3000px 高的内容,让页面能滚动。

<header id="p" style="margin-top: 150px; height: 72px; /* 被测的写法 */">
    <div id="c" style="position: fixed; inset: 0"></div>
</header>
<div style="height: 3000px"></div>

测试环境是 macOS 上的 Chrome 154(无头模式),视口 800 × 600。每种写法记录三个值:子元素的高度、页面向下滚动 400px 后子元素顶边的位置、子元素的 offsetParent。

判断方法:

  • 相对视口:子元素高 600px,滚动后顶边仍是 0;
  • 被父元素截胡:子元素只有 72px 高,滚动后跟着父元素,从 150 移到 -250。

1. 会截胡的写法

下面这些写法,子元素都只有 72px 高,滚动后顶边从 150 变成 -250,offsetParent 都指向父元素:

父元素上的写法 子元素高度 滚动 400px 后顶边
transform: translateZ(0) 72px -250
translate: 0px 72px -250
filter: blur(0) 72px -250
backdrop-filter: blur(8px) 72px -250
perspective: 1000px 72px -250
will-change: transform 72px -250
contain: paint 72px -250
contain: layout 72px -250
content-visibility: auto 72px -250

其中最容易踩到的是三个:

  • backdrop-filter:毛玻璃顶栏几乎都会用到,顶栏里面又常常放着菜单、弹层;
  • will-change: transform 和 transform: translateZ(0):常被当作“开启 GPU 加速”的写法随手加上,加在一个包着弹窗、提示条的容器上,里面的 fixed 元素就全部失效了;
  • content-visibility: auto:用来优化长页面渲染,加在文章区块上时,区块里的 fixed 元素也会被截下来。

2. 不会截胡的写法

下面这些写法,子元素都是 600px 高、滚动后顶边仍是 0,offsetParent 都是 null。第二列另做了一个命中测试:父元素里放一个 z-index: -1 的子元素,看它会不会沉到父元素背景下面,没沉下去就说明父元素建立了层叠上下文:

父元素上的写法 建立层叠上下文 截胡 fixed 后代
无(对照组) 否 否
opacity: .99 是 否
isolation: isolate 是 否
mix-blend-mode: multiply 是 否
position: relative; z-index: 1 是 否
will-change: opacity 是 否
container-type: inline-size 否 否
父元素本身 position: fixed 是 否

这一张表要说明的是:建立层叠上下文和建立包含块是两回事。

opacity、isolation、z-index 这些写法会改变元素的“绘制顺序”,影响的是谁盖住谁;它们不会改变 fixed 后代“相对谁定位”。排查 fixed 元素位置不对时,不用在这些属性上花时间。

四、“哀悼灰”放在 html 上没事,放在 body 上就会出问题

需要全站置灰的时候,很多网站会加一行:

html { filter: grayscale(1); }

Filter Effects 规范专门给根元素开了例外:filter 用在根元素上,不会让它成为 fixed 后代的包含块。实测也是这样,fixed 子元素仍然是 600px 高,滚动时不动。

但如果这一行写在 body 上:

body { filter: grayscale(1); }

实测 fixed 子元素的 offsetParent 变成了 body,高度变成了 3072px,也就是整个 body 的高度。

页面一滚动,顶栏、回到顶部按钮这些本该固定在屏幕上的元素,就会跟着正文一起滚走。

写法 子元素高度 滚动 400px 后顶边 offsetParent
html { filter: grayscale(1) } 600px 0 null
body { filter: grayscale(1) } 3072px -250 body

置灰这类全局滤镜,只能加在 html 上。

五、排查:用 offsetParent 一行找到“截胡”的祖先

CSSOM View 规范对 fixed 元素的 offsetParent 有专门的定义:没有祖先为它建立包含块时返回 null,否则返回那个祖先。

前面每一个“会截胡”的场景,offsetParent 都指向了父元素,body 置灰的场景指向 body;所有“不会截胡”的场景都是 null。

所以排查时,在开发者工具的 Elements 面板里选中出问题的 fixed 元素,然后在 Console 里输入:

$0.offsetParent

返回 null,说明它确实相对视口,问题出在别处,比如 z-index 或者被别的元素遮住;返回某个元素,那个元素就是“截胡”的祖先。

再进一步,可以把这个祖先身上几个可疑属性的非默认值列出来:

function fixedTrap(el) {
    const cb = el.offsetParent;   // fixed 元素:相对视口时是 null,被截胡时就是截胡它的祖先
    if (!cb) return '相对视口,没有被截胡';
    const cs = getComputedStyle(cb);
    const defaults = {
        transform: 'none', translate: 'none', rotate: 'none', scale: 'none', perspective: 'none',
        filter: 'none', backdropFilter: 'none', willChange: 'auto', contain: 'none', contentVisibility: 'visible',
    };
    const suspects = {};
    for (const [prop, initial] of Object.entries(defaults)) {
        if (cs[prop] && cs[prop] !== initial) suspects[prop] = cs[prop];
    }
    return { captor: cb, suspects };
}

fixedTrap($0);

在前面的测试页里跑这段代码,输出分别是:

场景 suspects 的输出
backdrop-filter 截胡 { backdropFilter: 'blur(8px)' }
translate: 0px 截胡 { translate: '0px' }
content-visibility 截胡 { contentVisibility: 'auto' }
body 置灰截胡 { filter: 'grayscale(1)' }
will-change: opacity 相对视口,没有被截胡

六、三种修法

1. 把 fixed 元素挪到截胡者外面

这次的抽屉就是这么修的:抽屉不再写在顶栏里,而是作为顶栏的兄弟节点放在它后面。

<header class="ff-nav">…</header>
<aside class="ff-drawer" id="ff-menu-panel">…</aside>

挪出来之后,抽屉在 390 × 844 的视口里是 273 × 844,offsetParent 是 null,顶栏收起时它也不会被带走。

挪出来之后还有两件事要一起处理:

  • 层级:抽屉和顶栏不在同一个层叠上下文里了,要重新排 z-index。现在是顶栏 1000、遮罩 1090、抽屉 1100,遮罩能盖住顶栏,抽屉又在遮罩上面;
  • 焦点:原来“焦点离开顶栏就收起菜单”的逻辑要改成“焦点离开顶栏和抽屉才收起”,否则用键盘从菜单按钮 Tab 进抽屉的那一刻,菜单就被关掉了。

在组件化框架里,这个思路就是 Portal:React 的 createPortal、Vue 的 Teleport,都可以把弹层渲染到指定的节点(通常是 body)下面,避开中间祖先上的这些属性。

2. 把截胡的属性挪到伪元素上

如果只是 backdrop-filter 这类“视觉效果”的属性在截胡,也可以不动 DOM,把效果挪到伪元素上:

.ff-nav {
    position: fixed;   /* 顶栏本身不再加 backdrop-filter */
}
.ff-nav::before {
    content: "";
    position: absolute; inset: 0; z-index: -1;
    backdrop-filter: blur(20px) saturate(1.6);
    background: rgba(255, 255, 255, .6);
}

伪元素是顶栏的子节点,不是抽屉的祖先,它的 backdrop-filter 管不到抽屉。实测这种写法下,fixed 子元素恢复为 600px 高、滚动时不动。

顶栏本身是 position: fixed,会建立层叠上下文,所以 z-index: -1 的伪元素只会沉到顶栏内容的下面,不会掉到页面后面去。

但这个办法有局限。流萤的顶栏还用 transform: translateY(-100%) 来做“往下滚时收起”,transform 同样会截胡,抽屉放在顶栏里仍然会被带走。所以这次最终用的是第一种修法。

3. 别随手加 will-change: transform

will-change: transform 和 transform: translateZ(0) 常被当作性能优化随手加上,但实测它们都会截胡 fixed 后代。

只在确实需要的元素上加,加之前看一眼它里面有没有弹窗、提示条、抽屉这类 fixed 元素。

七、总结:fixed 是相对视口,前提是没有祖先截胡

position: fixed 的元素位置不对、尺寸不对、跟着页面滚动,往往不是它自己的样式写错了,而是某个祖先建立了包含块。

要点 结论
会截胡的属性 transform 系列、perspective、filter、backdrop-filter、will-change: transform、contain、content-visibility
判断标准 值不是 none 就算,blur(0)、translate: 0px 也算
不会截胡的属性 opacity、isolation、mix-blend-mode、z-index、container-type,它们只影响层叠
全局滤镜 加在 html 上安全,加在 body 上会让 fixed 元素失效
排查方法 fixed 元素的 offsetParent 不为 null,指向的就是截胡者
修复方法 把 fixed 元素挪出去(Portal),或者把截胡属性挪到伪元素上

fixed 元素相对视口,前提是一路往上没有祖先截胡;位置不对时,先看它的 offsetParent。

0 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

评论区 0

头像
 0 条评论