Vue 3 插槽机制深度解析:两条线与三层树

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 却能在当前组件里拿到。更奇怪的是,以下三件事同时成立:

  1. childRef 在当前组件能拿到 → 当前组件对 ChildInSlot 有直接联系;
  2. ChildInSlot 能 inject 到 SlotProvider 的数据 → 它和 SlotProvider 也有直接联系;
  3. 插槽内容能访问当前组件的响应式变量 → 插槽内容和当前组件还有直接联系。

同一个组件,怎么同时跟两个组件都有关系?答案藏在插槽的本质里。


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 和中转。两条线分清,一切迎刃而解。

相关推荐
程序员大阳5 小时前
副队长JavaScript教程(49)-主动抛出异常与错误类型
javascript·异常处理·error·throw·错误类型
hhhjhl5 小时前
Protobuf.js 跨端三方库仓颉适配全流程
javascript·网络·udp
康小汪5 小时前
AI 写 Vue 总写成同一个味道:给它装 Skills,装的不是知识是边界
前端·vue.js·人工智能·ai编程·最佳实践
智能运维指南6 小时前
研制过程“两张皮“怎么破:研制一体化支撑平台的定位、能力边界与落地前提
java·前端·人工智能·嘉为蓝鲸
白山编程大哥7 小时前
Java 后端如何优雅地把 List 集合返回给前端
java·前端·list
Flynt8 小时前
我把热榜那篇"禁用 axios"逐条实测了一遍:结论大体对,坑在它没讲的地方
前端·javascript·axios
Csvn9 小时前
bundle优化
前端
slongzhang_9 小时前
linux 递归删除源码文件|递归删除目录下 *.js,但排除 *.min.js
linux·开发语言·javascript
北冥有鱼9 小时前
发版后动态路由点击报 `Failed to fetch dynamically imported module`」问题上的完整排查与修复过程
前端·vue.js
静默回滚9 小时前
视频压到 25 MB 以内,码率要算、音轨要扣、余量要留
前端