框架设计讲究全局视角的把控,一个项目就算再大,也是存在一条核心思路的,并围绕核心展开。
# 声明式地描述 UI
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。
# 模版
Vue.js 3 是一个声明式的 UI 框架,用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。在 Vue.js 中,哪怕是事件,都有与之对应的描述方式。用户不需要手写任何命令式代码,这就是所谓的声明式地描述 UI。
比如:
- 使用与 HTML 标签一致的方式来描述 DOM 元素,例如描述一个 div 都是使用
<div></div>
; - 使用与 HTML 标签一致的方式来描述属性,例如
<div id="root"></div>
; - 使用
:
或v-bind
来描述动态绑定的属性,例如<div :class="cls"></div>
; - 使用
@
或v-on
来描述事件,例如<div @click="handler"></div>
; - 使用与 HTML 标签一致的方式来描述层级结构,例如
<div><span></span></div>
。
# 虚拟 DOM
除了可以使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述:
const title = {
// 标签名称
tag: 'h1',
// 标签属性
props: {
onClick: handler
},
// 子节点
children: [
{ tag: 'span' }
]
}
2
3
4
5
6
7
8
9
10
11
12
以上 JavaScript 对象,对应到 Vue.js 模板,其实就是:
<h1 @click="handler"><span></span></h1>
使用 JavaScript对象描述 UI 更加灵活。比如,我们要表示一个标题,根据标题级别的不同,会分别采用 h1~h6 这几个标签,如果用 JavaScript 对象来描述,我们只需要使用一个变量来代表 h 标签即可:
// h 标签的级别
let level = 3
const title = {
tag: `h${level}`, // h3 标签
}
2
3
4
5
可以看到,当变量 level 值改变,对应的标签名字也会在 h1 和 h6 之间变化。但是如果使用模板来描述,就不得不穷举:
<h1 v-if="level === 1"></h1>
<h2 v-else-if="level === 2"></h2>
<h3 v-else-if="level === 3"></h3>
<h4 v-else-if="level === 4"></h4>
<h5 v-else-if="level === 5"></h5>
<h6 v-else-if="level === 6"></h6>
2
3
4
5
6
这远没有 JavaScript 对象灵活。使用 JavaScript 对象来描述 UI 的方式,其实就是所谓的虚拟 DOM。正是因为虚拟 DOM 的这种灵活性,Vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。
其实我们在 Vue.js 组件中手写的渲染函数就是使用虚拟 DOM 来描述 UI 的:
import { h } from 'vue'
export default {
render() {
return h('h1', { onClick: handler }) // 虚拟 DOM
}
}
2
3
4
5
6
7
h() 函数就是一个辅助创建虚拟 DOM 的工具函数,其返回值是一个对象,h() 函数的作用就是把 DOM 描述成 JavaScript 对象(虚拟 DOM ),让我们编写虚拟 DOM 变得更加轻松。
如果把上面 h() 函数调用的代码改成 JavaScript 对象,就需要写更多内容(如果还有子节点,那么需要编写的内容就更多了):
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}
2
3
4
5
6
7
8
一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。
虚拟 DOM 是如何渲染成真实 DOM 的
render 渲染函数的返回值就是虚拟 DOM 对象,renderer 渲染器函数就是将虚拟 DOM 变成真实 DOM。
虚拟 DOM 其实就是用来描述真实 DOM 的普通 JavaScript 对象,渲染器会把这个对象渲染为真实 DOM 元素。
# 初识渲染器
**渲染器的作用就是把虚拟 DOM 渲染为真实 DOM。**我们平时编写的 Vue.js 组件都是依赖渲染器来工作的。
脚本式,偏向解决问题的步骤:第一步做什么,第二步做什么,笑第三步做什么。
声明式,直接描述你要做什么就好,别的都不用管,让声明式框架帮你去做。
渲染器的核心原理,就是使用原生 JavaScript ,把虚拟 DOM 对象解析为真实 DOM 标签的过程。
假设我们有如下虚拟 DOM:
const vnode = {
// 用来描述标签名称
tag: 'div',
// 用来描述标签的属性、事件等内容
props: {
onClick: () => alert('hello')
},
// 用来描述标签的子节点
children: 'click me'
}
2
3
4
5
6
7
8
9
10
实际上,你完全可以自己设计虚拟 DOM 的结构,例如可以使用 tagName 代替tag,因为它本身就是一个 JavaScript 对象,并没有特殊含义。
我们需要编写一个渲染器,把上面这段虚拟 DOM 渲染为真实 DOM:
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 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
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)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
接下来,我们可以调用 renderer 函数:
renderer(vnode, document.body) // body 作为挂载点
vnode:虚拟 DOM 对象。
container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。
在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出alert('hello')
现在我们回过头来分析渲染器 renderer 的实现思路,总体来说,创建节点分为三步:
1、创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素。
2、为元素添加属性和事件:遍历 vnode.props 对象,如果 key 以 on 字符开头,说明它是一个事件,把字符 on 截取掉后再调用 toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成 click,最后调用addEventListener 绑定事件处理函数。
3、处理 children:如果 children 是一个数组,就递归地调用 renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为挂载点(父节点);如果 children是字符串,则使用 createTextNode 函数创建一个文本节点,并将其添加到新创建的元素内。
渲染器的精髓在于更新节点的阶段:
假设我们对 vnode 做一些小修改,对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。
const vnode = { tag: 'div', props: { onClick: () => console.log('Hello world.') }, children: 'Click again' // 从 Click me 变成 Click again }
1
2
3
4
5
6
7渲染器的作用之一就是寻找并且只更新变化的内容。
归根结底,渲染器的工作原理,其实都是使用一些我们熟悉的 DOM 操作 API 来完成渲染工作。
# 组件的本质
虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容。
# 用函数结构描述
我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
2
3
4
5
6
7
8
9
可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。
搞清楚了组件的本质,我们就可以定义用虚拟 DOM 来描述组件了。我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数:
const vnode = {
tag: MyComponent // 描述组件,此时的tag属性不是标签名称,而是组件函数
}
2
3
就像
{ 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)
}
}
2
3
4
5
6
7
8
9
如果 vnode.tag 的类型是字符串,说明它描述的是普通标签元素,此时调用 mountElement 函数完成渲染;
如果 vnode.tag 的类型是函数(或对象),则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。
其中 mountElement 函数与上文中 renderer 函数的内容一致:
function mountElement(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 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick--->click
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)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
再来看 mountComponent 函数是如何实现的:
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
2
3
4
5
6
首先调用 vnode.tag 函数,它其实就是组件函数本身,其返回值是虚拟DOM,即组件要渲染的内容,这里我们称之为 subtree。
既然 subtree 也是虚拟 DOM,那么直接调用renderer 函数完成渲染即可。
这样子,我们的 renderer()
函数也支持组件的渲染了。
# 用对象结构描述
组件不一定得是函数,我们也可以用一个 JavaScript 对象来表达组件,例如:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
2
3
4
5
6
7
8
9
10
11
12
该对象有一个
render
函数,其返回值代表组件要渲染的内容,是一个虚拟 DOM。
为了完成这种组件的渲染,我们需要修改渲染器 renderer() 函数以及 mountComponent() 函数。
首先,修改渲染器的判断条件:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') { // 如果是对象,说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
2
3
4
5
6
7
现在我们使用对象而不是函数来表达组件,因此要将 typeof vnode.tag=== 'function' 修改为 typeof vnode.tag === 'object'。
接着,修改 mountComponent 函数:
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的render函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
2
3
4
5
6
vnode.tag 是表达组件的对象,调用该对象的 render 函数得到组件要渲染的内容,也就是虚拟 DOM。
其实 Vue.js 中的有状态组件就是使用对象结构来表达的。
有状态组件 | 无状态组件 |
---|---|
指具有自己的状态(数据)的组件。通常使用对象结构(即 Vue 实例)来表达。这些组件可以使用 Vue 实例的数据、计算属性、生命周期钩子等功能,从而能够在组件内部保存和处理数据,并随着数据的改变而更新界面。 | 指没有自己的状态(数据)的组件。比如子组件通过 props 属性来获取父组件的属性,并且在函数中通过 this.$emit() 向父组件传递数据,而子组件本身并不保存数据和状态。无状态组件其实就是函数结构。 |
使用渲染函数渲染的是虚拟 DOM,执行 render 函数,会将虚拟 DOM 对象中的标签、属性、子标签分别处理。通过标签创建元素,通过属性创建事件、样式等,通过子标签处理 DOM 层级关系。如果渲染的是组件,则实现步骤是:执行组件中返回 DOM 对象的函数,再执行解析渲染页面的 render 方法。
# 模板的工作原理
将模板转换成虚拟 DOM 的工作是由 Vue.js 框架中的另一个重要组件部分:编译器,来实现的。编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同,编译器的作用其实就是将模板编译为渲染函数(然后添加为组件的属性)。
例如,给出如下模板:
<div @click="handler">
click me
</div>
2
3
对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:
render() {
return h('div', { onClick: handler }, 'click me')
}
2
3
以我们熟悉的 .vue 文件为例,一个 .vue 文件就是一个组件,如下所示:
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
其中 <template></template>
标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script>
标签块的组件对象上。所以最终在浏览器里运行的代码就是:
export default {
data() {/* ... */},
methods: {
handler: () => {/* ... */}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
2
3
4
5
6
7
8
9
我们可以整理一下这个过程:
1、编译器将 template 解析为 render 渲染函数。
2、渲染函数会返回虚拟 DOM 对象。
3、渲染器负责把虚拟 DOM 渲染为真实 DOM。
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是Vue.js 渲染页面的流程。
编译器将模板转换为虚拟 DOM,然后渲染器将虚拟 DOM 转化为真实 DOM。
组件可以是不同格式,比如函数、对象等约定格式,是对一组 DOM 元素的封装,渲染器就是按照一定的格式把组件的数据集转成真实 DOM。
# Vue.js 是各个模块组成的有机整体
组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的。因此,Vue.js 的各个模块之间是互相关联、互相制约的,共同构成一个有机整体。
假设我们有如下模版:
<div id="foo" :class="cls"></div>
编译器会把这段代码编译成渲染函数:
01 render() {
02 // 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
03 // 下面的代码等价于:
04 // return h('div', { id: 'foo', class: cls })
05 return {
06 tag: 'div',
07 props: {
08 id: 'foo',
09 class: cls
10 }
11 }
12 }
2
3
4
5
6
7
8
9
10
11
12
这段代码中的 cls 是一个变量,它随时可能发生变化。渲染器的作用之一就是寻找并且只更新变化的内容,当变量 cls 的值发生变化时,渲染器会自行寻找变更点,但对于渲染器来说,这个“寻找”的过程需要花费一些力气。而从编译器的角度看,可以很容易的看出模板中会出现变化的点。
编译器能识别出哪些是静态属性,哪些是动态属性,在生成代码的时候完全可以附带这些信息:
01 render() {
02 return {
03 tag: 'div',
04 props: {
05 id: 'foo',
06 class: cls
07 },
08 patchFlags: 1 // 假设数字 1 代表 class 是动态的
09 }
10 }
2
3
4
5
6
7
8
9
10
在生成的虚拟 DOM 对象中多出了一个 patchFlags 属性,用于描述某个属性是动态的,可能会发生变化的。这样,渲染器就省去了寻找变更点的工作量,性能自然就提升了。
可见,编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟DOM 对象。
# 总结
- Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。
- Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟DOM 更加直观。
- 渲染器的作用是把虚拟DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。
- 组件其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。
- 渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,我们称之为 subtree,最后再递归地调用渲染器将subtree 渲染出来即可。
- 编译器的作用是把模板编译为渲染函数。
- 编译器、渲染器都是 Vue.js 的核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。