12.组件的实现原理

2024/9/12 Vue

​ 渲染器主要负责将虚拟 DOM渲染为真实 DOM,我们只需要使用虚拟 DOM 来描述最终呈现的内容即可。但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。

​ 有了组件,我们就可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持。

# 渲染组件

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

 // MyComponent 是一个组件,它的值是一个选项对象
 const MyComponent = {
   name: 'MyComponent',
   data() {
     return { foo: 1 }
   }
 }
1
2
3
4
5
6
7

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

 // 该 vnode 用来描述普通标签
 const vnode = {
   type: 'div'
   // ...
 }
1
2
3
4
5

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

 // 该 vnode 用来描述片段
 const vnode = {
   type: Fragment
   // ...
 }
1
2
3
4
5

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

 // 该 vnode 用来描述文本节点
 const vnode = {
   type: Text
   // ...
 }
1
2
3
4
5

​ 渲染器的 patch 函数证明了上述内容,如下是我们在第三篇中实现的 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) {
     // 作为片段处理
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

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

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

 // 该 vnode 用来描述组件,type 属性存储组件的选项对象
 const vnode = {
   type: MyComponent
   // ...
 }
1
2
3
4
5

​ 为了让渲染器能够处理组件类型的虚拟节点,我们还需要在 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)
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

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

​ 渲染器有能力处理组件后,下一步我们要做的是,设计组件在用户层面的接口。这包括:用户应该如何编写组件?组件的选项对象必须包含哪些内容?以及组件拥有哪些能力?等等。

​ 实际上,组件本身是对页面内容的封装,它用来描述页面内容的一部分。因此,一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口。例如:

 const MyComponent = {
   // 组件名称,可选
   name: 'MyComponent',
   // 组件的渲染函数,其返回值必须为虚拟 DOM
   render() {
     // 返回虚拟 DOM
     return {
       type: 'div',
       children: `我是文本内容`
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12

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

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

​ 渲染器中真正完成组件渲染任务的是 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)
 }
1
2
3
4
5
6
7
8
9
10

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

以对象来描述组件,组件对象要渲染的内容写在 render 方法里,render 方法返回虚拟 DOM 给 renderer 渲染器渲染。

利用 mountComponent 和 patchComponent 来更新组件。

# 组件状态与自更新

​ 前面我们完成了组件的初始渲染。接下来,我们尝试为组件设计自身的状态:

 const MyComponent = {
   name: 'MyComponent',
   // 用 data 函数来定义组件自身的状态
   data() {
     return {
       foo: 'hello world'
     }
   },
   render() {
     return {
       type: 'div',
       children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

约定用户必须使用 data 函数来定义组件自身的状态,同时可以在渲染函数中通过 this 访问由 data 函数返回的状态数据。(数据是组件的状态)

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

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data } = componentOptions

   // 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
   const state = reactive(data())
   // 调用 render 函数时,将其 this 设置为 state,
   // 从而 render 函数内部可以通过 this 访问组件自身状态数据
   const subTree = render.call(state, state)
   patch(null, subTree, container, anchor)
 }
1
2
3
4
5
6
7
8
9
10
11

​ 实现组件自身状态的初始化需要两个步骤:

  • 通过组件的选项对象取得 data 函数并执行,然后调用 reactive 函数将 data 函数返回的状态包装为响应式数据;(实现数据的响应式状态)
  • 在调用 render 函数时,将其 this 的指向设置为响应式数据 state,同时将 state 作为 render 函数的第一个参数传递。(用 call 将 this 设为响应处理过后的 data,所以我们才可以在 <template> 中用 this.xxx 取得 data 中的数据。)

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

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

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data } = componentOptions

   const state = reactive(data())

   // 将组件的 render 函数调用包装到 effect 内
   effect(() => {
     const subTree = render.call(state, state)
     patch(null, subTree, container, anchor)
   })
 }
1
2
3
4
5
6
7
8
9
10
11
12

这样,一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。

​ 但是,由于 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())
       } finally {
         // 重置状态
         isFlushing = false
         queue.clear = 0
       }
     })
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

上面是调度器的最小实现,本质上利用了微任务的异步执行机制,实现对副作用函数的缓冲。其中 queueJob 函数是调度器最主要的函数,用来将一个任务或副作用函数添加到缓冲队列中,并开始刷新队列。

解析:因为 effect 的执行是同步的,queueJob 作为调度器函数时,会收集副作用函数,一开始因为 isFlushing 是 false,所以会先将 p.then 放进微任务队列中,在栈被清空前,如果期间都有触发副作用函数 effect,那么都会被收集到 queue 中,之后等到所有的同步任务执行完后,再将 p.then 从微任务中取出执行。

(Vue 对响应式数据使用异步队列来实现只执行一次,优化性能。)

​ 有了 queueJob 函数之后,我们可以在创建渲染副作用时使用它:

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data } = componentOptions

   const state = reactive(data())

   effect(() => {
     const subTree = render.call(state, state)
     patch(null, subTree, container, anchor)
   }, {
     // 指定该副作用函数的调度器为 queueJob 即可
     scheduler: queueJob
   })
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

调度函数也就是利用微任务异步执行,实现对副作用函数的缓冲,等待栈清空,取出执行。

​ 不过,上面这段代码存在缺陷。可以看到,我们在 effect 函数内调用 patch 函数完成渲染时,第一个参数总是 null。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。

​ 正确的做法是:每次更新时,都拿新的 subTree 与上一次组件所渲染的 subTree 进行打补丁。为此,我们需要实现组件实例,用它来维护组件整个生命周期的状态,这样渲染器才能够在正确的时机执行合适的操作。

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

组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data),等等。

​ 为了解决前面关于组件更新的问题,我们需要引入组件实例的概念,以及与之相关的状态信息:

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data } = componentOptions

   const state = reactive(data())

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

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

   effect(() => {
     // 调用组件的渲染函数,获得子树
     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
   }, { scheduler: queueJob })
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

使用一个对象来表示组件实例,该对象有三个属性:

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

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

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

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   // 从组件选项对象中取得组件的生命周期函数
   const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions

   // 在这里调用 beforeCreate 钩子
   beforeCreate && beforeCreate()

   const state = reactive(data())

   const instance = {
     state,
     isMounted: false,
     subTree: null
   }
   vnode.component = instance

   // 在这里调用 created 钩子
   created && created.call(state)

   effect(() => {
     const subTree = render.call(state, state)
     if (!instance.isMounted) {
       // 在这里调用 beforeMount 钩子
       beforeMount && beforeMount.call(state)
       patch(null, subTree, container, anchor)
       instance.isMounted = true
       // 在这里调用 mounted 钩子
       mounted && mounted.call(state)
     } else {
       // 在这里调用 beforeUpdate 钩子
       beforeUpdate && beforeUpdate.call(state)
       patch(instance.subTree, subTree, container, anchor)
       // 在这里调用 updated 钩子
       updated && updated.call(state)
     }
     instance.subTree = subTree
   }, { scheduler: queueJob })
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

​ 首先从组件的选项对象中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这其实就是组件生命周期的实现原理。

​ 但实际上,由于可能存在多个同样的组件生命周期钩子,例如来自 mixins 中的生命周期钩子函数,因此我们通常需要将组件生命周期钩子序列化为一个数组,但核心原理不变。

1、beforeCreate ->将 data 变成响应式数据、新建组件实例 instance -> created ->进入2 或 3

2、如果是首次挂载 ->beforeMount ->挂载节点 ->mounted

3、如果不是首次挂载 ->beforeUpdate>更新节点 ->updated

mountComponent 函数内部代码就是一个组件的执行流程:

1、获取组件选项;

2、从组件选项获取个选项:

3、执行 beforeCreate 生命周期;

4、reactive() 初始化 data 和 props;

5、创建实例化;

6、执行 created 生命周期;

7、挂载,执行 beforeMount 生命周期;

8、执行 patch 挂载 DOM;

9、执行 mounted 生命周期;

10、更新,执行 beforeUpdate 生命周期;

11、执行 patch 更新 DOM,执行 updated 生命周期。

# props 与组件的被动更新

组件对象的 props 转换成虚拟 DOM 之后也是 VNode 对象的 props 属性,跟 HTML 标签的属性或 DOM 的 property 属性一样,转换成 VNode 之后都会被存储到 VNode 的 props 属性中。

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

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

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

 const vnode = {
   type: MyComponent,
   props: {
     title: 'A big Title',
     other: this.val
   }
 }
1
2
3
4
5
6
7

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

 const MyComponent = {
   name: 'MyComponent',
   // 组件接收名为 title 的 props,并且该 props 的类型为 String
   props: {
     title: String
   },
   render() {
     return {
       type: 'div',
       children: `count is: ${this.title}` // 访问 props 数据
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

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

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

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

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   // 从组件选项对象中取出 props 定义,即 propsOption
   const { render, data, props: propsOption /* 其他省略 */ } = componentOptions

   beforeCreate && beforeCreate()
    
   const state = reactive(data())
   // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
   const [props, attrs] = resolveProps(propsOption, vnode.props)

   const instance = {
     state,
     // 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
     props: shallowReactive(props),
     isMounted: false,
     subTree: null
   }
   vnode.component = instance

   // 省略部分代码
 }

 // resolveProps 函数用于解析组件 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 {
       // 否则将其作为 attrs
       attrs[key] = propsData[key]
     }
   }

   // 最后返回 props 与 attrs 数据
   return [ props, attrs ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

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

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

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

 <template>
   <MyComponent :title="title"/>
 </template>
1
2
3

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

 // 父组件要渲染的内容
 const vnode = {
   type: MyComponent,
   props: {
     title: 'A Big Title'
   }
 }
1
2
3
4
5
6
7

当响应式数据 title 发生变化时,父组件的渲染函数会重新执行。

​ 假设 title 的值变为字符串 "A Small Title",那么新产生的虚拟 DOM 为:

 // 父组件要渲染的内容
 const vnode = {
   type: MyComponent,
   props: {
     title: 'A Small Title'
   }
 }
1
2
3
4
5
6
7

​ 接着,父组件会进行自更新。在更新过程中,渲染器发现父组件的 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)
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

其中,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]
     }
   }
 }

 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
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

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

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

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

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

 function mountComponent(vnode, container, anchor) {
   // 省略部分代码

   const instance = {
     state,
     props: shallowReactive(props),
     isMounted: false,
     subTree: null
   }

   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) {
         console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
       } else {
         console.error('不存在')
       }
     }
   })

   // 生命周期函数调用时要绑定渲染上下文对象
   created && created.call(renderContext)

   // 省略部分代码
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

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

proxy 代理组件实例,优先尝试读取 state,如果找不到,再从 props 中读取。

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

# setup 函数的作用与实现

​ 组件的 setup 函数是 Vue.js 3 新增的组件选项,它有别于 Vue.js 2 中存在的其他组件选项。这是因为 setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。

​ 在组件的整个生命周期中,setup 函数只会在被挂载时执行一次,它的返回值可以有两种情况。

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

 const Comp = {
   setup() {
     // setup 函数可以返回一个函数,该函数将作为组件的渲染函数
     return () => {
       return { type: 'div', children: 'hello' }
     }
   }
 }
1
2
3
4
5
6
7
8

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

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

 const Comp = {
   setup() {
     const count = ref(0)
     // 返回一个对象,对象中的数据会暴露到渲染函数中
     return {
       count
     }
   },
   render() {
     // 通过 this 可以访问 setup 暴露出来的响应式数据
     return { type: 'div', children: `count is: ${this.count}` }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13

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

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

 const Comp = {
   props: {
     foo: String
   },
   setup(props, setupContext) {
     props.foo // 访问传入的 props 数据
     // setupContext 中包含与组件接口相关的重要数据
     const { slots, emit, attrs, expose } = setupContext
     // ...
   }
 }
1
2
3
4
5
6
7
8
9
10
11

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

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

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

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

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   // 从组件选项中取出 setup 函数
   let { render, data, setup, /* 省略其他选项 */ } = componentOptions

   beforeCreate && beforeCreate()

   const state = data ? reactive(data()) : null
   const [props, attrs] = resolveProps(propsOption, vnode.props)

   const instance = {
     state,
     props: shallowReactive(props),
     isMounted: false,
       subTree: null
   }

   // setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要 attrs
   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.component = instance

   const renderContext = new Proxy(instance, {
     get(t, k, r) {
       const { state, props } = t
       if (state && k in state) {
         return state[k]
       } else if (k in 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) {
         console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
       } else if (setupState && k in setupState) {
         // 渲染上下文需要增加对 setupState 的支持
         setupState[k] = v
       } else {
         console.error('不存在')
       }
     }
   })

   // 省略部分代码
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

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

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

# 组件事件与 emit 的实现

​ emit 用来发射组件的自定义事件

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

     return () => {
       return // ...
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11

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

<MyComponent @change="handler" />
1

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

 const CompVNode = {
   type: MyComponent,
   props: {
     onChange: handler
   }
 }
1
2
3
4
5
6

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

将函数作为属性传递和监听自定义事件无本质区别,但自定义事件需要预先声明 emits。

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

 function mountComponent(vnode, container, anchor) {
   // 省略部分代码

   const instance = {
     state,
     props: shallowReactive(props),
     isMounted: false,
     subTree: null
   }

   // 定义 emit 函数,它接收两个参数
   // event: 事件名称
   // payload: 传递给事件处理函数的参数
   function emit(event, ...payload) {
     // 根据约定对事件名称进行处理,例如 change --> onChange
     const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
     // 根据处理后的事件名称去 props 中寻找对应的事件处理函数
     const handler = instance.props[eventName]
     if (handler) {
       // 调用事件处理函数并传递参数
       handler(...payload)
     } else {
       console.error('事件不存在')
     }
   }

   // 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
   const setupContext = { attrs, emit }

   // 省略部分代码
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

整体实现并不复杂,只需要实现一个 emit 函数并将其添加到 setupContext 对象中,这样用户就可以通过 setupContext 取得 emit 函数了。

另外,当 emit 函数被调用时,我们会根据约定对事件名称进行转换,以便能够在 props 数据对象中找到对应的事件处理函数。

最后,调用事件处理函数并透传参数即可。

​ 注意,前面提到,任何没有显式地声明为 props 的属性都会存储到 attrs 中。换句话说,任何事件类型的 props,即 onXxx 类的属性,都不会出现在 props 中。

​ 这导致我们无法根据事件名称在 instance.props 中找到对应的事件处理函数。为了解决这个问题,我们需要在解析 props 数据的时候对事件类型的 props 做特殊处理:

 function resolveProps(options, propsData) {
   const props = {}
   const attrs = {}
   for (const key in propsData) {
     // 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
     if (key in options || key.startsWith('on')) {
       props[key] = propsData[key]
     } else {
       attrs[key] = propsData[key]
     }
   }

   return [ props, attrs ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

# 插槽的工作原理与实现

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

 <template>
   <header><slot name="header" /></header>
   <div>
     <slot name="body" />
   </div>
   <footer><slot name="footer" /></footer>
 </template>
1
2
3
4
5
6
7

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

 <MyComponent>
   <template #header>
     <h1>我是标题</h1>
   </template>
   <template #body>
     <section>我是内容</section>
   </template>
   <template #footer>
     <p>我是注脚</p>
   </template>
 </MyComponent>
1
2
3
4
5
6
7
8
9
10
11

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

 // 父组件的渲染函数
 function render() {
   return {
     type: MyComponent,
     // 组件的 children 会被编译成一个对象
     children: {
       header() {
         return { type: 'h1', children: '我是标题' }
       },
       body() {
         return { type: 'section', children: '我是内容' }
       },
       footer() {
         return { type: 'p', children: '我是注脚' }
       }
     }
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

可以看到,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容

解析:这是在父组件的 render 方法中,VNode 的 type 类型是组件对象 MyComponent,VNode 的 children 是父组件的插槽内容。

​ 组件 MyComponent 的模板则会被编译为如下渲染函数:

 // MyComponent 组件模板的编译结果
 function render() {
   return [
     {
       type: 'header',
       children: [this.$slots.header()]
     },
     {
       type: 'body',
       children: [this.$slots.body()]
     },
     {
       type: 'footer',
       children: [this.$slots.footer()]
     }
   ]
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

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

 function mountComponent(vnode, container, anchor) {
   // 省略部分代码

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

   // 将 slots 对象添加到 setupContext 中
   const setupContext = { attrs, emit, slots }

 }
1
2
3
4
5
6
7
8
9
10

可以看到,最基本的 slots 的实现非常简单。只需要将编译好的 vnode.children 作为slots 对象,然后将 slots 对象添加到 setupContext 对象中。

​ 为了在 render 函数内和生命周期钩子函数内能够通过 this.$slots 来访问插槽内容,我们还需要在 renderContext 中特殊对待 $slots 属性:

 function mountComponent(vnode, container, anchor) {
   // 省略部分代码

   const slots = vnode.children || {}

   const instance = {
     state,
     props: shallowReactive(props),
     isMounted: false,
     subTree: null,
     // 将插槽添加到组件实例上
     slots
   }

   // 省略部分代码

   const renderContext = new Proxy(instance, {
     get(t, k, r) {
       const { state, props, slots } = t
       // 当 k 的值为 $slots 时,直接返回组件实例上的 slots
       if (k === '$slots') return slots

       // 省略部分代码
     },
     set (t, k, v, r) {
       // 省略部分代码
     }
   })

   // 省略部分代码
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

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

# 注册生命周期

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

 import { onMounted } from 'vue'

 const MyComponent = {
   setup() {
     onMounted(() => {
       console.log('mounted 1')
     })
     // 可以注册多个
     onMounted(() => {
       console.log('mounted 2')
     })
     // ...
   }
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在 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
 }
1
2
3
4
5
6

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

 function mountComponent(vnode, container, anchor) {
   // 省略部分代码

   const instance = {
     state,
     props: shallowReactive(props),
     isMounted: false,
     subTree: null,
     slots,
     // 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
     mounted: []
   }

   // 省略部分代码

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

   // 在调用 setup 函数之前,设置当前组件实例
   setCurrentInstance(instance)
   // 执行 setup 函数
   const setupResult = setup(shallowReadonly(instance.props), setupContext)
   // 在 setup 函数执行完毕之后,重置当前组件实例
   setCurrentInstance(null)

   // 省略部分代码
 }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

为了存储由 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 中调用')
   }
 }
1
2
3
4
5
6
7
8

整体实现非常简单直观。只需要通过 currentInstance 取得当前组件实例,并将生命周期钩子函数添加到当前实例对象的 instance.mounted 数组中即可。

另外,如果当前实例不存在,则说明用户没有在 setup 函数内调用 onMounted 函数,这是错误的用法,因此我们应该抛出错误及其原因。

附:

在 setup 中注册 hook,必须是同步的,因为注册的 compositionAPl 是注册到当前执行 setup 的组件对象实例中的,如果是异步的,那么异步任务会等到 setup 这个同步任务执行完毕之后才执行,这时候 currentInstance 已经被置为 null 了。

而这时候执行再执行 onMounted 等 hook 时,在 hook 中需要使用到当前实例 currentInstance 来注册 hook,这时候 currentInstance 为 null 就会出现问题了。

所以不能异步注册 hook,Vue 会发出警告。

结论:无论是微任务还是定时器宏任务,异步注册 hook 都不行,微任务是在当前宏任务结束之前的时候执行,setup 已经执行完毕,currentInstance 为 null,已经不能给当前的组件实例注册 hook,定时器更加不行,是在下一次循环,当前栈清空之后才处理,所以等到执行 hook 的时候,currentInstance 也为 null,所以也注册不了 hook。

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

 function mountComponent(vnode, container, anchor) {
     // 省略部分代码

     effect(() => {
       const subTree = render.call(renderContext, renderContext)
       if (!instance.isMounted) {
         // 省略部分代码

         // 遍历 instance.mounted 数组并逐个执行即可
         instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
       } else {
         // 省略部分代码
       }
       instance.subTree = subTree
     }, {
       scheduler: queueJob
     })
   }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

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

# 总结

  • 使用虚拟节点的 vnode.type 属性来存储组件对象,渲染器根据虚拟节点的该属性的类型来判断它是否是组件。如果是组件,则渲染器会使用 mountComponent 和 patchComponent 来完成组件的挂载和更新。
  • 在组件挂载阶段,会为组件创建一个用于渲染其内容的副作用函数。该副作用函数会与组件自身的响应式数据建立响应联系。当组件自身的响应式数据发生变化时,会触发渲染副作用函数重新执行,即重新渲染。
  • 但由于默认情况下重新渲染是同步执行的,这导致无法对任务去重,因此在创建渲染副作用函数时,指定了自定义的调用器。该调度器的作用是,当组件自身的响应式数据发生变化时,将渲染副作用函数缓冲到微任务队列中。有了缓冲队列,我们即可实现对渲染任务的去重,从而避免无用的重新渲染所导致的额外性能开销。
  • 组件实例本质上是一个对象,包含了组件运行过程中的状态,例如组件是否挂载、组件自身的响应式数据,以及组件所渲染的内容(即 subtree)等。有了组件实例后,在渲染副作用函数内,我们就可以根据组件实例上的状态标识,来决定应该进行全新的挂载,还是应该打补丁。
  • 副作用自更新所引起的子组件更新叫作子组件的被动更新。
  • 渲染上下文(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 函数。

执行自己的 render 方法即渲染组件内容,在执行 render 方法的时候包裹一层 effect 副作用函数,这样 render 方法中使用到的响应式变量发生了变化就会触发当前 effect 的执行,又因为加入了调度器,所以在响应式变量发生变化时,执行调度器函数,在调度器里执行 effect 回调。

为什么要这么做呢?

假如现在在组件内的 setup 中 for 循环修改 100 次响应式变量,此时不使用调度器的话那就会触发 effect 100次,而我们只需要最后一次更新即可,没必要更新100 次,所以我们使用调度器就可以将 for 循环 100 次修改触发执行的 effect 回调添加到微任务中去处理,用 set 集合来存储有去重作用,这样等当前宏任务 for 循环 100 次执行完成后执行微任务,此时只触发更新一次 effect。

上次更新: 2024/9/12 01:29:11