1. 声明式地描述 UI
1.1 声明式地描述 UI
- 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 标签时可以使用
<div></div> - 使用与 HTML 标签一致的方式来描述属性,例如
<div id="app"></div> - 使用
:或v-bind来描述动态绑定的属性,例如<div :id="dynamicId"></div> - 使用
@或v-on来描述事件,例如点击事件<div @click="handler"></div> - 使用与 HTML 标签一致的方式来描述层级结构,例如一个具有 span 子节点的 div 标签
<div><span></span></div>
可以看到,在 Vue.js 中,哪怕是事件,都有与之对应的描述方式。用户不需要手写任何命令式代码,这就是所谓的声明式地描述 UI。
1.2 使用 JS 对象描述 UI
除了上面这种使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述:
const title = {
// 标签名称
tag: 'h1', // 标签属性
props: {
onClick: handler
}, // 子节点
children: [{tag: 'span'}]
}
1.3 模板和 JS 对象有何不同
使用 JavaScript 对象描述 UI 更加灵活。
// h 标签的级别
let level = 3
const title = {
tag: `h${level}`, // h3 标签
}
使用 JavaScript 对象来描述 UI 的方式,其实就是所谓的虚拟 DOM。
其实我们在 Vue.js 组件中手写的渲染函数就是使用虚拟 DOM 来描述 UI 的,如以下代码所示:
import {h} from 'vue'
export default {
render() {
return h('h1', {onClick: handler})// 虚拟 DOM
}
}
h 函数就是一个辅助创建虚拟 DOM 的工具函数。
另外,这里有必要解释一下 什么是组件的渲染函数。
一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。

2. 初识渲染器
虚拟 DOM 其实就是用 JavaScript 对象来描述真实的 DOM 结构。
虚拟 DOM 是如何变成真实 DOM 并渲染到浏览器页面中的呢?
这就用到了渲染器。渲染器的作用就是把虚拟 DOM 渲染为真实 DOM。
2.1 编写一个渲染器
function renderer(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
// 事件名称 onClick ---> click
el.addEventListener(key.substr(2).toLowerCase(),
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
这里的 renderer 函数接收如下两个参数:
- vnode:虚拟 DOM 对象
- container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。
接下来,我们可以调用 renderer 函数:
renderer(vnode, document.body) // body 作为挂载点
渲染器 renderer 的实现思路,总体来说分为三步
- 创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素
- 为元素添加属性和事件:遍历 vnode.props 对象,如果 key 以 on 字符开头,说明它是一个事件
- 处理 children:如果 children 是一个数组,就递归地调用 renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为挂载点(父节点);如果 children 是字符串,则使用 createTextNode 函数创建一个文本节点,并将其添加到新创建的元素内。
3. 组件的本质
虚拟 DOM 其实就是用来描述真实 DOM 的普通 JavaScript对象,渲染器会把这个对象渲染为真实 DOM 元素。
那么组件又是什么呢?
组件和虚拟 DOM 有什么关系?
渲染器如何渲染组件?
虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。
例如使用 { tag: 'div' } 来描述 <div> 标签,但是组件并不是真实的 DOM 元素,那么如何使用虚拟 DOM 来描述呢?
想要弄明白这个问题,就需要先搞清楚组件的本质是什么。一句话总结:组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,
因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义用虚拟 DOM 来描述组件了。 很简单,我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数
const vnode = {
tag: MyComponent
}
就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名称,而是组件函数。
为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数,如下所示:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
其中 mountElement 函数与上文中 renderer 函数的内容一致,再来看 mountComponent 函数是如何实现的:
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
首先调用 vnode.tag 函数,我们知道它其实就是组件函数本身,其返回值是虚拟 DOM,即组件要渲染的内容,这里我们称之为 subtree。
既然 subtree 也是虚拟 DOM,那么直接调用 renderer 函数完成渲染即可。
这里希望大家能够做到举一反三,例如组件一定得是函数吗?当然不是,我们完全可以使用一个 JavaScript 对象来表达组件,例如:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
该对象有一个函数,叫作 render,其返回值代表组件要渲染的内容。
为了完成组件的渲染,我们需要修改 renderer 渲染器以及 mountComponent 函数。首先,修改渲染器的判断条件:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
接着,修改 mountComponent 函数:
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
在上述代码中,vnode.tag 是表达组件的对象,调用该对象的 render 函数得到组件要渲染的内容,也就是虚拟DOM。
4. 模板的工作原理
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述UI 的方式。
上文中我们讲解了虚拟 DOM 是如何渲染成真实 DOM 的,那么模板是如何工作的呢?
这就要提到Vue.js 框架中的另外一个重要组成部分:编译器。
编译器的作用其实就是将模板编译为渲染函数,对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
}
}
</script>
其中 <template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上,所以最终在浏览器里运行的代码就是:
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。
假设我们有如下模板:
<div id="foo" :class="cls"></div>
根据上文的介绍,我们知道编译器会把这段代码编译成渲染函数:
render() {
// 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
// 下面的代码等价于:
// return h('div', { id: 'foo', class: cls })
return {
tag: 'div',
props: {
id: 'foo',
class: cls
}
}
}
可以发现,在这段代码中,cls 是一个变量,它可能会发生变化。
我们知道渲染器的作用之一就是寻找并且只更新变化的内容,所以当变量 cls 的值发生变化时,渲染器会自行寻找变更点。
对于渲染器来说,这个“寻找”的过程需要花费一些力气。那么从编译器的视角来看,它能否知道哪些内容会发生变化呢?
如果编译器有能力分析动态内容,并在编译阶段把这些信息提取出来,然后直接交给渲染器,这样渲染器不就不需要花费大力气去寻找变更点了吗?
这是个好想法并且能够实现。
Vue.js 的模板是有特点的,拿上面的模板来说,
我们一眼就能看出其中 id="foo" 是永远不会变化的,
而 :class="cls" 是一个 v-bind 绑定,它是可能发生变化的。所以编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码的时候完全可以附带这些信息:
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
patchFlags: 1 // 假设数字 1 代表 class 是动态的
}
}
如上面的代码所示,在生成的虚拟 DOM 对象中多出了一个 patchFlags 属性,
我们假设数字 1 代表 “class 是动态的”,
这样渲染器看到这个标志时就知道:“哦,原来只有 class 属性会发生改变。”
对于渲染器来说,就相当于省去了寻找变更点的工作量,性能自然就提升了。
通过这个例子,我们了解到编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟 DOM 对象。
