组件化——组件的实现原理

渲染器主要负责将虚拟 DOM 渲染为真实 DOM,我们只需要使用虚拟 DOM 来描述最终呈现的内容即可。但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。有了组件,我们就可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持,从现在开始,我们将详细讨论 Vue.js 中的组件化。

1、渲染组件

从用户的角度来看,一个有状态组件就是一个选项对象,如下面的代码所示:

01 // MyComponent 是一个组件,它的值是一个选项对象
02 const MyComponent = {
03   name: 'MyComponent',
04   data() {
05     return { foo: 1 }
06   }
07 }

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

01 // 该 vnode 用来描述普通标签
02 const vnode = {
03   type: 'div'
04   // ...
05 }

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

01 // 该 vnode 用来描述片段
02 const vnode = {
03   type: Fragment
04   // ...
05 }

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

01 // 该 vnode 用来描述文本节点
02 const vnode = {
03   type: Text
04   // ...
05 }

渲染器的 patch 函数证明了上述内容,如下是我们实现的 patch 函数的代码:

01 function patch(n1, n2, container, anchor) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06
07   const { type } = n2
08
09   if (typeof type === 'string') {
10     // 作为普通元素处理
11   } else if (type === Text) {
12     // 作为文本节点处理
13   } else if (type === Fragment) {
14     // 作为片段处理
15   }
16 }

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

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

01 // 该 vnode 用来描述组件,type 属性存储组件的选项对象
02 const vnode = {
03   type: MyComponent
04   // ...
05 }

为了让渲染器能够处理组件类型的虚拟节点,我们还需要在patch 函数中对组件类型的虚拟节点进行处理,如下面的代码所示:

01 function patch(n1, n2, container, anchor) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06
07   const { type } = n2
08
09   if (typeof type === 'string') {
10     // 作为普通元素处理
11   } else if (type === Text) {
12     // 作为文本节点处理
13   } else if (type === Fragment) {
14     // 作为片段处理
15   } else if (typeof type === 'object') {
16     // vnode.type 的值是选项对象,作为组件来处理
17     if (!n1) {
18       // 挂载组件
19       mountComponent(n2, container, anchor)
20     } else {
21       // 更新组件
22       patchComponent(n1, n2, anchor)
23     }
24   }
25 }

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

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

01 const MyComponent = {
02   // 组件名称,可选
03   name: 'MyComponent',
04   // 组件的渲染函数,其返回值必须为虚拟 DOM
05   render() {
06     // 返回虚拟 DOM
07     return {
08       type: 'div',
09       children: `我是文本内容`
10     }
11   }
12 }

这是一个最简单的组件示例。有了基本的组件结构之后,渲染器就可以完成组件的渲染,如下面的代码所示:

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

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

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

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

2、组件状态与自更新

在上一节中,我们完成了组件的初始渲染。接下来,我们尝试为组件设计自身的状态,如下面的代码所示:

01 const MyComponent = {
02   name: 'MyComponent',
03   // 用 data 函数来定义组件自身的状态
04   data() {
05     return {
06       foo: 'hello world'
07     }
08   },
09   render() {
10     return {
11       type: 'div',
12       children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
13     }
14   }
15 }

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

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

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   const { render, data } = componentOptions
04
05   // 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
06   const state = reactive(data())
07   // 调用 render 函数时,将其 this 设置为 state,
08   // 从而 render 函数内部可以通过 this 访问组件自身状态数据
09   const subTree = render.call(state, state)
10   patch(null, subTree, container, anchor)
11 }

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

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

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

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

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   const { render, data } = componentOptions
04
05   const state = reactive(data())
06
07   // 将组件的 render 函数调用包装到 effect 内
08   effect(() => {
09     const subTree = render.call(state, state)
10     patch(null, subTree, container, anchor)
11   })
12 }

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

01 // 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
02 const queue = new Set()
03 // 一个标志,代表是否正在刷新任务队列
04 let isFlushing = false
05 // 创建一个立即 resolve 的 Promise 实例
06 const p = Promise.resolve()
07
08 // 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
09 function queueJob(job) {
10   // 将 job 添加到任务队列 queue 中
11   queue.add(job)
12   // 如果还没有开始刷新队列,则刷新之
13   if (!isFlushing) {
14     // 将该标志设置为 true 以避免重复刷新
15     isFlushing = true
16     // 在微任务中刷新缓冲队列
17     p.then(() => {
18       try {
19         // 执行任务队列中的任务
20         queue.forEach(job => job())
21       } finally {
22         // 重置状态
23         isFlushing = false
24         queue.clear = 0
25       }
26     })
27   }
28 }

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

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   const { render, data } = componentOptions
04
05   const state = reactive(data())
06
07   effect(() => {
08     const subTree = render.call(state, state)
09     patch(null, subTree, container, anchor)
10   }, {
11     // 指定该副作用函数的调度器为 queueJob 即可
12     scheduler: queueJob
13   })
14 }

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

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

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

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

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   const { render, data } = componentOptions
04
05   const state = reactive(data())
06
07   // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
08   const instance = {
09     // 组件自身的状态数据,即 data
10     state,
11     // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
12     isMounted: false,
13     // 组件所渲染的内容,即子树(subTree)
14     subTree: null
15   }
16
17   // 将组件实例设置到 vnode 上,用于后续更新
18   vnode.component = instance
19
20   effect(() => {
21     // 调用组件的渲染函数,获得子树
22     const subTree = render.call(state, state)
23     // 检查组件是否已经被挂载
24     if (!instance.isMounted) {
25       // 初次挂载,调用 patch 函数第一个参数传递 null
26       patch(null, subTree, container, anchor)
27       // 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
28       // 而是会执行更新
29       instance.isMounted = true
30     } else {
31       // 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
32       // 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
33       // 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
34       patch(instance.subTree, subTree, container, anchor)
35     }
36     // 更新组件实例的子树
37     instance.subTree = subTree
38   }, { scheduler: queueJob })
39 }

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

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

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

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

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   // 从组件选项对象中取得组件的生命周期函数
04   const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions
05
06   // 在这里调用 beforeCreate 钩子
07   beforeCreate && beforeCreate()
08
09   const state = reactive(data())
10
11   const instance = {
12     state,
13     isMounted: false,
14     subTree: null
15   }
16   vnode.component = instance
17
18   // 在这里调用 created 钩子
19   created && created.call(state)
20
21   effect(() => {
22     const subTree = render.call(state, state)
23     if (!instance.isMounted) {
24       // 在这里调用 beforeMount 钩子
25       beforeMount && beforeMount.call(state)
26       patch(null, subTree, container, anchor)
27       instance.isMounted = true
28       // 在这里调用 mounted 钩子
29       mounted && mounted.call(state)
30     } else {
31       // 在这里调用 beforeUpdate 钩子
32       beforeUpdate && beforeUpdate.call(state)
33       patch(instance.subTree, subTree, container, anchor)
34       // 在这里调用 updated 钩子
35       updated && updated.call(state)
36     }
37     instance.subTree = subTree
38   }, { scheduler: queueJob })
39 }

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

4、props 与组件的被动更新

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

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

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

01 const vnode = {
02   type: MyComponent,
03   props: {
04     title: 'A big Title',
05     other: this.val
06   }
07 }

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

01 const MyComponent = {
02   name: 'MyComponent',
03   // 组件接收名为 title 的 props,并且该 props 的类型为 String
04   props: {
05     title: String
06   },
07   render() {
08     return {
09       type: 'div',
10       children: `count is: ${this.title}` // 访问 props 数据
11     }
12   }
13 }

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

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

我们需要结合这两个选项来解析出组件在渲染时需要用到的props 数据,具体实现如下:

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   // 从组件选项对象中取出 props 定义,即 propsOption
04   const { render, data, props: propsOption /* 其他省略 */ } = componentOptions
05
06   beforeCreate && beforeCreate()
07
08   const state = reactive(data())
09   // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
10   const [props, attrs] = resolveProps(propsOption, vnode.props)
11
12   const instance = {
13     state,
14     // 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
15     props: shallowReactive(props),
16     isMounted: false,
17     subTree: null
18   }
19   vnode.component = instance
20
21   // 省略部分代码
22 }
23
24 // resolveProps 函数用于解析组件 props 和 attrs 数据
25 function resolveProps(options, propsData) {
26   const props = {}
27   const attrs = {}
28   // 遍历为组件传递的 props 数据
29   for (const key in propsData) {
30     if (key in options) {
31       // 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
32       props[key] = propsData[key]
33     } else {
34       // 否则将其作为 attrs
35       attrs[key] = propsData[key]
36     }
37   }
38
39   // 最后返回 props 与 attrs 数据
40   return [ props, attrs ]
41 }

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

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

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

01 <template>
02   <MyComponent :title="title"/>
03 </template>

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

01 <template>
02   <MyComponent :title="title"/>
03 </template>

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

01 // 父组件要渲染的内容
02 const vnode = {
03   type: MyComponent,
04   props: {
05     title: 'A Big Title'
06   }
07 }

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

01 // 父组件要渲染的内容
02 const vnode = {
03   type: MyComponent,
04   props: {
05     title: 'A Small Title'
06   }
07 }

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

01 function patch(n1, n2, container, anchor) {
02   if (n1 && n1.type !== n2.type) {
03     unmount(n1)
04     n1 = null
05   }
06
07   const { type } = n2
08
09   if (typeof type === 'string') {
10     // 省略部分代码
11   } else if (type === Text) {
12     // 省略部分代码
13   } else if (type === Fragment) {
14     // 省略部分代码
15   } else if (typeof type === 'object') {
16     // vnode.type 的值是选项对象,作为组件来处理
17     if (!n1) {
18       mountComponent(n2, container, anchor)
19     } else {
20       // 更新组件
21       patchComponent(n1, n2, anchor)
22     }
23   }
24 }

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

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

patchComponent 函数的具体实现如下:

01 function patchComponent(n1, n2, anchor) {
02   // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
03   const instance = (n2.component = n1.component)
04   // 获取当前的 props 数据
05   const { props } = instance
06   // 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
07   if (hasPropsChanged(n1.props, n2.props)) {
08     // 调用 resolveProps 函数重新获取 props 数据
09     const [ nextProps ] = resolveProps(n2.type.props, n2.props)
10     // 更新 props
11     for (const k in nextProps) {
12       props[k] = nextProps[k]
13     }
14     // 删除不存在的 props
15     for (const k in props) {
16       if (!(k in nextProps)) delete props[k]
17     }
18   }
19 }
20
21 function hasPropsChanged(
22   prevProps,
23   nextProps
24 ) {
25   const nextKeys = Object.keys(nextProps)
26   // 如果新旧 props 的数量变了,则说明有变化
27   if (nextKeys.length !== Object.keys(prevProps).length) {
28     return true
29   }
30     // 只有
31   for (let i = 0; i < nextKeys.length; i++) {
32     const key = nextKeys[i]
33     // 有不相等的 props,则说明有变化
34     if (nextProps[key] !== prevProps[key]) return true
35   }
36   return false
37 }

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

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

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

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

01 function mountComponent(vnode, container, anchor) {
02   // 省略部分代码
03
04   const instance = {
05     state,
06     props: shallowReactive(props),
07     isMounted: false,
08     subTree: null
09   }
10
11   vnode.component = instance
12
13   // 创建渲染上下文对象,本质上是组件实例的代理
14   const renderContext = new Proxy(instance, {
15     get(t, k, r) {
16       // 取得组件自身状态与 props 数据
17       const { state, props } = t
18       // 先尝试读取自身状态数据
19       if (state && k in state) {
20         return state[k]
21       } else if (k in props) { // 如果组件自身没有该数据,则尝试从 props 中读取
22         return props[k]
23       } else {
24         console.error('不存在')
25       }
26     },
27     set (t, k, v, r) {
28       const { state, props } = t
29       if (state && k in state) {
30         state[k] = v
31       } else if (k in props) {
32         console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
33       } else {
34         console.error('不存在')
35       }
36     }
37   })
38
39   // 生命周期函数调用时要绑定渲染上下文对象
40   created && created.call(renderContext)
41
42   // 省略部分代码
43 }

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

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

5、setup 函数的作用与实现

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

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

01 const Comp = {
02   setup() {
03     // setup 函数可以返回一个函数,该函数将作为组件的渲染函数
04     return () => {
05       return { type: 'div', children: 'hello' }
06     }
07   }
08 }

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

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

01 const Comp = {
02   setup() {
03     const count = ref(0)
04     // 返回一个对象,对象中的数据会暴露到渲染函数中
05     return {
06       count
07     }
08   },
09   render() {
10     // 通过 this 可以访问 setup 暴露出来的响应式数据
11     return { type: 'div', children: `count is: ${this.count}` }
12   }
13 }

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

另外,setup 函数接收两个参数。第一个参数是 props 数据对象,第二个参数也是一个对象,通常称为 setupContext,如下面的代码所示:

01 const Comp = {
02   props: {
03     foo: String
04   },
05   setup(props, setupContext) {
06     props.foo // 访问传入的 props 数据
07     // setupContext 中包含与组件接口相关的重要数据
08     const { slots, emit, attrs, expose } = setupContext
09     // ...
10   }
11 }

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

  • slots:组件接收到的插槽。
  • emit:一个函数,用来发射自定义事件。
  • attrs:在上节中我们介绍过 attrs 对象。当为组件传递props 时,那些没有显式地声明为 props 的属性会存储到 attrs 对象中。
  • expose:一个函数,用来显式地对外暴露组件数据。与 expose 相关的 API 设计仍然在讨论中,详情可以查看具体的 RFC 内容。

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

接下来,我们就围绕上述这些能力来尝试实现 setup 组件选项,如下面的代码所示:

01 function mountComponent(vnode, container, anchor) {
02   const componentOptions = vnode.type
03   // 从组件选项中取出 setup 函数
04   let { render, data, setup, /* 省略其他选项 */ } = componentOptions
05
06   beforeCreate && beforeCreate()
07
08   const state = data ? reactive(data()) : null
09   const [props, attrs] = resolveProps(propsOption, vnode.props)
10
11   const instance = {
12     state,
13     props: shallowReactive(props),
14     isMounted: false,
15     subTree: null
16   }
17
18   // setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要 attrs
19   const setupContext = { attrs }
20   // 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
21   // 将 setupContext 作为第二个参数传递
22   const setupResult = setup(shallowReadonly(instance.props), setupContext)
23   // setupState 用来存储由 setup 返回的数据
24   let setupState = null
25   // 如果 setup 函数的返回值是函数,则将其作为渲染函数
26   if (typeof setupResult === 'function') {
27     // 报告冲突
28     if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
29     // 将 setupResult 作为渲染函数
30     render = setupResult
31   } else {
32     // 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
33     setupState = setupResult
34   }
35
36   vnode.component = instance
37
38   const renderContext = new Proxy(instance, {
39     get(t, k, r) {
40       const { state, props } = t
41       if (state && k in state) {
42         return state[k]
43       } else if (k in props) {
44         return props[k]
45       } else if (setupState && k in setupState) {
46         // 渲染上下文需要增加对 setupState 的支持
47         return setupState[k]
48       } else {
49         console.error('不存在')
50       }
51     },
52     set (t, k, v, r) {
53       const { state, props } = t
54       if (state && k in state) {
55         state[k] = v
56       } else if (k in props) {
57         console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
58       } else if (setupState && k in setupState) {
59         // 渲染上下文需要增加对 setupState 的支持
60         setupState[k] = v
61       } else {
62         console.error('不存在')
63       }
64     }
65   })
66
67   // 省略部分代码
68 }

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

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

6、组件事件与 emit 的实现

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

01 const MyComponent = {
02   name: 'MyComponent',
03   setup(props, { emit }) {
04     // 发射 change 事件,并传递给事件处理函数两个参数
05     emit('change', 1, 2)
06
07     return () => {
08       return // ...
09     }
10   }
11 }

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

01 <MyComponent @change="handler" />

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

01 const CompVNode = {
02   type: MyComponent,
03   props: {
04     onChange: handler
05   }
06 }

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

在具体的实现上,发射自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行,如下面的代码所示:

01 function mountComponent(vnode, container, anchor) {
02   // 省略部分代码
03
04   const instance = {
05     state,
06     props: shallowReactive(props),
07     isMounted: false,
08     subTree: null
09   }
10
11   // 定义 emit 函数,它接收两个参数
12   // event: 事件名称
13   // payload: 传递给事件处理函数的参数
14   function emit(event, ...payload) {
15     // 根据约定对事件名称进行处理,例如 change --> onChange
16     const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
17     // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
18     const handler = instance.props[eventName]
19     if (handler) {
20       // 调用事件处理函数并传递参数
21       handler(...payload)
22     } else {
23       console.error('事件不存在')
24     }
25   }
26
27   // 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
28   const setupContext = { attrs, emit }
29
30   // 省略部分代码
31 }

整体实现并不复杂,只需要实现一个 emit 函数并将其添加到setupContext 对象中,这样用户就可以通过 setupContext 取得 emit 函数了。另外,当 emit 函数被调用时,我们会根据约定对事件名称进行转换,以便能够在 props 数据对象中找到对应的事件处理函数。最后,调用事件处理函数并透传参数即可。这里有一点需要额外注意,我们在讲解 props 时提到,任何没有显式地声明为 props 的属性都会存储到 attrs 中。换句话说,任何事件类型的 props,即 onXxx 类的属性,都不会出现在 props 中。这导致我们无法根据事件名称在instance.props 中找到对应的事件处理函数。为了解决这个问题,我们需要在解析 props 数据的时候对事件类型的 props 做特殊处理,如下面的代码所示:

01 function resolveProps(options, propsData) {
02   const props = {}
03   const attrs = {}
04   for (const key in propsData) {
05     // 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
06     if (key in options || key.startsWith('on')) {
07       props[key] = propsData[key]
08     } else {
09       attrs[key] = propsData[key]
10     }
11   }
12
13   return [ props, attrs ]
14 }

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

7、插槽的工作原理与实现

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

01 <template>
02   <header><slot name="header" /></header>
03   <div>
04     <slot name="body" />
05   </div>
06   <footer><slot name="footer" /></footer>
07 </template>

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

01 <MyComponent>
02   <template #header>
03     <h1>我是标题</h1>
04   </template>
05   <template #body>
06     <section>我是内容</section>
07   </template>
08   <template #footer>
09     <p>我是注脚</p>
10   </template>
11 </MyComponent>

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

01 // 父组件的渲染函数
02 function render() {
03   return {
04     type: MyComponent,
05     // 组件的 children 会被编译成一个对象
06     children: {
07       header() {
08         return { type: 'h1', children: '我是标题' }
09       },
10       body() {
11         return { type: 'section', children: '我是内容' }
12       },
13       footer() {
14         return { type: 'p', children: '我是注脚' }
15       }
16     }
17   }
18 }

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

01 // MyComponent 组件模板的编译结果
02 function render() {
03   return [
04     {
05       type: 'header',
06       children: [this.$slots.header()]
07     },
08     {
09       type: 'body',
10       children: [this.$slots.body()]
11     },
12     {
13       type: 'footer',
14       children: [this.$slots.footer()]
15     }
16   ]
17 }

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

在运行时的实现上,插槽则依赖于 setupContext 中的 slots 对象,如下面的代码所示:

01 function mountComponent(vnode, container, anchor) {
02   // 省略部分代码
03
04   // 直接使用编译好的 vnode.children 对象作为 slots 对象即可
05   const slots = vnode.children || {}
06
07   // 将 slots 对象添加到 setupContext 中
08   const setupContext = { attrs, emit, slots }
09
10 }

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

01 function mountComponent(vnode, container, anchor) {
02   // 省略部分代码
03
04   const slots = vnode.children || {}
05
06   const instance = {
07     state,
08     props: shallowReactive(props),
09     isMounted: false,
10     subTree: null,
11     // 将插槽添加到组件实例上
12     slots
13   }
14
15   // 省略部分代码
16
17   const renderContext = new Proxy(instance, {
18     get(t, k, r) {
19       const { state, props, slots } = t
20       // 当 k 的值为 $slots 时,直接返回组件实例上的 slots
21       if (k === '$slots') return slots
22
23       // 省略部分代码
24     },
25     set (t, k, v, r) {
26       // 省略部分代码
27     }
28   })
29
30   // 省略部分代码
31 }

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

8、注册生命周期

在 Vue.js 3 中,有一部分组合式 API 是用来注册生命周期钩子函数的,例如 onMounted、onUpdated 等,如下面的代码所示:

01 import { onMounted } from 'vue'
02
03 const MyComponent = {
04   setup() {
05     onMounted(() => {
06       console.log('mounted 1')
07     })
08     // 可以注册多个
09     onMounted(() => {
10       console.log('mounted 2')
11     })
12
13     // ...
14   }
15 }

在 setup 函数中调用 onMounted 函数即可注册 mounted 生命周期钩子函数,并且可以通过多次调用 onMounted 函数来注册多个钩子函数,这些函数会在组件被挂载之后再执行。这里的疑问在于,在 A 组件的 setup 函数中调用 onMounted 函数会将该钩子函数注册到 A 组件上;而在 B 组件的 setup 函数中调用 onMounted 函数会将钩子函数注册到 B 组件上,这是如何实现的呢?实际上,我们需要维护一个变量currentInstance,用它来存储当前组件实例,每当初始化组件并执行组件的 setup 函数之前,先将 currentInstance 设置为当前组件实例,再执行组件的 setup 函数,这样我们就可以通过 currentInstance 来获取当前正在被初始化的组件实例,从而将那些通过 onMounted 函数注册的钩子函数与组件实例进行关联。

接下来我们着手实现。首先需要设计一个当前实例的维护方法,如下面的代码所示:

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

有了 currentInstance 变量,以及用来设置该变量的setCurrentInstance 函数之后,我们就可以着手修改mounteComponent 函数了,如下面的代码所示:

01 function mountComponent(vnode, container, anchor) {
02   // 省略部分代码
03
04   const instance = {
05     state,
06     props: shallowReactive(props),
07     isMounted: false,
08     subTree: null,
09     slots,
10     // 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
11     mounted: []
12   }
13
14   // 省略部分代码
15
16   // setup
17   const setupContext = { attrs, emit, slots }
18
19   // 在调用 setup 函数之前,设置当前组件实例
20   setCurrentInstance(instance)
21   // 执行 setup 函数
22   const setupResult = setup(shallowReadonly(instance.props), setupContext)
23   // 在 setup 函数执行完毕之后,重置当前组件实例
24   setCurrentInstance(null)
25
26   // 省略部分代码
27 }

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

现在,组件实例的维护已经搞定了。接下来考虑 onMounted 函数本身的实现,如下面的代码所示:

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

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

最后一步需要做的是,在合适的时机调用这些注册到instance.mounted 数组中的生命周期钩子函数,如下面的代码所示:

01 function mountComponent(vnode, container, anchor) {
02     // 省略部分代码
03
04     effect(() => {
05       const subTree = render.call(renderContext, renderContext)
06       if (!instance.isMounted) {
07         // 省略部分代码
08
09         // 遍历 instance.mounted 数组并逐个执行即可
10         instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
11       } else {
12         // 省略部分代码
13       }
14       instance.subTree = subTree
15     }, {
16       scheduler: queueJob
17     })
18   }

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

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

你可能感兴趣的:(Web,#,Vue+TypeScript,javascript,前端,vue.js,开发语言)