一、一个只有 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