跳到主要内容

Modal 的 position:fixed 为什么会失效:一次 filter 引发的包含块事故,与 Vue 3 Teleport

· 阅读需 16 分钟

需求很简单:给页面加一个 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)✅ 正常

三条值得记住的结论:

  1. 触发条件是「值 ≠ none」,不是「效果明显」。blur(0px) 和 translateX(0) 都是视觉上的空操作,但都会触发。很多项目里的 filter: blur(0)、transform: translate(0) 是"先写着占位"的产物——它们就是隐形炸弹。
  2. will-change 是重灾区。为了"性能优化"写上的 will-change: transform,会提前把包含块建立起来——即使元素当前根本没有 transform。规范里这是明确要求的(见下节)。
  3. opacity 不触发,这是个好的反例:它确实会创建层叠上下文(影响 z-index),但不创建包含块。这两件事经常被混为一谈,实测证明它们不是一回事。

复现代码见文末附录,换台机器跑一下就能验证。

四、规范依据:四条原文​

这不是浏览器的实现怪癖,是规范写死的。四条相关条文:

① filter(Filter Effects Level 1):

A value other than none for the filter property 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 transform property 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-change must 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:

  1. position: fixed 失效不是玄学——它的参照系是「最近的、创建了包含块的祖先」,没有这样的祖先时才是视口;
  2. filter / transform / perspective / backdrop-filter / contain: paint|layout|strict / will-change(对应属性) 都会让祖先成为包含块,判定条件是「值 ≠ none」,不是视觉效果强弱;
  3. opacity 不触发(虽然它创建层叠上下文)——这两件事别混;
  4. Vue 3 的 <Teleport> 是这类问题的语言级解法,它把 DOM 挪出祖先,从根上避免包含块被改写;
  5. 但 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>

⚠️ 两个复盘时踩过的坑,你复现时也会遇到:

  1. 别用 requestAnimationFrame 做测量。在无头 --dump-dom 下 rAF 链会被虚拟时间预算截断,结果时有时无——我第一次跑就吃了这个亏。直接 getBoundingClientRect()(它本身会强制重排)结果才是确定的。
  2. 判断「是否铺满视口」时必须读 innerWidth/innerHeight,不能拿 --window-size 的值当视口。--window-size=1200,900 里的 900 含窗口装饰,真实视口高度是 813。我一开始拿 900 去比,把正确的 813 误判成了失败。

参考​

本站相关:

声明:本文无厂商合作。所有实测数据来自本机 Chrome(无头模式,--dump-dom 读取 DOM),脚本可复跑。规范条文为原文引用并附出处;「包含块」在规范中的正式名称是 containing block,本文为可读性统一写作「包含块」。