组件的实现原理

我们已经讲解过了浏览器的基本原理与实现。渲染器主要负责将虚拟 DOM 渲染为真实 DOM,我们只需要使用虚拟 DOM 来描述最终呈现的内容即可。但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变的越多越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。有了组件,我们就可以将一个大页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完善的页面。组件化的实现同样需要渲染器的支持。

渲染组件

从用户的角度来看,一个有状态组件就是一个选项对象。

const MyComponent = {
  name: 'MyComponent',
  data() {
    return { foo: 1 }
  }
}
js

但是,如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。例如,为了描述普通标签,我们使用虚拟节点的 vnode.type 属性来存储标签名称,如下面的代码所示:

const vnode = {
  type: 'div',
  // ...
}
js

为了描述片段,我们让虚拟节点的 vnode.type 属性的值为 Fragment,例如:

const vnode = {
  type: Fragment,
  // ...
}
js

为了描述文本,我们让虚拟节点的 vnode.type 属性的值为 Text。

const vnode = {
  type: Text,
  // ...
}
js

渲染器的 patch 函数证明了上述内容:

function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    // 作为普通元素处理
  } else if (type === Text) {
    // 作为文本节点处理
  } else if (type === Fragment) {
    // 作为片段处理
  }
}
js

可以看到,渲染器会使用虚拟节点的 type 属性来区分其类型。对于不同类型的节点,需要采用不同的处理方式来完成挂载和更新。

实际上,对于组件来说也是一样的。为了使用虚拟节点来描述组件,我们可以用虚拟节点的 vnode.type 属性来存储组件的选项对象。

const vnode = {
  type: MyComponent,
  // ...
}
js

为了让渲染器能够处理组件类型的虚拟节点,我们还需要在 patch 函数中对组件类型的节点进行处理。

function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    // 作为普通元素处理
  } else if (type === Text) {
    // 作为文本节点处理
  } else if (type === Fragment) {
    // 作为片段处理
  } else if (typeof type === 'object') {
    // vnode.type 的值是选项对象,作为组件处理
    if (!n1) {
      // 挂载组件
      mountComponent(n2, container, anchor)
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor)
    }
  }
}
js

在上面这段代码中,我们新增了一个 else if 分支,用来处理虚拟节点的 vnode.type 属性值为对象的情况,即将该虚拟节点作为组件的描述来看待,并调用 mountElementpatchComponent 函数来完成组件的挂载和更新。

渲染器有能力处理组件后,我们需要设计组件在用户层面的接口。这包括:用户应该如何编写组件?组件的选项对象必须是哪些内容?以及组件拥有哪些能力?等等。实际上,组件本身就是对页面内容的封装,它用来描述页面内容的一部分。因此,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM。换句话来说,组件的渲染函数就是用来描述组件所渲染内容的接口,如下面的代码所示:

const MyComponent = {
  // 组件名称
  name: 'MyComponent',
  // 组件的渲染函数,返回值必须为虚拟 DOM
  render() {
    return {
      type: 'div',
      children: '我是文本内容'
    }
  }
}
js

这个一个最简单的组件示例。有了基本的组件结构后,渲染器就可以完成组件的渲染。

// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const CompVNode = {
  type: MyComponent
}
// 调用渲染器渲染组件
renderer.render(CompVNode, document.querySelector('#app'))
js

浏览器真正完成组件渲染任务的是 mountComponent 函数,其具体实现如下:

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render } = componentOptions
  // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
  const subTree = render()
  // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
  patch(null, subTree, container, anchor)
}
js

这样,我们就实现了最基本的组件化方案。

组件状态与自更新

接下来,我们尝试为组件设计自身的状态,如下面的代码所示:

const MyComponent = {
  name: 'MyComponent',
  // 用 data 函数定义组件自身状态
  data() {
    return {
      foo: 'hello world'
    }
  },
  render() {
    // 渲染函数中使用组件状态
    return {
      type: 'div',
      children: `foo 的值是:${ this.foo }`
    }
  }
}
js

在上面这段代码中,我们约定用户必须使用 data 函数来定义组件自身的状态,同时可以在渲染函数中通过 this 访问由 data 函数返回的状态数据。

下面的代码实现了组件自身状态的初始化:

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render, data } = componentOptions

  // 调用 data 函数得到原始数据
  const state = reactive(data())
  // 调用 render 函数时,将其 this 设置为 state,
  // 从而 render 函数内部可以通过 this 访问组件自身状态数据
  const subTree = render.call(state, state)
  // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
  patch(null, subTree, container, anchor)
}
js

如上面的代码所示,实现组件自身状态的初始化需要两个步骤:

  • 通过组件的选项对象取得 data 函数并执行,然后调用 reactive 函数将 data 函数返回的状态包装为响应式数据;
  • 调用 render 函数时,将其 this 的指向设置为响应式数据 state,同时将 state 作为 render 函数的第一个参数传递。

经过上述两步工作后,我们就实现了对组件自身状态的支持,以及在渲染函数内访问组件自身状态的能力。

当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个 effect 中。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render, data } = componentOptions

  // 调用 data 函数得到原始数据
  const state = reactive(data())
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    // 调用 render 函数时,将其 this 设置为 state,
    // 从而 render 函数内部可以通过 this 访问组件自身状态数据
    const subTree = render.call(state, state)
    // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
    patch(null, subTree, container, anchor)
  })
}
js

这样,一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。但是,由于 effect 的执行是同步的,因此当响应式数据发生变化时,与之关联的副作用函数会同步执行。换句话来说,如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的。因此,我们需要设计一个机制,使得无论对响应式数据进行多少次修改,副作用函数都只会重新执行一次。为此,我们需要实现一个调度器,当副作用函数重新执行多次时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。

// 缓存任务队列,用一个 Set 数据结构来表示,可以自动对任务进行去重
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 示例
const p = Promise.resolve()

// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
  // 将 job 添加到任务队列 queue 中
  queue.add(job)
  // 如果还没有开始刷新队列
  if (!isFlushing) {
    // 将该标志设置为 true 避免重复刷新
    isFlushing = true
    // 微任务队列中刷新缓冲队列
    p.then(() => {
      try {
        // 执行任务队列中的任务
        queue.forEach(job => job())
      } catch (error) {
        // 重置状态
        isFlushing = false
        queue.length = 0
      }
    })
  }
}
js

上面是调度器的最小实现,本质上利用了微任务的异步执行机制,实现对副作用函数的缓冲。其中 queueJob 函数是调度器最主要的函数,用来将一个任务或副作用函数添加到缓冲队列中,并开始刷新队列。有了 queueJob 函数之后,我们就可以在创建渲染副作用函数时使用它。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render, data } = componentOptions

  // 调用 data 函数得到原始数据
  const state = reactive(data())
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    // 调用 render 函数时,将其 this 设置为 state,
    // 从而 render 函数内部可以通过 this 访问组件自身状态数据
    const subTree = render.call(state, state)
    // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
    patch(null, subTree, container, anchor)
  }, {
    // 指定该副作用函数的调度器为 queueJob 即可
    scheduler: queueJob
  })
}
js

这样,当响应式数据发生变化时,副作用函数不会立即同步执行,而是会被 queueJob 函数调度,最后在一个微任务中执行。

不过,上面这段代码仍存在缺陷。我们在 effect 函数内调用 patch 函数完成渲染时,第一个参数总是 null。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。正确的做法是:每次更新时,都拿新的 subTree 与上一次组件所渲染的 subTree 进行打补丁。为此,我们需要实现组件实例,用它维护整个生命周期的状态,这样渲染器才能够在正确地的时机执行合适的操作。

组件实例与组件的生命周期

组件本质上就是一个状态集合,它维护着组件运行过程中的所有信息,例如注册到组件的生命周期,组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data)等等。为了解决上一小节中关于组件更新的问题,我们需要引入组件实例的概念,以及与之相关的状态信息。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const { render, data } = componentOptions

  // 调用 data 函数得到原始数据
  const state = reactive(data())

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    // 调用 render 函数时,将其 this 设置为 state,
    // 从而 render 函数内部可以通过 this 访问组件自身状态数据
    const subTree = render.call(state, state)

    // 检查组件是否已经被挂载
    if (!instance.isMounted) {
      // 初次挂载,调用 patch 函数第一个参数传递 null
      patch(null, subTree, container, anchor)
      // 将组件示例的 isMounted 属性设置为 true
      instance.isMounted = true
    } else {
      // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可
      // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树
      // 使用新的子树与上一次渲染的子树进行打补丁操作
      patch(instance.subTree, subTree, container, anchor)
    }

    // 更新组件实例的子树
    instance.subTree = subTree
  }, {
    // 指定该副作用函数的调度器为 queueJob 即可
    scheduler: queueJob
  })
}
js

在上面这段代码中,我们使用一个对象来表示组件实例,该对象有三个属性:

  • state:组件自身的状态数据,即 data。
  • isMounted:一个布尔值,用来表示组件是否被挂载。
  • subTree:存储组件的渲染函数返回的虚拟 DOM,即组件子树(subTree)。

实际上,我们可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例清凉,以减少内存占用。

在上面的实现中,组件实例的 instance.isMounted 属性可以用来区分组件的挂载和更新。因此,我们可以在合适的时机调用组件对应的生命周期钩子。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const {
    render, data,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

  // 调用 data 函数得到原始数据
  const state = reactive(data())

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance

  // 调用 created 钩子
  created && created.call(state)
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    // 调用 render 函数时,将其 this 设置为 state,
    // 从而 render 函数内部可以通过 this 访问组件自身状态数据
    const subTree = render.call(state, state)

    // 检查组件是否已经被挂载
    if (!instance.isMounted) {
      // 调用 beforeMount 钩子
      beforeMount && beforeCreate.call(state)

      // 初次挂载,调用 patch 函数第一个参数传递 null
      patch(null, subTree, container, anchor)
      // 将组件示例的 isMounted 属性设置为 true
      instance.isMounted = true

      // 调用 mounted 钩子
      mounted && mounted.call(state)
    } else {
      // 调用 beforeUpdate 钩子
      beforeUpdate && beforeUpdate.call(state)

      // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可
      // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树
      // 使用新的子树与上一次渲染的子树进行打补丁操作
      patch(instance.subTree, subTree, container, anchor)

      // 调用 updated 钩子
      updated && updated.call(state)
    }

    // 更新组件实例的子树
    instance.subTree = subTree
  }, {
    // 指定该副作用函数的调度器为 queueJob 即可
    scheduler: queueJob
  })
}
js

这上面这段代码中,我们首先从组件的选项对象中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这其实就是组件生命周期的实现原理。实际上,由于可能存在多个同样的组件生命周期钩子,例如来自 mixins 中的生命周期函数,因此我们通常需要将组件生命周期钩子序列化为一个数组,但核心原理不变。

props 与组件的被动更新

在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大。假设我们有如下模板:

<MyComponent title="A Big Title" :other="val" />

这段模板对应的虚拟 DOM 是:

const vnode = {
  type: MyComponent,
  props: {
    title: 'A Big Title',
    other: this.val
  }
}
js

可以看到,模板与虚拟 DOM 几乎是 “同构” 的。另外,在编写组件时,我们需要显式地指定组件会接收哪些 props 数据。

const MyComponent = {
  name: 'MyComponent',
  // 组件接收名为 title 的 props,并且该 props 的类型为 string
  props: {
    title: String
  },
  render() {
    return {
      type: 'div',
      // 访问 props 数据
      children: `count is: ${ this.title }`
    }
  }
}
js

所以,对于一个组件来说,有两部分关于 props 的内容我们需要关心:

  • 为组件传递的 props 数据,即组件的 vnode.props 对象;
  • 组件选项对象中定义的 props 选项,即 MyComponent.props 对象。

我们需要结合这两个选项来解析出组件在渲染时需要用到的 props 数据。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const {
    render, data, props: propsOption,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

  // 调用 data 函数得到原始数据
  const state = reactive(data ? data() : {})
  // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
  const [props, attrs] = resolveProps(propsOption, vnode.props)

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance

  // 调用 created 钩子
  created && created.call(state)
  
	// ...
}
js
// 解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {
  const props = {}
  const attrs = {}

  // 遍历组件传递的 props 数据
  for (const key in propsData) {
    if (key in options) {
      // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,
      // 则视为合法的 props
      props[key] = propsData[key]
    } else {
      // 否则将其视为 atts
      attrs[key] = propsData[key]
    }
  }

  // 最后返回 props 和 attrs 数据
  return [props, attrs]
}
js

在上面这段代码中,我们将组件选项中定义的 MyComponent.props 对象和为组件传递的 vnode.props 对象相结合,最终解析出组件在渲染时需要使用的 propsattrs 数据。这里需要注意两点:

  • 在 vue.js 3 中,没有定义在 MyComponent.props 选项中的 props 数据将存储到 attrs 对象中。
  • 上述实现中没有包含默认值、类型校验等内容处理。实际上,这些内容也都是围绕 MyComponent.props 以及 vnode.props 这两个对象展开的,实现起来并不复杂。

处理完 props 数据后,我们再来讨论关于 props 数据变化的问题。props 本质上是父组件的数据,当 props 发生变化时,会触发父组件渲染。假设父组件的模板如下:

<template>
	<MyComponent :title="title" />
</template>

其中,响应式数据 title 的初始值为字符串 “A Big Title”,因此首次渲染时,父组件的虚拟 DOM 为:

// 父组件要渲染的内容
const vnode = {
  type: MyComponent,
  props: {
    title: 'A Big Title'
  }
}
js

当响应式数据 title 发生变化时,父组件的渲染函数会重新执行。假设 title 的值变为字符串 “A Small Title” ,那么新产生的虚拟 DOM 为:

// 父组件要渲染的内容
const vnode = {
  type: MyComponent,
  props: {
    title: 'A Small Title'
  }
}
js

接着,父组件会进行自更新。在更新过程中,渲染器发现父组件的 subTree 包含组件类型的虚拟节点,所以会调用 patchComponent 函数完成子组件的更新。

function patch(n1, n2, container, anchor) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    // ...
  } else if (type === Text) {
    // ...
  } else if (type === Fragment) {
    // ...
  } else if (typeof type === 'object') {
    // vnode.type 的值是选项对象,作为组件处理
    if (!n1) {
      // 挂载组件
      mountComponent(n2, container, anchor)
    } else {
      // 更新组件
      patchComponent(n1, n2, anchor)
    }
  }
}
js

其中,patchComponent 函数用来完成子组件的更新。我们把父组件自更新所引起的子组件更新叫做子组件的被动更新。当子组件发生被动更新时,我们需要做的是:

  • 检查子组件是否真的需要更新,因为子组件的 props 可能是不变的;
  • 如果需要更新,则更新子组件的 props、slots 等内容。

patchComponent 函数的具体实现如下:

function patchComponent(n1, n2, anchor) {
  // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
  const instance = (n2.component = n1.component)
  // 获取当前的 props 数据
  const { props } = instance

  // 调用 hasPropsChanged 检测子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
  if (hasPropsChanged(n1.props, n2.props)) {
    // 调用 resolveProps 函数重新获取 props 数据
    const [nextProps] = resolveProps(n2.type.props, n2.props)
    // 更新 props
    for (const k in nextProps) {
      props[k] = nextProps[k]
    }
    // 删除不存在的 props
    for (const k in props) {
      if (!(k in nextProps)) delete props[k]
    }

    // TODO:update 逻辑
  }
}
js
function hasPropsChanged(prevProps, nextProps) {
  const nextKeys = Object.keys(nextProps)
  // 如果新旧 props 的数量变了,说明有变化
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    // 有不相等的 props,则说明有变化
    if (nextProps[key] !== prevProps[key]) return true
  }
  return false
}
js

上面是组件被动更新的最小实现,有两点需要注意:

  • 需要将组件实例添加到新的组件 vnode 对象上,即 n2.component = n1.component,否则下次更新时将无法取得组件实例;
  • instance.props 对象本身是浅响应的。因此,在更新组件的 props 时,只需要设置 intsance.props 对象即可触发组件重新渲染。

在上面的代码中,我们没有处理 attrsslots 的更新。attrs 的更新本质上与更新 props 的原理相似。实际上,要完善地实现 Vue.js 中的 props 机制,需要编写大量边界代码。但本质上来说,其原理都是根据组件的 props 选项定义以及为组件传递的 props 数据来处理的。

由于 props 数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一个渲染上下文对象。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  const {
    render, data, props: propsOption,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

	// ...

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props } = t
      if (state && k in state) {
        // 尝试读取自身状态数据
        return state[k]
      } else if (k in props) {
        // 如果组件自身没有数据,尝试从 props 中读取
        return props[k]
      } else {
        console.error('不存在')
      }
    },
    set(t, k, v, r) {
      const { state, props } = t
      if (state && k in state) {
        state[k] = v
      } else if (k in props) {
        props[k] = v
      } else {
        console.error('不存在')
      }
    }
  })

  // 调用 created 钩子
  created && created.call(state)
  
	// ...
}
js

在上面这段代码中,我们为组件实例创建了一个代理对象,该对象即渲染上下文对象。它的意义在于拦截数据状态的读取和设置操作,每当在渲染函数或生命周期钩子中通过 this 来读取数据时,都会优先从组件的自身状态中读取,如果组件本身没有对应的数据,则再从 props 数据中读取。最后我们将渲染上下文作为渲染函数以及声明周期钩子的 this 值即可。

实际上,除了组件自身的数据以及 props 数据之外,完整的组件还包含 methodscomputed 等选项中定义的数据和方法,这些内容都应该在渲染上下文对象中处理。

setup 函数的作用与实现

组件的 setup 函数是 Vue.js 3 新增的组件选项,它有别于 Vue.js 2 中存在的其他组件选项。这是因为 setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册声明周期钩子等能力。在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况。

1. 返回一个函数,该函数将作为组件的 render 函数:

const Comp = {
  setup() {
    // setup 函数可以返回一个函数,该函数将作为组件的渲染函数
    return () => {
      return { type: 'div', children: 'hello' }
    }
  }
}
js

这种方式常用于组件不是以模板来表达其渲染内容的情况。如果组件以模板来表达其渲染内容,那么 setup 函数不可以再返回函数,否则会与模板编译生成的渲染函数产生冲突。

2. 返回一个对象,该对象中包含的数据将暴露给模板使用:

cosnt Comp = {
  setup() {
    const count = ref(0)
    // 但会一个对象,对象中的数据会暴露到渲染函数中
  },
  render() {
    // 通过 this 可以访问 setup 暴露出的响应式数据
    return { type: 'div', children: `count is:${ this.count }` }
  }
}
js

可以看到,setup 函数暴露的数据可以在渲染函数中通过 this 来访问。

另外,setup 函数接收两个参数。第一个参数是是 props 数据对象,第二个参数也是一个对象,通常称为 setupContext

const Comp = {
  props: {
    foo: String
  },
  setup(props, setupContext) {
    // 访问传入的 props 数据
    props.foo 
    // setupContext 中包含与组件接口相关的重要数据
    const { slots, emit, attrs, expose } = setupContext
    // ...
  }
}
js

从上面的代码可以看出,我们可以通过 setup 函数的第一个参数取得外部为组件传递的 props 数据对象。同时,setup 函数还接收第二个参数 setupContext 对象,其中保存着与组件接口相关的数据和方法:

  • slots:组件接收到的插槽;
  • emit:一个函数,用来发射自定义事件;
  • attrs:当为组件传递 props 时,那些没有显式声明为 props 的属性会存储到 attrs 对象中;
  • expose:一个函数,用来显式地对外暴露组件数据。

通常情况下,不建议将 setup 与 Vue.js 2 中其他组件选项混用。例如 datawatchmethods 等选项,我们称之为 “传统” 组件选项。在 Vue.js 3 的场景下,更加提供组合式 API,setup 函数就是为组合式 API 而生的。混用组合式 API 的 setup 选项与 ”传统“ 组件选项并不是明智的选择,因为这样会带来语义和理解上的负担。

接下来,我们就围绕上述这些能力尝试实现 setup 组件选项。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    // ....
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

	// ...

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null
  }

  // setupContext
  const setupContext = { attrs }
  // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), setupContext)
  // setupState 用来存储由 setup 返回的数据
  let setupState = null
  // 如果 setup 函数的返回值是函数,则将其作为渲染函数
  if (typeof setupResult === 'function') {
    if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
    // 将 setupResult 作为渲染函数
    render = setupResult
  } else {
    // 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
    setupState = setupResult
  }

  // 将组件实例设置到 vnode 上,用于后续更新
  vnode.component = instance

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props } = t
      if (state && k in state) {
        // 尝试读取自身状态数据
        return state[k]
      } else if (k in props) {
        // 如果组件自身没有数据,尝试从 props 中读取
        return props[k]
      } else if (setupState && k in setupState) {
        // 渲染上下文需要增加对 setupState 的支持
        return setupState[k]
      } else {
        console.error('不存在')
      }
    },
    set(t, k, v, r) {
      const { state, props } = t
      if (state && k in state) {
        state[k] = v
      } else if (k in props) {
        props[k] = v
      } else if (setupState && k in setupState) {
        setupState[k] = v
      } else {
        console.error('不存在')
      }
    }
  })

  // 调用 created 钩子
  created && created.call(renderContext)
  
 	// ...
}
js

上面是 setup 函数的最小实现,有以下几点需要注意:

  • setupContext 是一个对象,包含 attrsemitslots 等内容。
  • 我们通过检测 setup 函数的返回值类型来决定应该如何处理它。如果它的返回值为函数,则直接将其作为组件的渲染函数。这里需要注意的是,为了避免产生歧义,我们需要检查组件选项中是否已经存在 render 函数,如果泡在,则需要打印警告信息。
  • 渲染上下文 renderContext 应该正确地处理 setupState,因为 setup 函数返回的数据状态也应该暴露到渲染环境。

代码实现

组件事件与 emit 的实现

emit 用来发射组件的自定义事件,如下面的代码所示:

const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发生 change 事件,并传递给事件处理函数两个参数
    emit('change', 1, 2)

    return () => {
      return { type: 'div', children: 'hello world' }
    }
  }
}
js

当使用该组件时,我们可以监听由 emit 函数发射的自定义事件:

<MyComponent @change="handler" />

上面这段模板对应的虚拟 DOM 为:

const CompVNode = {
  type: MyComponent,
  props: {
    onChange: handler
  }
}
js

可以看到,自定义事件 change 被编译为名为 onChange 的属性,并存储在 props 数据对象中,这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。

在具体的实现上,发生自定义事件的本质就是根据事件名称去 props 数据对象中寻找对应的事件处理函数并执行。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

  // 调用 data 函数得到原始数据
  const state = reactive(data ? data() : {})
  // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
  const [props, attrs] = resolveProps(propsOption, vnode.props)

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null
  }

  // 定义 emit 函数,它接收两个参数
  // event:事件名称
  // payload:传递给事件处理函数的参数
  function emit(event, ...payload) {
    // 根据约定对事件名称进行处理
    const eventName = `on${ event[0].toUpperCase() + event.slice(1) }`
    // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
    const handler = instance.props[eventName]

    if (handler) {
      // 调用事件处理函数并传递参数
      handler(...payload)
    } else {
      console.error('事件不存在')
    }
  }

  // ...
}
js

整体实现并不复杂,只需要实现一个 emit 函数并将其添加到 setupContext 对象中,这样用户就可以通过 setupContext 取得 emit 函数。另外,当 emit 函数被调用时,我们会根据约定对事件名称进行转换,以便能够在 props 数据对象中找到对应的事件处理函数。最后,调用事件处理函数并透传参数即可。

这里有一点需要额外注意,我们在实现 resolveProps 函数时提到,任何没有显式声明为 props 的属性都会存储到 attrs 中。换句话来说,任何事件类型的 props,即 onXxx 这类的属性,都不会出现在 props 中。这会导致我们无法根据事件名称在 instance.props 中找到对应的事件处理函数。为了解决这个问题,我们需要在解析 props 数据的时候对事件类型的 props 做特殊处理。

// 解析组件 props 和 attrs 数据
function resolveProps(options = {}, propsData) {
  const props = {}
  const attrs = {}

  // 遍历组件传递的 props 数据
  for (const key in propsData) {
    if (key in options || key.startsWith('on')) {
      // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,
      // 则视为合法的 props
      props[key] = propsData[key]
    } else {
      // 否则将其视为 atts
      attrs[key] = propsData[key]
    }
  }

  // 最后返回 props 和 attrs 数据
  return [props, attrs]
}
js

处理方式很简单,通过检测 propsData 的 key 值来判断它是否以字符串 ‘on’ 开头,如果是,则认为该属性是组件的自定义事件。这时。即使组件没有显式地将其声明为 props,我们特将它添加到最终解析的 props 数据中,而不是添加到 attrs 对象中。

代码地址

插槽的工作原理与实现

顾名思义,组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入,如下面给出的 MyComponent 组件的模板所示:

<template>
	<header><slot name="header" /></header>
	<div>
    <slot name="body" />
  </div>
  <footer><slot name="footer" /></footer>
</template>

当在父组件使用 <MyComponet> 组件时,可以根据插槽的名字来插入自定义的内容。

<MyComponent>
	<template #header>
		<h1>我是标题</h1>
  </template>
  <template #body>
		<section>我是内容</section>
  </template>
  <template #footer>
  	<p>我是注脚</p>
  </template>
</MyComponent>

上面这段父组件的模板会被编译为如下渲染函数:

// 父组件的渲染函数
function render() {
  return {
    type: MyComponent,
    // 组件的 children 会被编译成一个对象
    children: {
      header() {
        return { type: 'h1', children: '我是标题' }
      },
      body() {
        return { type: 'section', children: '我是内容' }
      },
      footer() {
    	  return { type: 'p', children: '我是注脚' }
      }
    }
  }
}
js

可以看到,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容。组件 MyComponent 的模板则会被编译为如下渲染函数:

// MyComponent 组件模板的编译结果
function render() {
  return [
    {
      type: 'header',
      children: [this.$slots.header()]
    },
    {
      type: 'div',
      children: [this.$slots.body()]
    },
    {
      type: 'footer',
      children: [this.$slots.footer()]
    }
  ]
}
js

可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。这与 React 中 render props 的概念非常相似。

在运行时的实现上,插槽则依赖于 setupContext 中的 slots 对象。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()
	
  // ...

  // 使用编译好的 vnode.children 对象作为 slots 对象即可
  const slots = vnode.children || {}

  // setupContext
  const setupContext = { attrs, emit, slots }
  
	// ...
}

js

可以看到,最基本的 slots 的实现非常简单。只需要将编译好的 vnode.children 作为 slots 对象,最后将 slots 对象添加到 setupContext 对象中。为了在 render 函数内和生命周期钩子函数内通过 this.$slots 来访问插槽内容,我们还需要在 renderContext 中特殊处理 $slots 属性。


function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()

  // 调用 data 函数得到原始数据
  const state = reactive(data ? data() : {})
  // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
  const [props, attrs] = resolveProps(propsOption, vnode.props)
  // 使用编译好的 vnode.children 对象作为 slots 对象即可
  const slots = vnode.children || {}

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
    // 将插槽添加到组件实例上
    slots
  }
		
  // ...
  
  // setupContext
  const setupContext = { attrs, emit, slots }
	
  // ...

  // 创建渲染上下文对象,本质上是组件实例的代理
  const renderContext = new Proxy(instance, {
    get(t, k, r) {
      // 取得组件自身状态与 props 数据
      const { state, props, slots } = t

      if (k === '$slots') return slots;

			// ...
    },
    set(t, k, v, r) {
  		// ...
    }
  })
	
  // ...
}
js

我们对渲染上下文 renderContext 代理对象的 get 拦截函数做了特殊处理,当读取的键是 $slots 时,直接返回组件实例上的 slots 对象,这样用户就可以通过 this.$slots 来访问插槽内容了。

因为编译后组件模板的 render 函数返回一个数组,我们需要对此进行处理:

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions
	
  // ...
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    const subTree = render.call(renderContext, renderContext)

    // 检查组件是否已经被挂载
    if (!instance.isMounted) {
      // 调用 beforeMount 钩子
      beforeMount && beforeCreate.call(renderContext)

      if (Array.isArray(subTree)) {
        // 如果组件 render 函数返回的是数组,循环挂载
        subTree.forEach(tree => {
          patch(null, tree, container, anchor)
        })
      } else {
        // 初次挂载,调用 patch 函数第一个参数传递 null
        patch(null, subTree, container, anchor)
      }

      // 将组件示例的 isMounted 属性设置为 true
      instance.isMounted = true

      // 调用 mounted 钩子
      mounted && mounted.call(renderContext)
    } else {
     	// ...
    }

    // 更新组件实例的子树
    instance.subTree = subTree
  }, {
    // 指定该副作用函数的调度器为 queueJob 即可
    scheduler: queueJob
  })
}
js

代码地址

注册生命周期

在 Vue.js 中,有一部分组合式 API 是用来注册生命周期钩子函数的,例如 onMountedonUpdated 等。

const MyComponent = {
  setup() {
    onMounted(() => {
      console.log('mounted 1')
    })

    onMounted(() => {
      console.log('mounted 2')
    })
  }
}
js

在 setup 函数中调用 onMounted 函数即可注册 mounted 生命周期钩子函数,并且可以通过多次调用 onMounted 函数来注册多个钩子函数,这些函数会在组件被挂在之后再执行。这里的问题在于,在 A 组件的 setup 函数中调用 onMounted 函数会将该钩子函数注册到 A 组件上;而在 B 组件的 setup 函数中调用 onMounted 函数会将钩子函数注册到 B 组件上,这是如何实现的呢?

实际上,我们需要维护一个变量 currentInstance,用它来存储当前组件实例,每当初始化并执行组件的 setup 函数之前,先将 currentInstance 设置为当前组件实例,再执行组件的 setup 函数时,我们就可以通过 currentInstance 来获取当前正在被初始化的组件实例,从而将那些与 onMounted 函数注册的钩子函数与组件实例相关联。

接下来我们着手实现。首先需要设计一个当前实例的维护方法。

// 全局变量,存在当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该组件实例设置为 currentInstance
function setCurrentInstance(instance) {
  currentInstance = instance
}
js

有了 currentInstance 变量,以及用来设置该变量的 setCurrentInstance 函数之后,我们就可以着手修改 mounteComponent 函数了。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions
	
  // ...

  // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
  const instance = {
    // 组件自身的状态数据,即 data
    state,
    // 将解析出的 props 数据包装为 shallowReative 并定义到组件实例上
    props: shallowReactive(props),
    // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
    isMounted: false,
    // 组件所渲染的内容,即子树(subTree)
    subTree: null,
    // 将插槽添加到组件实例上
    slots,
    // 组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
    mounted: []
  }
	
  // ...

  // setupContext
  const setupContext = { attrs, emit, slots }

  // 调用 setup 函数之前,设置当前组件实例
  setCurrentInstance(instance)
  
  // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值
  // 将 setupContext 作为第二个参数传递
  const setupResult = setup(shallowReadonly(instance.props), setupContext)

  // 在 setup 函数执行完毕之后,重置当前组件实例
  setCurrentInstance(null)
	
  // ...
}

js

上面这段代码以 onMounted 函数为例进行说明。为了存储由 onMounted 函数注册的生命周期钩子,我们需要在组件实例对象上添加 instance.mounted 数组。之所以 instance.mounted 的数据类型是数组,是因为在 setup 函数中,可以多次调用 onMounted 函数来注册不同的生命周期函数,这些生命周期函数都存储在 instance.mounted 数组中。

现在,组件实例的维护已经搞定了。接下来考虑 onMounted 函数本身的实现。

function onMounted(fn) {
  if (currentInstance) {
    // 将生命周期函数添加到 instance.mounted 数组中
    currentInstance.mounted.push(fn)
  } else {
    console.error('onMounted 函数只能在 setup 中调用')
  }
}
js

可以看到,整体实现非常简单直观。只需要通过 currentInstance 取得当前组件实例,并将生命周期钩子函数添加到当前实例对象的 instance.mounted 数组中即可。另外,如果当前实例不存在,则说明用户没有在 setup 函数内调用 onMounted 函数,这时错误的用法,因此我们应该抛出错误及其原因。

最后一步需要做的是,在合适的时机调用这些注册在 instance.mounted 数组中的生命周期钩子函数。

function mountComponent(vnode, container, anchor) {
  // 通过 vnode 获取组件的选项对象,即 vnode.type
  const componentOptions = vnode.type
  // 获取组件的渲染函数 render
  let {
    render, data, props: propsOption, setup,
    beforeCreate, created, beforeMount, mounted, beforeUpdate, updated
  } = componentOptions

  // 调用 beforeCrate 钩子
  beforeCreate && beforeCreate()
	
  // ...
  
  // 将组件的 render 函数包装到 effect 内
  effect(() => {
    const subTree = render.call(renderContext, renderContext)

    // 检查组件是否已经被挂载
    if (!instance.isMounted) {
      // 调用 beforeMount 钩子
      beforeMount && beforeCreate.call(renderContext)

      // 遍历 instance.mounted 数组并逐个执行即可
      if (Array.isArray(instance.mounted)) {
        instance.mounted.forEach(hook => hook.call(renderContext))
      }

     	// ...

      // 将组件示例的 isMounted 属性设置为 true
      instance.isMounted = true

      // 调用 mounted 钩子
      mounted && mounted.call(renderContext)
    } else {
    	// ...
    }

    // 更新组件实例的子树
    instance.subTree = subTree
  }, {
    // 指定该副作用函数的调度器为 queueJob 即可
    scheduler: queueJob
  })
}
js

可以看到,我们只需要在合适的时机遍历 instance.mounted 数组,并逐个执行该数组内的生命周期钩子函数即可。

对于除 mounted 以外的生命周期钩子函数,其原理同上。

代码地址

总结

本篇文章中,我们首先讨论了如何使用虚拟节点来描述组件。使用虚拟节点的 vnode.type 属性来存储组件对象,渲染器根据虚拟节点的该属性的类型来判断它是都是组件。如果是组件,则渲染器会使用 mountComponentpatchComponent 来完成组件的挂载和更新。

接着,我们讨论了组件的自更新。我们知道,在组件挂载阶段,会为组件创建一个用于渲染其内容的副作用函数。该副作用函数会与组件自身的响应式数据建立响应联系。当组件自身的响应式数据发生变化时,会触发渲染副作用函数重新执行,即重新渲染。但由于默认情况下重新渲染是同步执行的,这导致无法对任务去重,因此我们在创建渲染副作用函数时,指定了自定义的调度器。该调度器的作用是:当组件自身的响应式数据发生变化时,将渲染副作用函数缓存到微任务队列中。有了缓冲队列,我们即可实现对渲染任务去重,从而避免无用的重新渲染所导致的额外性能开销。

然后,我们介绍了组件实例。它本质上是一个对象,包含了组件运行过程中的状态,例如组件是否挂载、组件自身的响应式数据,以及组件所渲染的内容(即 subTree)等。有了组件实例后,在渲染副作用函数内,我们就可以根据组件实例上的状态标识,来决定应该进行全新的挂载,还是打补丁。

我们还讨论了组件的 props 与组件的被动更新。副作用自更新所引起的子组件更新叫做子组件的被动更新。我们还介绍了渲染上下(renderContext),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。

之后,我们讨论了 setup 函数。该函数是为了组合式 API 而生的,所以我们要避免将其与 Vue.js 2 中的 “传统” 组件选项混合使用。setup 函数的返回值可以是两种类型,如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象暴露到渲染上下文中。

emit 函数包含在 setupContext 对象中,可以通过 emit 函数发射组件的自定义事件。通过 v-on 指令为组件绑定的事件经过编译后,会以 onXxx 的形式存储到 props 对象中。当 emit 函数执行时,会在 props 对象中寻找对应的事件处理函数执行它。

随后,我们讨论了组件的插槽。它借鉴了 Web Component<slot> 标签的概念。插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。<slot> 标签则会被编译为插槽函数的调用,通过指定对应的插槽函数,得到外部向槽位填充的内容(即虚拟 DOM),最后将内容渲染到槽位中。

最后,我们讨论了 onMounted 等用于注册声明周期钩子函数的方法的实现。通过 onMounted 注册的声明周期函数会被注册到当前组件实例的 instance.mounted 数组中。为了维护当前正在初始化的组件实例,我们定义了全局变量 currentInstance,以及用来设置该变量的 setCurrentInstance 函数。