Vue 3 组件边界的十三个细节:插槽作用域、Teleport 与属性继承

作者:忆笙智云官方 | 发布时间:2026-11-03 06:07 | 更新时间:2026-11-03 06:07

组件边界上的三种错位

写 Vue 时间长了会发现,组件用起来出问题,很少是因为 API 不会用,而是因为脑子里那张图和实际发生的事情不是一回事。

具体有三种错位。

作用域错位:你以为插槽里的代码属于子组件,其实它编译在父组件里。

DOM 与组件树错位:你以为 Teleport 把内容搬走了,逻辑关系也跟着搬走了,其实只搬了 DOM。

继承错位:你以为传下去的 class 一定会出现在某个地方,其实它可能压根没地方可去。

这三种错位不会报错,只会让行为看起来莫名其妙。下面十三个细节,基本都从这三个源头长出来。

一、插槽内容属于父组件,不属于子组件

这一点官方文档放在"渲染作用域"一节,措辞很明确:插槽内容可以访问父组件的数据,因为插槽内容本身是在父组件模板中定义的;而它无法访问子组件的数据,因为 Vue 模板里的表达式只能访问其定义时所处的作用域——跟 JavaScript 的词法作用域是一个道理。

<!-- 父组件 -->
<FancyButton>{{ message }}</FancyButton>

这里的 {{ message }} 用的是父组件的 message,哪怕它最终渲染在 FancyButton 内部。子组件里的同名变量跟它没有任何关系。

很多人的第一反应是"那我在插槽里怎么用子组件的状态",答案就是第二点。

二、作用域插槽的本质是一个函数

官方给的类比很到位:作用域插槽相当于传给子组件的一个函数,子组件在渲染时调用它,并把数据作为参数传进来。

<!-- 子组件 -->
<slot name="item" v-bind="item" />
<!-- 父组件 -->
<FancyList :api-url="url">
  <template #item="{ body, username, likes }">
    <div>{{ body }} — {{ username }}</div>
  </template>
</FancyList>

编译之后,父组件传给子组件的是 { item: ({ body, username, likes }) => vnode } 这样的函数。子组件 v-for 到第几条,就调用这个函数几次,每次传不同的参数。

这个模型解释了三件事。

第一,为什么 v-slot 支持解构。 因为它就是个函数参数,函数参数当然能解构。

第二,为什么插槽内容无法直接拿到子组件的 ref 或响应式状态。 函数是在父组件作用域里定义的,它能捕获的只有父组件作用域的变量。子组件的数据只能通过参数显式传出去。

第三,为什么作用域插槽是"把渲染控制权交出去"的标准手段。 FancyList 负责取数、分页、滚动加载,但"每一项长什么样"由父组件决定。数据在子组件,模板在父组件,中间靠这个函数参数打通。

顺带说一个实际影响:因为插槽是函数,父组件的更新会重新生成插槽函数。如果一个列表组件内部有大量插槽内容,而父组件更新频繁,这部分开销是真实存在的。但这是设计使然,不是 bug,绝大多数场景不需要优化。

三、Teleport 只搬 DOM,不搬逻辑

这是 Teleport 最核心的语义,官方写得很干脆:它只改变了渲染的 DOM 结构,不影响组件间的逻辑关系。如果 Teleport 里包了一个组件,那个组件始终和使用了 Teleport 的组件保持逻辑父子关系。

所以这三件事完全正常:

原因很直接:组件树的父子关系是在渲染时建立的,跟最终渲染到哪个 DOM 节点没关系。Teleport 只是把生成的 DOM 元素挂到了别的地方。

这也意味着一个常见写法是安全的:模态框组件里用 Teleport 把内容送到 body,模态框内部的表单、按钮、校验逻辑该工作都工作,inject 拿到的全局配置也不会丢。

不过样式这块有个坑要说清楚。<style scoped> 的样式在 Teleport 之后大部分还能生效,因为 scoped 编译出来的是属性选择器(.modal[data-v-abc123]),元素被搬走时 data-v 属性也跟着走了,不依赖 DOM 层级。

但依赖层级关系的选择器会失效:

/* 单类选择器,Teleport 之后仍然生效 */
.modal { position: fixed; }

/* 后代选择器,Teleport 之后失效 */
.dialog-wrapper .modal { ... }

第二条编译成 .dialog-wrapper[data-v-abc] .modal[data-v-abc],要求 .dialog-wrapper 是 .modal 的真实 DOM 祖先。Teleport 之后 .modal 跑到 body 下了,祖先链上根本没有 .dialog-wrapper,选择器匹配不上。

所以封装带 Teleport 的弹窗时,样式尽量写在弹窗组件自己的根元素上,别依赖外层的包裹结构。这一点在设计弹窗组件的 props 时就要考虑进去。

四、但 DOM 事件仍然按真实 DOM 冒泡

这一条要特别说清楚,因为网上不少文章写成了"Teleport 之后事件还是按组件树冒泡",这是错的。

分开看两件事:

组件自定义事件(emit):走组件树,跟 DOM 位置无关。这一条官方说的"事件照常工作"指的就是它。

原生 DOM 事件(click、mousedown 等):走真实的 DOM 树。Teleport 把元素挂到了 body 下,它就在 body 的子树里,点击事件的冒泡路径是 body → ...,完全绕过了原组件所在位置的那些 DOM 祖先。

这个区别在实际项目里有直接后果。

后果一:事件委托会失效。

<!-- 假设在一个 div 里做了事件委托,监听所有按钮点击 -->
<div @click="onAnyClick">
  <MyModal />   <!-- 内部用 Teleport 到 body -->
</div>

模态框里的按钮被点击时,onAnyClick 不会触发。因为 DOM 上那个按钮根本不在这个 div 里。如果这段逻辑依赖"点击容器内任意元素都记录一次埋点",模态框里的操作就漏了。

后果二:外面套 @click.stop 拦不住。

<div @click.stop="...">
  <MyModal />
</div>

模态框里的点击不会被这个 .stop 拦住,同样因为冒泡路径不经过它。

后果三:点击外部关闭反而变简单了。

<div @click="open = false">
  <button @click.stop="open = true">打开</button>
  <Teleport to="body">
    <div v-if="open" class="modal" @click.stop>...</div>
  </Teleport>
</div>

如果模态框没有 Teleport,这段代码会因为冒泡出问题——点模态框内部的空白处,冒泡到外层 div 把 open 关了。加上 Teleport 之后,模态框已经不在这个 div 的 DOM 子树里,外层压根收不到事件,@click.stop 都成了可有可无的保险。

所以遇到"Teleport 之后事件监听怪怪的",先问一句:这个监听是 DOM 事件还是组件 emit。答案不同,排查方向完全相反。

五、Teleport 的目标必须在挂载时就存在

官方有个明确的限制:to 指向的目标元素,在 Teleport 挂载时必须已经存在于 DOM 中。

<Teleport to="#container">
  <div>内容</div>
</Teleport>

<div id="container"></div>   <!-- 报错:目标还不存在 -->

这段会报错,因为按模板顺序,Teleport 先挂载,那时候 #container 还没渲染出来。

Vue 3.5 加了 defer 来解决这个顺序问题:

<Teleport defer to="#container">
  <div>内容</div>
</Teleport>

<div id="container"></div>   <!-- 现在可以了 -->

defer 的作用是把目标解析推迟到当前渲染周期结束,所以同一轮里后面渲染出来的元素也能当目标。但有个边界要说清楚:目标必须在同一个挂载/更新周期内出现。如果那个 div 是一秒后异步渲染出来的,defer 也救不了,照样报错。

这也是为什么大多数 Teleport 的目标选 body——它一定在,永远不存在时序问题。

六、多根节点组件不会自动继承 attrs

单根节点组件,父组件传的 class、style、id、v-on 监听器会自动加到根元素上。这条大家都熟。

但只要模板有多个根节点,这个自动行为就没有了。而且不是"静默失效",是直接抛运行时警告。

<!-- CustomLayout 的模板 -->
<header>...</header>
<main>...</main>
<footer>...</footer>
<CustomLayout id="layout" @click="onClick" />

控制台会警告:组件有多个根节点,无法自动继承 attrs。因为框架确实不知道该往哪个节点上放。

修法有两种,取决于你想要什么行为。

如果 attrs 应该落在某个特定节点上,显式绑定:

<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

如果压根不需要透传(比如布局组件的结构是封闭的),声明一下让它别管:

defineOptions({ inheritAttrs: false })

defineOptions 是 3.3+ 的宏,之前得单独开一个 <script> 块写 export default { inheritAttrs: false }。

这里有个容易忽略的连带影响:多根节点组件用 ref 拿到的不是一个元素,而是一个数组或者说不确定的东西。想操作"根元素"的代码,在这类组件上得改成显式 ref 到具体节点。

七、$attrs 不是响应式的

这条官方专门标注了,但很容易漏读:attrs 对象总是反映最新的透传 attribute,但它不是响应式的,出于性能考虑。不能用侦听器去监听它的变化。

const attrs = useAttrs()

watch(() => attrs.class, () => {})   // 不会触发

为什么不做成响应式?因为 attrs 的变化频率可能很高(父组件每次渲染都可能变),而它通常只在渲染时被读取一次。做成响应式意味着要为每个组件的 attrs 建立依赖追踪,成本不划算。

替代方案官方也给了:需要响应性就用 prop;或者用 onUpdated,在每次更新后基于最新的 attrs 执行副作用。

onUpdated(() => {
  console.log(attrs.class)   // 这里拿到的是最新的
})

这个限制在封装第三方组件时最容易踩:你写了个包装组件,想把父组件传来的额外属性原样转下去,同时根据某个属性值做点判断。用 watch 监听 attrs 不会工作,得改用 onUpdated,或者干脆把那个属性声明成 prop。

八、$attrs 保留原始大小写,事件变成函数

两个小细节,不知道就会写出 undefined。

大小写:和 props 不同,attrs 在 JavaScript 里保留原始的 attribute 名字大小写。所以模板里写 foo-bar="x",取值要用:

attrs['foo-bar']    // 对
attrs.fooBar        // undefined

props 会自动做 kebab-case 到 camelCase 的转换,attrs 不会。因为 attrs 的定位是"未经声明的原始属性",保留原样更符合它的语义。

事件形态:@click 这样的监听器在 $attrs 里是一个函数,键名是 onClick:

attrs.onClick       // 父组件传的 @click 处理函数

所以"把所有 attrs 透传到内部元素"的经典写法才成立:

<button v-bind="$attrs">确定</button>

v-bind 不带参数时,会把对象的每个 key 当作 attribute 应用上去。onClick 这种以 on 开头的 key 会被识别成事件监听,class 会被合并,id 会被设置。一套规则全打通,不用手动分类。

九、KeepAlive 按 name 匹配,不是按组件引用

include / exclude 匹配的是组件的 name 选项,不是组件对象,也不是文件路径。

在 Options API 时代这不是问题,export default { name: 'UserList' } 写着就有。在 <script setup> 时代,绝大多数组件根本没写 name,于是:

<KeepAlive include="UserList">
  <component :is="current" />
</KeepAlive>

如果 UserList.vue 里没声明 name,这个 include 就匹配不上,缓存不生效,切回来状态全丢。

官方给了一个好消息:3.2.34 及以上版本,<script setup> 的单文件组件会自动根据文件名生成 name。所以 UserList.vue 天然就叫 UserList,不用手动声明。

但这条规则有两个边界,踩上就不生效。

边界一:文件名和你想的不一样。 user-list.vue 推断出来的 name 是 user-list,不是 UserList。而 keep-alive 的 include 匹配是精确匹配(字符串、正则、数组三种形式),写 include="UserList" 匹配不到 user-list。团队里文件名风格不统一的时候,这个问题会出现得很随机。

边界二:动态组件用变量绑定时,容易误以为 name 是变量名。

<component :is="current" />

这里 current 是变量名,不是组件 name。缓存匹配看的是 current 指向的那个组件的 name。想看当前组件到底叫什么,打开 Devtools 是最快的。

稳妥的做法是在需要缓存的组件里显式声明:

defineOptions({ name: 'UserList' })

一句代码,把"靠推断"变成"确定的",也顺便让递归组件、Devtools 显示都更清晰。

十、onActivated 在首次挂载时也会调用

这条如果不知道,很容易写出重复请求。

直觉上 onActivated 是"从缓存里回来时"调用的钩子。但官方明确说了:它在组件挂载时也会调用,onDeactivated 在组件卸载时也会调用。

onActivated(() => {
  // 首次挂载 → 调用
  // 每次从缓存重新插入 → 调用
  fetchList()
})

如果组件里 onMounted 也调了一次 fetchList(),那首次进入页面就会发两次请求。表现是 Network 面板里成对出现,接口有幂等问题的话看不出异常,没有的话就是重复插入数据。

正确的用法是二选一:要么把初始化逻辑全放 onActivated(首次挂载会触发),要么在 onActivated 里判断是不是第一次。

还有一个官方强调过的点:这两个钩子不仅对缓存树的根组件生效,也适用于缓存树中的后代组件。也就是说,一个页面组件被缓存,它内部的所有子组件都会收到 activated/deactivated。如果你在深层子组件里用了这两个钩子做定时器启停,要知道它们会被连带触发,而不是只有页面组件收到。

十一、max 是 LRU,被淘汰就是真销毁

max 限制缓存实例数,行为类似 LRU:超过上限时,最久没被访问的实例被销毁。

关键在于"销毁"这两个字。被淘汰的实例不是"藏起来了",而是走了完整的卸载流程:onDeactivated 触发,然后 onUnmounted 触发,状态清空。下次再进去,是一个全新的实例,之前填的表单、滚动位置、内部状态全没了。

这跟"缓存满了就少缓存几个"的直觉不一样。如果你的页面缓存里塞了十个重组件,max 设了 5,用户来回切的时候会莫名其妙发现"我明明没关这个页面,怎么数据重置了"。

两个应对方式:

调大 max,或者不设。 不设就是不限制,代价是内存。企业后台系统的标签页场景,通常页面数量可控(十几个),不设上限是可以接受的。

给缓存加上"手动清理"的能力。 比如标签页关闭时,从 include 列表里移除对应 name,让 KeepAlive 主动丢掉那个实例。这是后台系统里比 max 更可控的做法。

const cachedViews = ref(['UserList', 'OrderList'])

function closeTab(name) {
  cachedViews.value = cachedViews.value.filter(n => n !== name)
}
<KeepAlive :include="cachedViews">
  <component :is="current" />
</KeepAlive>

这样缓存的生命周期就跟标签页绑定了,用户关掉标签页=丢掉缓存,符合直觉。

十二、script setup 组件默认是"关闭"的

这条坑过的人很多:父组件通过 ref 拿到子组件实例,想调子组件的方法,结果发现那个对象上什么都没有。

<ChildForm ref="formRef" />
const formRef = ref(null)
onMounted(() => {
  formRef.value.validate()   // TypeError: validate is not a function
})

原因官方写得很清楚:使用 <script setup> 的组件是默认关闭的,通过模板引用或者 $parent 链拿到的公开实例,不会暴露任何在 <script setup> 中声明的绑定。

这是有意的封装,不是疏漏。Options API 时代所有 methods 都挂在 this 上,父组件想调什么都能调到,代价是组件内部实现被外部随意依赖,重构时寸步难行。<script setup> 把默认值改成了"不暴露",要暴露得显式声明:

const validate = () => { /* ... */ }
const reset = () => { /* ... */ }

defineExpose({ validate, reset })

两点补充。

暴露出去的对象里,ref 会自动解包。 父组件拿到的 formRef.value.count 是数字,不是 ref 对象,跟普通实例属性一样。

别为了省事把整个内部状态都 expose 出去。 一旦暴露,父组件就可能依赖它,之后你改内部结构就是破坏性变更。合理的做法是只暴露"动作"(validate、reset、focus),不暴露"状态"。

如果只是要拿 DOM 元素,不需要 expose——ref 打在原生元素上拿到的就是元素本身,只有打在组件上才会遇到这层封装。

十三、动态组件的销毁重建,以及 :key 的作用

<component :is="current"> 切到另一个组件时,旧组件会走完整的卸载流程,新组件从头挂载。这是默认行为,很多人以为它会"复用"。

真正需要留意的是反过来两种情况。

情况一:同一个组件,想让它在数据变化时彻底重置。

比如一个表单组件,弹窗里复用,每次打开要清空内部状态。如果只是把绑定的数据重置,组件内部自己维护的状态(比如"哪个字段被 touch 过"、内部草稿)不会清。加个 key 强制重建:

<OrderForm :key="formKey" :order-id="currentId" />
function openForm(id) {
  currentId.value = id
  formKey.value++       // key 变了,组件销毁重建,内部状态全部归零
}

key 变化等于告诉框架"这是一个新组件",而不是"同一个组件换了 props"。这招在"弹窗复用同一个表单组件"的场景里特别省事,比在子组件里写一堆 watch 重置逻辑干净。

情况二:切换时不想重建。

那就是上一节说的 KeepAlive 场景。<component :is> 外面包一层 <KeepAlive>,切走时不卸载,只进缓存。

这两者的选择标准很简单:切回来时期望看到旧状态,就用 KeepAlive;期望看到干净状态,就用 :key 强制重建。 前者是"标签页",后者是"弹窗"。

还有个小细节:v-if 和 :is 在销毁语义上是一致的,v-if 从 true 变 false 也会卸载组件、清空状态。区别只在于 :is 是在多个组件之间切换,v-if 是在"有"和"没有"之间切换。所以"用 v-if 控制弹窗,每次打开状态都是新的"这个常见现象,本质就是卸载重建,不是 bug。

三条判断规则

这十三个细节如果只记三条,记这三条就够应付大部分情况。

第一,看到插槽里的变量拿不到,先问作用域。 插槽内容在父组件编译,子组件的数据只能通过作用域插槽往下传,没有第二条路。

第二,看到 Teleport 之后事件行为变了,先分清是 DOM 事件还是组件事件。 前者按真实 DOM 树走,后者按组件树走。这个区分能省掉大量瞎猜。

第三,看到 attrs 或 keep-alive 行为不符合预期,先确认"名字"和"根节点数"。 attrs 的多根节点警告、keep-alive 的 name 匹配,本质上都是"框架无法确定该往哪放",需要你显式告诉它。

组件边界的坑大多不属于"API 用错",而属于"模型不对"。模型对了,这些行为其实都是合理设计的自然结果,甚至会觉得很巧妙——比如 Teleport 能在不破坏组件逻辑的前提下解决 CSS 层级问题,就是靠"逻辑关系和 DOM 位置分离"这个设计。