渲染器主要负责将虚拟 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 函数的第一个参数传递。
经过上述两步工作后,我们就实现了对组件自身状态的支持,以及在渲染函数内访问组件自身状态的能力。