Vue 3 插槽机制深度解析:两条线与三层树
1. 一个反直觉的现象
xml
<template>
<SlotProvider>
<ChildInSlot ref="childRef" />
</SlotProvider>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref(null)
onMounted(() => {
console.log(childRef.value) // 竟然能拿到 ChildInSlot 的实例?
})
</script>
ChildInSlot 最终挂在 SlotProvider 内部渲染出来的 DOM 里,但 childRef 却能在当前组件里拿到。更奇怪的是,以下三件事同时成立:
childRef在当前组件能拿到 → 当前组件对ChildInSlot有直接联系;ChildInSlot能inject到SlotProvider的数据 → 它和SlotProvider也有直接联系;- 插槽内容能访问当前组件的响应式变量 → 插槽内容和当前组件还有直接联系。
同一个组件,怎么同时跟两个组件都有关系?答案藏在插槽的本质里。
2. 核心模型:三层树 + 两条线
2.1 运行时组件树
markdown
ParentComponent(祖父)
└── SlotProvider(父)
└── ChildInSlot(孙)
这三层是严格的祖父-父-孙结构,是所有讨论的地基。
2.2 插槽编译后是什么?
scss
// ParentComponent 模板编译产物
h(SlotProvider, null, {
default: () => h(ChildInSlot, { ref: childRef })
})
插槽就是一个延迟执行的回调函数。 SlotProvider 渲染 <slot /> 时调用它,拿到 VNode 并挂载。
因为 SlotProvider 调用了插槽函数,所以 ChildInSlot 的组件实例父级就是 SlotProvider。这就是三层树的由来。
2.3 两条线:一切困惑的根源
除了组件树这条"血缘线",还存在一条闭包作用域线 ------插槽函数的代码写在 ParentComponent 的渲染函数里,闭包捕获的是 ParentComponent 的变量。
| 组件树线 | 闭包线 | |
|---|---|---|
| 路径 | 祖父 → 父 → 孙 | 祖父 →(跳过父)→ 插槽内容代码 |
| 由什么决定 | 谁调用插槽函数 | 插槽函数在哪里定义 |
| 掌控什么 | provide/inject、生命周期、$parent |
变量访问、ref 归属 |
| 类比 | "户口落在谁家" | "代码写在谁家" |
心法:谁调用回调,谁就是父;谁定义回调,谁掌控 ref 和数据中转。
记住这张表,后面所有现象都是它的推论。
3. 四条数据流路径
基于两条线模型,三层之间的数据流只有四种路径。
① 父 → 孙:作用域插槽 + props(经过祖父)
SlotProvider 不知道插槽里是什么,只能把数据当参数传给插槽函数,由祖父转交给孙:
xml
SlotProvider --传参--> 祖父(插槽函数) --props--> ChildInSlot
<!-- SlotProvider.vue -->
<template>
<slot :user="user" :update-user="updateUser" />
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '张三', age: 30 })
function updateUser(key, value) { user[key] = value }
</script>
<!-- ParentComponent.vue -->
<SlotProvider #default="{ user, updateUser }">
<ChildInSlot :user="user" :update-user="updateUser" />
</SlotProvider>
为什么必须经过祖父? 因为 ChildInSlot 的 VNode 是祖父创建的,只有祖父能给它传 props。
② 父 → 孙:provide/inject(跳过祖父)
沿组件树线直接传递,祖父完全不知情:
scss
SlotProvider --provide--> ChildInSlot(inject)
适用于主题、配置等全局性上下文数据。缺点是数据来源不透明,调试困难。
③ 孙 → 父:回调函数(经过祖父)
和路径①对称。updateUser 从父传到祖父再传到孙,执行时修改的仍是父的数据:
rust
ChildInSlot --调用props中的函数--> 祖父 --> SlotProvider(原始定义处)
代码见路径①的完整示例,ChildInSlot 内部这样使用:
xml
<!-- ChildInSlot.vue -->
<template>
<input :value="user.name" @input="updateUser('name', $event.target.value)" />
</template>
<script setup>
defineProps({ user: Object, updateUser: Function })
</script>
④ 祖父 → 孙:ref(跳过父)
css
ParentComponent --childRef.value--> ChildInSlot
为什么 ref 不归父? 因为 ref 属于 VNode 的创建上下文 ,而非挂载位置。插槽函数虽然被父调用,但定义在祖父的渲染函数中,VNode 创建时记录的上下文是祖父(对应源码中的 currentRenderingInstance)。Vue 按"收货地址"把实例送给祖父。
如果 ref 跟随挂载位置,你在模板里写了
ref="childRef"却拿不到值------这显然违背直觉。
路径速查表
| 流向 | 机制 | 是否经过祖父 | 走哪条线 |
|---|---|---|---|
| 父 → 孙 | 作用域插槽 + props | ✅ | 闭包线 + 组件树线 |
| 父 → 孙 | provide/inject | ❌ | 纯组件树线 |
| 孙 → 父 | 回调函数 | ✅ | 闭包线 + 组件树线 |
| 祖父 → 孙 | ref | ❌ | 纯闭包线 |
规律:走 props/回调必经祖父中转;走组件树或纯闭包则跳过中间层。
4. 命令式调用:当插槽脱离模板(选读)
如果你从未在 JS 中动态创建过带插槽的组件,可以安全跳过本节。核心模型不受影响。
前面所有讨论都基于模板,编译器帮你把 <slot> 语法转成了回调函数。但在命令式场景中,没有模板、没有编译器,你需要手动写编译器生成的东西。
其实就一件事:手写那个回调函数。
回顾第 2.2 节中模板编译后的代码:
scss
// 模板写法(编译器生成)
h(SlotProvider, null, {
default: () => h(ChildInSlot, { ref: childRef })
})
命令式场景下,你只需要自己写出完全相同的结构:
csharp
// 命令式写法(你自己写)
const childRef = ref(null)
h(SlotProvider, null, {
default: (slotProps) => h(ChildInSlot, {
ref: childRef, // 定义在当前作用域 → ref 归这里
user: slotProps.user // 和作用域插槽完全一致
})
})
两段代码结构一模一样。唯一的区别是:上面那段是编译器替你写的,下面这段是你自己写的。
5. 总结
| 问题 | 答案 |
|---|---|
| 插槽是什么? | 延迟执行的回调函数 |
| 谁是组件树父级? | 调用插槽函数的组件 |
| ref 归谁? | 定义插槽函数的组件(VNode 创建上下文) |
| 数据怎么走? | 四条路径,取决于走组件树线还是闭包线 |
插槽就是回调函数。谁调用,谁是父;谁定义,谁掌控 ref 和中转。两条线分清,一切迎刃而解。