Vue.js 3.0 核心源码解析 - 前百度、滴滴资深技术专家 - 拉勾教育

前面一节课我们学习了 Props,使用它我们可以让组件支持不同的配置来实现不同的功能。

不过,有些时候我们希望子组件模板中的部分内容可以定制化,这个时候使用 Props 就显得不够灵活和易用了。因此,Vue.js 受到 Web Component 草案的启发,通过插槽的方式实现内容分发,它允许我们在父组件中编写 DOM 并在子组件渲染时把 DOM 添加到子组件的插槽中,使用起来非常方便。

在分析插槽的实现前,我们先来简单回顾一下插槽的使用方法。

插槽的用法

举个简单的例子,假设我们有一个 TodoButton 子组件:

  1. <button class="todo-button">
  2. <slot></slot>
  3. </button>

然后我们在父组件中可以这么使用 TodoButton 组件:

  1. <todo-button>
  2. <!-- 添加一个字体图标 -->
  3. <i class="icon icon-plus"></i>
  4. Add todo
  5. </todo-button>

其实就是在 todo-button 的标签内部去编写插槽中的 DOM 内容,最终 TodoButton 组件渲染的 HTML 是这样的:

  1. <button class="todo-button">
  2. <!-- 添加一个字体图标 -->
  3. <i class="icon icon-plus"></i>
  4. Add todo
  5. </button>

这个例子就是最简单的普通插槽的用法,有时候我们希望子组件可以有多个插槽,再举个例子,假设我们有一个布局组件 Layout,定义如下:

  1. <div class="layout">
  2. <header>
  3. <slot name="header"></slot>
  4. </header>
  5. <main>
  6. <slot></slot>
  7. </main>
  8. <footer>
  9. <slot name="footer"></slot>
  10. </footer>
  11. </div>

我们在 Layout 组件中定义了多个插槽,并且其中两个插槽标签还添加了 name 属性(没有设置 name 属性则默认 name 是 default),然后我们在父组件中可以这么使用 Layout 组件:

  1. <template>
  2. <layout>
  3. <template v-slot:header>
  4. <h1>{{ header }}</h1>
  5. </template>
  6. <template v-slot:default>
  7. <p>{{ main }}</p>
  8. </template>
  9. <template v-slot:footer>
  10. <p>{{ footer }}</p>
  11. </template>
  12. </layout>
  13. </template>
  14. <script>
  15. export default {
  16. data (){
  17. return {
  18. header: 'Here might be a page title',
  19. main: 'A paragraph for the main content.',
  20. footer: 'Here\'s some contact info'
  21. }
  22. }
  23. }
  24. </script>

这里使用 template 以及 v-slot 指令去把内部的 DOM 分发到子组件对应的插槽中,最终 Layout 组件渲染的 HTML 如下:

  1. <div class="layout">
  2. <header>
  3. <h1>Here might be a page title</h1>
  4. </header>
  5. <main>
  6. <p>A paragraph for the main content.</p>
  7. </main>
  8. <footer>
  9. <p>Here's some contact info</p>
  10. </footer>
  11. </div>

这个例子就是命名插槽的用法,它实现了在一个组件中定义多个插槽的需求。另外我们需要注意,父组件在插槽中引入的数据,它的作用域是父组件的。

不过有些时候,我们希望父组件填充插槽内容的时候,使用子组件的一些数据,为了实现这个需求,Vue.js 提供了作用域插槽。

举个例子,我们有这样一个 TodoList 子组件:

  1. <template>
  2. <ul>
  3. <li v-for="(item, index) in items">
  4. <slot :item="item"></slot>
  5. </li>
  6. </ul>
  7. </template>
  8. <script>
  9. export default {
  10. data() {
  11. return {
  12. items: ['Feed a cat', 'Buy milk']
  13. }
  14. }
  15. }
  16. </script>

注意,这里我们给 slot 标签加上了 item 属性,目的就是传递子组件中的 item 数据,然后我们可以在父组件中这么去使用 TodoList 组件:

  1. <todo-list>
  2. <template v-slot:default="slotProps">
  3. <i class="icon icon-check"></i>
  4. <span class="green">{{ slotProps.item }}<span>
  5. </template>
  6. </todo-list>

注意,这里的 v-slot 指令的值为 slotProps,它是一个对象,它的值包含了子组件往 slot 标签中添加的 props,在我们这个例子中,v-slot 就包含了 item 属性,然后我们就可以在内部使用这个 slotProps.item 了,最终 TodoList 子组件渲染的 HTML 如下:

  1. <ul>
  2. <li v-for="(item, index) in items">
  3. <i class="icon icon-check"></i>
  4. <span class="green">{{ item }}<span>
  5. </li>
  6. </ul>

上述例子就是作用域插槽的用法,它实现了在父组件填写子组件插槽内容的时候,可以使用子组件传递数据的需求。

这些就是插槽的一些常见使用方式,那么接下来,我们就来探究一下插槽背后的实现原理吧!

插槽的实现

在分析具体的代码前,我们先来想一下插槽的特点,其实就是在父组件中去编写子组件插槽部分的模板,然后在子组件渲染的时候,把这部分模板内容填充到子组件的插槽中。

所以在父组件渲染阶段,子组件插槽部分的 DOM 是不能渲染的,需要通过某种方式保留下来,等到子组件渲染的时候再渲染。顺着这个思路,我们来分析具体实现的代码。

我们还是通过例子的方式来分析插槽实现的整个流程,首先来看父组件模板:

  1. <layout>
  2. <template v-slot:header>
  3. <h1>{{ header }}</h1>
  4. </template>
  5. <template v-slot:default>
  6. <p>{{ main }}</p>
  7. </template>
  8. <template v-slot:footer>
  9. <p>{{ footer }}</p>
  10. </template>
  11. </layout>

这里你可以借助模板编译工具看一下它编译后的 render 函数:

  1. import { toDisplayString as _toDisplayString, createVNode as _createVNode, resolveComponent as _resolveComponent, withCtx as _withCtx, openBlock as _openBlock, createBlock as _createBlock } from "vue"
  2. export function render(_ctx, _cache, $props, $setup, $data, $options) {
  3. const _component_layout = _resolveComponent("layout")
  4. return (_openBlock(), _createBlock(_component_layout, null, {
  5. header: _withCtx(() => [
  6. _createVNode("h1", null, _toDisplayString(_ctx.header), 1 )
  7. ]),
  8. default: _withCtx(() => [
  9. _createVNode("p", null, _toDisplayString(_ctx.main), 1 )
  10. ]),
  11. footer: _withCtx(() => [
  12. _createVNode("p", null, _toDisplayString(_ctx.footer), 1 )
  13. ]),
  14. _: 1
  15. }))
  16. }

前面我们学习过 createBlock,它的内部通过执行 createVNode 创建了 vnode,注意 createBlock 函数的第三个参数,它表示创建的 vnode 子节点,在我们这个例子中,它是一个对象。

通常,我们创建 vnode 传入的子节点是一个数组,那么对于对象类型的子节点,它内部做了哪些处理呢?我们来回顾一下 createVNode 的实现:

  1. function createVNode(type,props = null,children = null) {
  2. if (props) {
  3. }
  4. const vnode = {
  5. type,
  6. props
  7. }
  8. normalizeChildren(vnode, children)
  9. return vnode
  10. }

其中,normalizeChildren 就是用来处理传入的参数 children,我们来看一下它的实现:

  1. function normalizeChildren (vnode, children) {
  2. let type = 0
  3. const { shapeFlag } = vnode
  4. if (children == null) {
  5. children = null
  6. }
  7. else if (isArray(children)) {
  8. type = 16
  9. }
  10. else if (typeof children === 'object') {
  11. if ((shapeFlag & 1 || shapeFlag & 64 ) && children.default) {
  12. normalizeChildren(vnode, children.default())
  13. return
  14. }
  15. else {
  16. type = 32
  17. const slotFlag = children._
  18. if (!slotFlag && !(InternalObjectKey in children)) {
  19. children._ctx = currentRenderingInstance
  20. }
  21. else if (slotFlag === 3 && currentRenderingInstance) {
  22. if (currentRenderingInstance.vnode.patchFlag & 1024 ) {
  23. children._ = 2
  24. vnode.patchFlag |= 1024
  25. }
  26. else {
  27. children._ = 1
  28. }
  29. }
  30. }
  31. }
  32. else if (isFunction(children)) {
  33. children = { default: children, _ctx: currentRenderingInstance }
  34. type = 32
  35. }
  36. else {
  37. children = String(children)
  38. if (shapeFlag & 64 ) {
  39. type = 16
  40. children = [createTextVNode(children)]
  41. }
  42. else {
  43. type = 8
  44. }
  45. }
  46. vnode.children = children
  47. vnode.shapeFlag |= type
  48. }

normalizeChildren 函数主要的作用就是标准化 children 以及获取 vnode 的节点类型 shapeFlag。

这里,我们重点关注插槽相关的逻辑。经过处理,vnode.children 仍然是传入的对象数据,而 vnode.shapeFlag 会与 slot 子节点类型 SLOTS_CHILDREN 进行运算,由于 vnode 本身的 shapFlag 是 STATEFUL_COMPONENT,所以运算后的 shapeFlag 是 SLOTS_CHILDREN | STATEFUL_COMPONENT。

确定了 shapeFlag,会影响后续的 patch 过程,我们知道在 patch 中会根据 vnode 的 type 和 shapeFlag 来决定后续的执行逻辑,我们来回顾一下它的实现:

  1. const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, optimized = false) => {
  2. if (n1 && !isSameVNodeType(n1, n2)) {
  3. anchor = getNextHostNode(n1)
  4. unmount(n1, parentComponent, parentSuspense, true)
  5. n1 = null
  6. }
  7. const { type, shapeFlag } = n2
  8. switch (type) {
  9. case Text:
  10. break
  11. case Comment:
  12. break
  13. case Static:
  14. break
  15. case Fragment:
  16. break
  17. default:
  18. if (shapeFlag & 1 ) {
  19. }
  20. else if (shapeFlag & 6 ) {
  21. processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
  22. }
  23. else if (shapeFlag & 64 ) {
  24. }
  25. else if (shapeFlag & 128 ) {
  26. }
  27. }
  28. }

这里由于 type 是组件对象,shapeFlag 满足shapeFlag&6的情况,所以会走到 processComponent 的逻辑,递归去渲染子组件。

至此,带有子节点插槽的组件与普通的组件渲染并无区别,还是通过递归的方式去渲染子组件。

渲染子组件又会执行组件的渲染逻辑了,这个流程我们在前面的章节已经分析过,其中有一个 setupComponent 的流程,我们来回顾一下它的实现:

  1. function setupComponent (instance, isSSR = false) {
  2. const { props, children, shapeFlag } = instance.vnode
  3. const isStateful = shapeFlag & 4
  4. initProps(instance, props, isStateful, isSSR)
  5. initSlots(instance, children)
  6. const setupResult = isStateful
  7. ? setupStatefulComponent(instance, isSSR)
  8. : undefined
  9. return setupResult
  10. }

注意,这里的 instance.vnode 就是组件 vnode,我们可以从中拿到子组件的实例、props 和 children 等数据。setupComponent 执行过程中会通过 initSlots 函数去初始化插槽,并传入 instance 和 children,我们来看一下它的实现:

  1. const initSlots = (instance, children) => {
  2. if (instance.vnode.shapeFlag & 32 ) {
  3. const type = children._
  4. if (type) {
  5. instance.slots = children
  6. def(children, '_', type)
  7. }
  8. else {
  9. normalizeObjectSlots(children, (instance.slots = {}))
  10. }
  11. }
  12. else {
  13. instance.slots = {}
  14. if (children) {
  15. normalizeVNodeSlots(instance, children)
  16. }
  17. }
  18. def(instance.slots, InternalObjectKey, 1)
  19. }

initSlots 的实现逻辑很简单,这里的 children 就是前面传入的插槽对象数据,然后我们把它保留到 instance.slots 对象中,后续我们就可以从 instance.slots 拿到插槽的数据了。

到这里,我们在子组件的初始化过程中就拿到由父组件传入的插槽数据了,那么接下来,我们就来分析子组件是如何把这些插槽数据渲染到页面上的吧。

我们先来看子组件的模板:

  1. <div class="layout">
  2. <header>
  3. <slot name="header"></slot>
  4. </header>
  5. <main>
  6. <slot></slot>
  7. </main>
  8. <footer>
  9. <slot name="footer"></slot>
  10. </footer>
  11. </div>

这里你可以借助模板编译工具看一下它编译后的 render 函数:

  1. import { renderSlot as _renderSlot, createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
  2. export function render(_ctx, _cache, $props, $setup, $data, $options) {
  3. return (_openBlock(), _createBlock("div", { class: "layout" }, [
  4. _createVNode("header", null, [
  5. _renderSlot(_ctx.$slots, "header")
  6. ]),
  7. _createVNode("main", null, [
  8. _renderSlot(_ctx.$slots, "default")
  9. ]),
  10. _createVNode("footer", null, [
  11. _renderSlot(_ctx.$slots, "footer")
  12. ])
  13. ]))
  14. }

通过编译后的代码我们可以看出,子组件的插槽部分的 DOM 主要通过 renderSlot 方法渲染生成的,我们来看它的实现:

  1. function renderSlot(slots, name, props = {}, fallback) {
  2. let slot = slots[name];
  3. return (openBlock(),
  4. createBlock(Fragment, { key: props.key }, slot ? slot(props) : fallback ? fallback() : [], slots._ === 1
  5. ? 64
  6. : -2 ));
  7. }

renderSlot 函数的第一个参数 slots 就是 instance.slots,我们在子组件初始化的时候已经获得了这个 slots 对象,第二个参数是 name。

renderSlot 的实现也很简单,首先根据第二个参数 name 获取对应的插槽函数 slot,接着通过 createBlock 创建了 vnode 节点,注意,它的类型是一个 Fragment,children 是执行 slot 插槽函数的返回值。

下面我们来看看 slot 函数长啥样,先看一下示例中的 instance.slots 的值:

  1. {
  2. header: _withCtx(() => [
  3. _createVNode("h1", null, _toDisplayString(_ctx.header), 1 )
  4. ]),
  5. default: _withCtx(() => [
  6. _createVNode("p", null, _toDisplayString(_ctx.main), 1 )
  7. ]),
  8. footer: _withCtx(() => [
  9. _createVNode("p", null, _toDisplayString(_ctx.footer), 1 )
  10. ]),
  11. _: 1
  12. }

那么对于 name 为 header,它的值就是:

  1. _withCtx(() => [
  2. _createVNode("h1", null, _toDisplayString(_ctx.header), 1 )
  3. ])

它是执行 _withCtx 函数后的返回值,我们接着看 withCtx 函数的实现:

  1. function withCtx(fn, ctx = currentRenderingInstance) {
  2. if (!ctx)
  3. return fn
  4. return function renderFnWithContext() {
  5. const owner = currentRenderingInstance
  6. setCurrentRenderingInstance(ctx)
  7. const res = fn.apply(null, arguments)
  8. setCurrentRenderingInstance(owner)
  9. return res
  10. }
  11. }

withCtx 的实现很简单,它支持传入一个函数 fn 和执行的上下文变量 ctx,它的默认值是 currentRenderingInstance,也就是执行 render 函数时的当前组件实例。

withCtx 会返回一个新的函数,这个函数执行的时候,会先保存当前渲染的组件实例 owner,然后把 ctx 设置为当前渲染的组件实例,接着执行 fn,执行完毕后,再把之前的 owner 设置为当前组件实例。

这么做就是为了保证在子组件中渲染具体插槽内容时,它的渲染组件实例是父组件实例,这样也就保证它的数据作用域也是父组件的了。

所以对于 header 这个 slot,它的 slot 函数的返回值是一个数组,如下:

  1. [
  2. _createVNode("h1", null, _toDisplayString(_ctx.header), 1 )
  3. ]

我们回到 renderSlot 函数,最终插槽对应的 vnode 渲染就变成了如下函数:

  1. createBlock(Fragment, { key: props.key }, [_createVNode("h1", null, _toDisplayString(_ctx.header), 1 )], 64 )

我们知道,createBlock 内部是会执行 createVNode 创建 vnode,vnode 创建完后,仍然会通过 patch 把 vnode 挂载到页面上,那么对于插槽的渲染,patch 过程又有什么不同呢?

注意这里我们的 vnode 的 type 是 Fragement,所以在执行 patch 的时候,会执行 processFragment 逻辑,我们来看它的实现:

  1. const processFragment = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  2. const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))
  3. const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))
  4. let { patchFlag } = n2
  5. if (patchFlag > 0) {
  6. optimized = true
  7. }
  8. if (n1 == null) {
  9. hostInsert(fragmentStartAnchor, container, anchor)
  10. hostInsert(fragmentEndAnchor, container, anchor)
  11. mountChildren(n2.children, container, fragmentEndAnchor, parentComponent, parentSuspense, isSVG, optimized)
  12. } else {
  13. }
  14. }

我们只分析挂载子节点的过程,所以 n1 的值为 null,n2 就是我们前面创建的 vnode 节点,它的 children 是一个数组。

processFragment 函数首先通过 hostInsert 在容器的前后插入两个空文本节点,然后在以尾文本节点作为参考锚点,通过 mountChildren 把 children 挂载到 container 容器中。

至此,我们就完成了子组件插槽内容的渲染。可以看到,插槽的实现实际上就是一种延时渲染,把父组件中编写的插槽内容保存到一个对象上,并且把具体渲染 DOM 的代码用函数的方式封装,然后在子组件渲染的时候,根据插槽名在对象中找到对应的函数,然后执行这些函数做真正的渲染。

总结

好的,到这里我们这一节课的学习就结束啦。希望你能了解插槽的实现原理,知道父组件和子组件在实现插槽 feature 的时候各自做了哪些事情。

最后,给你留一道思考题目,作用域插槽是如何实现子组件数据传递的?欢迎你在留言区与我分享。

本节课的相关代码在源代码中的位置如下:
packages/runtime-core/src/componentSlots.ts
packages/runtime-core/src/vnode.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/helpers/withRenderContext.ts