Modal 的 position:fixed 为什么会失效:一次 filter 引发的包含块事故,与 Vue 3 Teleport
需求很简单:给页面加一个 Modal。遮罩层 position: fixed; inset: 0,铺满视口——教科书写法。
结果它没有铺满视口,而是缩在某个容器里,位置还随着容器走。
这篇就讲这件事:先说清 fixed 为什么失效(不是玄学,是 CSS 规范里一条明确的规则),再用无头浏览器实测 14 个属性看谁触发,最后引出 Vue 3 的 Teleport 方案,以及它自己带来的几个新坑。
文中的实测数据来自本机 Chrome 无头模式,复现用的最小代码在文末附录,可直接贴出来自己验一遍。所有规范条文均标注了出处并可核对。
一、现场:modal 为什么会"缩"在容器里
先还原一个最小场景。假设页面结构是这样:
<div class="app-shell"> <!-- 某处给它加了 filter -->
<div class="modal"> <!-- position: fixed; inset: 0 -->
<div class="modal-box">…</div>
</div>
</div>
.app-shell {
/* 可能来自"毛玻璃"设计、可能来自某个动效、也可能只是随手写的占位 */
filter: blur(0px);
margin: 300px 0 0 250px;
width: 400px;
height: 200px;
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .4);
}
按直觉,.modal 的 inset: 0 应该撑满视口。实测结果是它撑满了祖先的那 400×200 的盒子:
| 遮罩层实际 rect | 期望 | |
|---|---|---|
| 窗口 1200 × 813 | (250, 300) 400 × 200 | (0, 0) 1200 × 813 |
250, 300 正是 .app-shell 的 margin,400 × 200 正是它的宽高。fixed 的参照系不是视口,而是那个 .app-shell。
二、原因:祖先创建了「包含块」
position: fixed 的定位基准默认是视口(准确地说是 initial containing block)。但 CSS 里有一条规则:某些属性的非初始值会让元素成为后代的「包含块」(containing block),一旦如此,后代里所有 absolute 和 fixed 的定位都改成相对这个元素解析。
filter 就是其中之一。所以 .app-shell 上有 filter 时,.modal 的 inset: 0 就不再是"贴视口四边",而是"贴 .app-shell 的 padding box 四边"。
关键点在于触发条件是「值不是 none」,而不是「视觉效果明显」。 我实测过 filter: blur(0px)——视觉上完全等于没加,但一样触发。这条后面会反复出现。
三、实测:14 个属性,谁触发谁不触发
光看文档容易漏,我干脆把常见嫌疑属性全测一遍。测试方法:给祖先加属性,在里面放一个 position: fixed; top: 0; left: 0 的 40×40 方块,量它的 getBoundingClientRect()。落在 (0,0) 说明 fixed 正常,落在祖先的位置说明包含块被改写了。
| 祖先上的属性 | fixed 元素实际位置 | 判定 |
|---|---|---|
| (无属性,基线) | (0, 0) | ✅ 正常 |
filter: blur(0px) | (200, 480) | ❌ 失效 |
filter: none | (0, 0) | ✅ 正常 |
backdrop-filter: blur(0px) | (200, 1140) | ❌ 失效 |
transform: translateX(0) | (200, 1470) | ❌ 失效 |
transform: none | (0, 0) | ✅ 正常 |
perspective: 1000px | (200, 2130) | ❌ 失效 |
perspective: none | (0, 0) | ✅ 正常 |
will-change: transform | (200, 2790) | ❌ 失效 |
will-change: filter | (200, 3120) | ❌ 失效 |
will-change: auto | (0, 0) | ✅ 正常 |
contain: paint | (200, 3780) | ❌ 失效 |
contain: layout | (200, 4110) | ❌ 失效 |
contain: strict | (200, 4440) | ❌ 失效 |
opacity: 0.999 | (0, 0) | ✅ 正常 |
三条值得记住的结论:
- 触发条件是「值 ≠
none」,不是「效果明显」。blur(0px)和translateX(0)都是视觉上的空操作,但都会触发。很多项目里的filter: blur(0)、transform: translate(0)是"先写着占位"的产物——它们就是隐形炸弹。 will-change是重灾区。为了"性能优化"写上的will-change: transform,会提前把包含块建立起来——即使元素当前根本没有transform。规范里这是明确要求的(见下节)。opacity不触发,这是个好的反例:它确实会创建层叠上下文(影响z-index),但不创建包含块。这两件事经常被混为一谈,实测证明它们不是一回事。
复现代码见文末附录,换台机器跑一下就能验证。
四、规范依据:四条原文
这不是浏览器的实现怪癖,是规范写死的。四条相关条文:
① filter(Filter Effects Level 1):
A value other than none for the
filterproperty results in the creation of a containing block for absolute and fixed positioned descendants unless the element it applies to is a document root element in the current browsing context.
注意最后那个例外——这是为什么 filter 加在 <html> 上不会触发:根元素本来就没有"祖先包含块"的概念。
② transform(CSS Transforms Level 1):
For elements whose layout is governed by the CSS box model, any value other than none for the
transformproperty also causes the element to establish a containing block for all descendants. Its padding box will be used to layout for all of its absolute-position descendants, fixed-position descendants, and descendant fixed background attachments.
③ contain(CSS Containment Level 2):
The layout containment box establishes an absolute positioning containing block and a fixed positioning containing block.
contain: paint 也触发(实测确认),因为它隐含了 layout containment。
④ will-change(CSS Will Change Level 1):
If any non-initial value of a property would cause the element to generate a containing block for absolutely positioned elements, specifying that property in
will-changemust cause the element to generate a containing block for absolutely positioned elements.
will-change 的设计意图是"提前优化",所以它提前把副作用也一并生效了。这就解释了为什么上表里 will-change: transform 在没有 transform 的情况下依然触发。
五、Teleport:把 DOM 挪出这个祖先
知道了原因,解法就很直白:只要 Modal 的 DOM 不在那个带 filter 的祖先里,包含块就不会被改写。
手动 appendChild 到 body 当然可以,但在 Vue 里维护起来很难看——组件的 DOM 位置和它的逻辑位置脱节了,你得自己在 mounted / unmounted 里管生命周期,还得处理和框架的协调。
Vue 3 给了语言级的方案:<Teleport>——它把组件模板里的内容渲染到别的地方去。
<template>
<button @click="open = true">打开</button>
<Teleport to="body">
<div v-if="open" class="modal" @click.self="open = false">
<div class="modal-box">
<slot />
</div>
</div>
</Teleport>
</template>
to="body" 表示"把这块 DOM 挂到 document.body 下"。于是 .modal 的祖先链里再也没有 .app-shell,position: fixed 恢复正常。
实测对比(同一个页面,只是把节点从祖先里移到 body):
| 遮罩层 rect | ||
|---|---|---|
① 留在带 filter 的祖先内 | (250, 300) 400 × 200 | ❌ 被祖先约束 |
② Teleport 到 body 后 | (0, 0) 1200 × 813 | ✅ 铺满视口 |
| ③ 再把祖先整体位移 900px | (0, 0) 1200 × 813 | ✅ 完全不受影响 |
第三行是重点:Teleport 不只是修好了初始位置,还把两者彻底解耦了。之后祖先怎么动、怎么滚、怎么变,都不会再影响 Modal。
那不用 Teleport,改 CSS 行不行
能,但都有代价:
| 替代方案 | 代价 |
|---|---|
去掉祖先的 filter | 通常做不到——那是设计稿要的,或者来自第三方组件 |
把 Modal 挪到没有 filter 的层级 | 等于手工做 Teleport,且组件复用性没了 |
position: absolute + 手动算坐标 | 要处理滚动、resize、祖先定位上下文,比 Teleport 复杂得多 |
结论:这是 Teleport 最典型、最正当的使用场景。
六、Teleport 之后会踩的坑
Teleport 解决了包含块问题,但它自己带来几个新问题——这几个才是实际开发里真正费时间的。
坑 1:样式不再跟着原位置走
这是最容易翻车的一条。Teleport 只移动 DOM,不移动 CSS 的作用范围。
- CSS 继承断了:原来
.app-shell上的color/font-size/ CSS 变量,Teleport 之后不再继承到 Modal(因为 DOM 上的父子关系没了); - 后代选择器失效:
.app-shell .modal { … }这类写法再也匹配不到; - Scoped CSS 的
data-v-xxx属性还在,所以<style scoped>里定义的 Modal 样式依然生效——但只针对该组件自己写的选择器,靠"祖先层级"匹配的一律失效。
⚠️ 但要注意:断的只是 CSS,Vue 层面的关系是全好的。 官方文档明确写了:
<Teleport>的组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。这也意味着来自父组件的注入也会按预期工作。
也就是说 props、事件、provide/inject、ref 都照常——受影响的只有「靠 DOM 层级生效的 CSS」。这个区分很重要:很多人以为 Teleport 之后组件就"断亲"了,其实没有。
实践建议:Modal 组件的样式写成自包含的(不依赖任何祖先上下文)。主题相关的 CSS 变量要显式定义在 :root 或 Modal 根节点上,不能指望从祖先继承。
坑 2:z-index 的参照系也变了
Teleport 到 body 之后,Modal 的层叠上下文是相对 body 的。如果原来它靠"在某个高层叠上下文里"压住别人,现在可能压不住了。
建议:给 Modal 一个明确的高 z-index(比如 1000 起),并在项目里维护一份 z-index 层级表。别在同一层里比大小。
坑 3:多个 Modal 的叠放顺序
<Teleport to="body"> 的多个 Modal,DOM 顺序 = 挂载顺序。后挂载的在上层,但如果你用 v-if 反复开关,挂载顺序可能不符合预期。
建议:如果是同层的多个 Modal,用 <Teleport> 挂到同一个自定义容器,并用 z-index 显式管理,不要依赖 DOM 顺序。
坑 4:SSR 下没有 body
<Teleport to="body"> 在服务端渲染时无法解析目标——服务端根本没有 document.body。
处理:让这块内容只在客户端渲染(Nuxt 里用 <ClientOnly> 包一层,或者挂载后再渲染),不要指望服务端能把它 teleport 出去。
⚠️ 顺便澄清一个容易混淆的属性:defer(Vue 3.5+)不是用来解决 SSR 的。官方文档说得很清楚,它的用途是「推迟 Teleport 的目标解析,直到应用的其他部分挂载」,从而允许 Teleport 把「由 Vue 渲染、且位于组件树之后部分」的容器元素作为目标:
<Teleport defer to="#late-div">…</Teleport>
<!-- 这个容器出现在模板中更靠后的位置 -->
<div id="late-div"></div>
也就是说:defer 解决的是「目标还没渲染出来」,不是「服务端没有 body」。两件事别混。
坑 5:disabled 的用途比想象中窄
<Teleport :disabled="true"> 会把内容渲染回原位置。官方给的典型场景是"桌面端当浮层渲染、移动端当行内组件":
<Teleport :disabled="isMobile">
…
</Teleport>
但这条要注意:disabled 是"是否移动到目标",不是"是否渲染"。想做"要不要显示 Modal",该用 v-if / v-show,别用 disabled——它管不着渲染与否。
(另:切换 disabled 时 Vue 内部会真实地移动 DOM 节点。子组件实例是否会被重建、内部状态是否保留,我没有实测,这里不写结论——如果你的场景依赖这个,先自己验一遍。)
七、什么时候该用 Teleport
适合:
- Modal / Dialog / Drawer / Toast——本场景,教科书用例;
- Dropdown / Tooltip / Popover——同样会被祖先的
overflow: hidden或transform裁掉/错位; - 全屏 Loading / 引导遮罩。
不适合:
- 需要继承祖先样式和上下文的普通内容——Teleport 之后这些全断;
- 只是"想少写几层 div"——那应该调结构,不是用 Teleport;
- 表单控件类——
Teleport出去的<input>在<form>里不再被包含,提交时拿不到值(除非用form属性关联)。这条很容易被忽略。
八、小结
回到开头那个 bug:
position: fixed失效不是玄学——它的参照系是「最近的、创建了包含块的祖先」,没有这样的祖先时才是视口;filter/transform/perspective/backdrop-filter/contain: paint|layout|strict/will-change(对应属性) 都会让祖先成为包含块,判定条件是「值 ≠none」,不是视觉效果强弱;opacity不触发(虽然它创建层叠上下文)——这两件事别混;- Vue 3 的
<Teleport>是这类问题的语言级解法,它把 DOM 挪出祖先,从根上避免包含块被改写; - 但 Teleport 会切断 CSS 继承与后代选择器——所以 Modal 组件的样式要写成自包含的,这是用它的主要成本。
最后一个实用建议:排查这类问题时,别在 DevTools 里盯着 position: fixed 看——它显示正常。要去祖先链上逐层找那几个属性。Chrome DevTools 的 Computed 面板里勾选 "Show all" 能看到 filter 这类被继承/生效的计算值,逐层翻一遍通常很快就能定位。
附:自己复现的方法
本文的表格不是抄来的。把下面这段存成 probe.html,双击用浏览器打开,页面上会直接标出每个属性的判定结果——不需要任何构建工具或依赖。
<!doctype html>
<html><head><meta charset="utf-8"><title>谁让祖先成了包含块</title>
<style>
body { margin: 0; font: 14px/1.6 system-ui, sans-serif; }
/* 每个用例:祖先带明显偏移,内部放一个 position:fixed 的方块 */
.case { position: relative; margin: 90px 0 0 200px; width: 420px; height: 40px; background: #eee; }
.fixed { position: fixed; top: 0; left: 0; width: 30px; height: 30px; background: red; }
/* ---- 被测属性,逐个单独施加 ---- */
.p-filter { filter: blur(0px); } /* 视觉上等于没加 */
.p-transform { transform: translateX(0); } /* 视觉上等于没加 */
.p-wc { will-change: transform; } /* 当前并没有 transform */
.p-contain { contain: paint; }
.p-opacity { opacity: 0.999; } /* 反例:创建层叠上下文,但不创建包含块 */
</style></head>
<body>
<div id="stage"></div>
<script>
const CASES = [
['(无属性,基线)', ''],
['filter: blur(0px)', 'p-filter'],
['transform: translateX(0)','p-transform'],
['will-change: transform', 'p-wc'],
['contain: paint', 'p-contain'],
['opacity: 0.999', 'p-opacity'],
];
const stage = document.getElementById('stage');
CASES.forEach(([label, cls], i) => {
const wrap = document.createElement('div');
wrap.className = 'case' + (cls ? ' ' + cls : '');
wrap.style.top = (i * 100) + 'px';
wrap.innerHTML = label;
const box = document.createElement('div');
box.className = 'fixed';
wrap.appendChild(box);
stage.appendChild(wrap);
});
// 同步测量:getBoundingClientRect 会强制重排,不依赖任何动画帧时序
document.body.offsetHeight;
document.querySelectorAll('.case').forEach((el, i) => {
const r = el.querySelector('.fixed').getBoundingClientRect();
// 方块落在 (0,0) = fixed 正常(相对视口);落在祖先位置 = 祖先成了包含块
const broken = Math.abs(r.left) > 3 || Math.abs(r.top) > 3;
el.style.background = broken ? '#ffe0e0' : '#e0ffe0';
el.insertAdjacentHTML('beforeend',
`<b style="color:${broken ? '#c00' : '#080'}">
→ fixed 实际位置 (${Math.round(r.left)}, ${Math.round(r.top)})
${broken ? '❌ 失效,祖先成了包含块' : '✅ 正常,相对视口'}
</b>`);
});
</script></body></html>
判定逻辑:那个红色方块写的是 top: 0; left: 0。如果它在**(0, 0)(视口左上角),说明 fixed 正常;如果它跑到了祖先的位置**(约 200, 100 起),说明祖先成了包含块。
想拿本文表格里的精确数字,用无头模式跑:
# 需要 Chrome;把结果打到 stdout,比肉眼读数更省事
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless --disable-gpu --no-sandbox --window-size=1200,2200 \
--virtual-time-budget=6000 --dump-dom "file://$PWD/probe.html"
验证 Teleport 修复效果的片段
把「挪出祖先」这一步单独拎出来,对比前后 rect:
<div class="app-shell"> <!-- 带 filter 的祖先 -->
<div class="modal" id="modal"></div>
</div>
<script>
const reflow = () => document.body.offsetHeight; // 强制同步重排
const rect = () => {
const r = document.getElementById('modal').getBoundingClientRect();
return `(${Math.round(r.left)},${Math.round(r.top)}) ${Math.round(r.width)}x${Math.round(r.height)}`;
};
console.log('视口 :', innerWidth + 'x' + innerHeight);
console.log('① 在祖先内:', rect()); // 会被祖先的盒子约束
document.body.appendChild(document.getElementById('modal')); // 等价于 Teleport
reflow();
console.log('② 挪到body:', rect()); // 铺满视口
</script>
⚠️ 两个复盘时踩过的坑,你复现时也会遇到:
- 别用
requestAnimationFrame做测量。在无头--dump-dom下 rAF 链会被虚拟时间预算截断,结果时有时无——我第一次跑就吃了这个亏。直接getBoundingClientRect()(它本身会强制重排)结果才是确定的。- 判断「是否铺满视口」时必须读
innerWidth/innerHeight,不能拿--window-size的值当视口。--window-size=1200,900里的 900 含窗口装饰,真实视口高度是 813。我一开始拿 900 去比,把正确的 813 误判成了失败。
参考
- 规范:Filter Effects Level 1 §filter|CSS Transforms Level 1 §包含块|CSS Containment Level 2 §containment-paint|CSS Will Change Level 1
- Vue:Teleport 官方文档
- 实测复现代码:见本文附录「自己复现的方法」
本站相关:
- Vue 3 迁移深水区(本系列另一篇踩坑记录)
- 用 D2 画流程图
声明:本文无厂商合作。所有实测数据来自本机 Chrome(无头模式,
--dump-dom读取 DOM),脚本可复跑。规范条文为原文引用并附出处;「包含块」在规范中的正式名称是 containing block,本文为可读性统一写作「包含块」。
