渲染器主要负责将虚拟 DOM 渲染为真实 DOM,我们只需要使用虚拟 DOM 来描述最终呈现的内容即可。但当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。这时,我们就需要组件化的能力。有了组件,我们就可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持,从现在开始,我们将详细讨论 Vue.js 中的组件化。
从用户的角度来看,一个有状态组件就是一个选项对象,如下面的代码所示:
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 }
这样,我们就实现了最基本的组件化方案。
在上一节中,我们完成了组件的初始渲染。接下来,我们尝试为组件设计自身的状态,如下面的代码所示:
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 }
如上面的代码所示,实现组件自身状态的初始化需要两个步骤:
经过上述两步工作后,我们就实现了对组件自身状态的支持,以及在渲染函数内访问组件自身状态的能力。
当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个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 进行打补丁。为此,我们需要实现组件实例,用它来维护组件整个生命周期的状态,这样渲染器才能够在正确的时机执行合适的操作。
组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(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 }
在上面这段代码中,我们使用一个对象来表示组件实例,该对象有三个属性:
实际上,我们可以在需要的时候,任意地在组件实例 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 中的生命周期钩子函数,因此我们通常需要将组件生命周期钩子序列化为一个数组,但核心原理不变。
在虚拟 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 数据,具体实现如下:
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 数据。这里需要注意两点。
处理完 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 函数用来完成子组件的更新。我们把由父组件自更新所引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,我们需要做的是:
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 }
上面是组件被动更新的最小实现,有两点需要注意:
在上面的实现中,我们没有处理 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 等选项中定义的数据和方法,这些内容都应该在渲染上下文对象中处理。
组件的 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 对象,其中保存着与组件接口相关的数据和方法,如下所示:
通常情况下,不建议将 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 函数的最小实现,这里有以下几点需要注意:
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 对象中。
顾名思义,组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入,如下面给出的 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
来访问插槽内容了。
在 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 以外的生命周期钩子函数,其原理同上。