vue.js 3 的设计思路
从全局视角了解 vue.js 3 的设计思路、工作机制及其重要的组成部分。
声明式 UI
vue.js 是一个声明式的 UI 框架,用户在使用 vue.js 3 开发页面时是声明式地描述 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>
。
除了使用模板来声明式地描述 UI 之外,还可以使用 JavaScript 对象来描述。
const title = {
tag: 'h1',
props: {
onClick: handler
},
children: [
{ tag: 'span' }
]
}
对应到 vue.js 模板
<h1 @click="handler">
<span></span>
</h1>
两种方式对比,使用 JavaScript 对象描述 UI 更加灵活。比如我们要表示一个标题,根据标题级别不同,采用 h1~h6
这几个标签。
const level = 3;
const title = {
tag: `h${ level }`
}
<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>
使用 JavaScript 对象描述 UI 的方式,其实就是所谓的虚拟 DOM。
vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。
import { h } from 'vue';
export default {
render() {
return h('h1', { onCllick: handler })
}
}
h 函数的返回值就是一个对象,它的作用是让我们编写虚拟 DOM 更加轻松。h 函数就是一个辅助创建虚拟 DOM 的工具函数。
渲染器
虚拟 DOM 就是用 JavaScript 对象描述真实 DOM 结构,然后再通过渲染器将虚拟 DOM 渲染到页面。
渲染器的作用就是把虚拟 DOM 渲染为真实 DOM。假设我们有以下虚拟 DOM。
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
};
我们可以实现一个渲染器,将上面这段虚拟 DOM 渲染为真实 DOM。
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
};
function renderer (vnode, container) {
const el = document.createElement(vnode.tag);
for (const key in vnode.props) {
if (/^on/.test(key)) {
el.addEventListener(
key.substr(2).toLowerCase(),
vnode.props[key]
)
}
}
if (typeof vnode.children === 'string') {
el.appendChild(document.createTextNode(vnode.children));
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => renderer(child, el));
}
container.appendChild(el);
}
renderer(vnode, document.body);
简单说下渲染器的实现思路:
- 创建元素:以
vnode.tag
作为标签名称来创建 DOM 元素; - 为元素添加属性和事件;
- 处理 children;
我们现在处理的仅仅是创建节点,渲染器的精髓都在更新节点的阶段,
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click again' // click me 改成 click again
};
对于渲染器来说,需要精确找到 vnode
对象的变更点并且只更新变更的内容。
组件的本质
虚拟 DOM 除了可以描述真实 DOM 之外,还可以描述组件。组件本质上就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此我们可以定义一个函数代表组件,函数的返回值就代表组件要渲染的内容。
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
};
组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数。
const vnode = {
tag: MyComponent
};
tag: myComponent
用户描述组件。为了能够渲染组件,我们还需要修改 renderer 函数。
function renderer (vnode, container) {
if (typeof vnode.children === 'string') {
mountElement(vnode, container);
} else if (typeof vnode.tag === 'function') {
mountComponent(vnode, container);
}
}
function mountElement (vnode, container) {
const el = document.createElement(vnode.tag);
for (const key in vnode.props) {
if (/^on/.test(key)) {
el.addEventListener(
key.substr(2).toLowerCase(),
vnode.props[key]
)
}
}
if (typeof vnode.children === 'string') {
el.appendChild(document.createTextNode(vnode.children));
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => renderer(child, el));
}
container.appendChild(el);
}
function mountComponent (vnode, container) {
const subtree = vnode.tag();
renderer(subtree, container);
}
renderer(vnode, document.body);
组件一定是函数吗?我们完全可以使用 JavaScript 对象来表达组件。
const MyComponent = {
render () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
为此,我们还需要修改 renderer 方法和 mountComponent 方法。
function renderer (vnode, container) {
if (typeof vnode.children === 'string') {
mountElement(vnode, container);
} else if (typeof vnode.tag === 'object') {
mountComponent(vnode, container);
}
}
function mountComponent (vnode, container) {
const subtree = vnode.tag.render();
renderer(subtree, container);
}
vue.js 的有状态组件就是使用对象结构来表达的。
模板工作原理
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,vue.js 同时支持这两种描述 UI 的方式。
我们已经知道虚拟 DOM 如何渲染成真实 DOM,那模板是如何工作的,这就要提高 vue.js 另一个重要组成部分,编译器。
编译器的作用就是将模板编译为渲染函数。
<div @click="handler">
click me
</div>
render () {
return h('div', { onClick: handler }, 'click me')
}
对于编译器来说,模板就是一个普通的字符串它会分析该字符串并生成一个功能与之相同的渲染函数。
以 .vue
文件为例,一个 .vue
文件就是一个组件。
<template>
<div @click="handler">
click me
</div>
</template>
<script>
export default {
data() {},
methods: {
handler: () => {}
}
}
</script>
template 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script>
标签块的组件对象上。
<script>
export default {
data() {},
methods: {
handler: () => {}
},
render () {
return h('div', { onClick: handler }, 'click me')
}
}
</script>
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染内容最终都是通过渲染函数产生的,渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 vue.js 渲染页面的流程。
模块组成的有机整体
组件的实现依赖于渲染器,模板的编译依赖于编译器,编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的。
vue.js 各个模块之间是互相关联、互相制约的,共同构成一个有机整体。
下面以编译器和渲染器这两个模块为例,看一下它们是如何配合工作,实现性能提升的。
<div id="foo" :class-"cls"></div>
首先,编译器会把这段代码编译成渲染函数
render() {
// 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
},
patchPlags: 1 // 假设数字 1 代表 class 是动态的
}
}
假设数字 1 代表 “class 是动态的”,渲染器看到这个标志就知道 class 属性会发生改变。对于渲染器来说,相当于省去寻找变更点的工作量,这样就会提升性能。
编译器和渲染器之间互相配合可能让性能进一步提升,它们借助于虚拟 DOM 对象进行配合,虚拟 DOM 对象中会包含多种数据字段,每个字段都代表一定的含义。
总结
vue.js 是一个声明式的框架,它直接描述结果,用户不需要关注过程。vue.js 采用模板的方式来描述 UI,它同样支持使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,模板要比虚拟 DOM 更加直观。
渲染器的作用就是把虚拟 DOM 对象渲染为真实 DOM 元素。它递归地遍历虚拟 DOM 对象,调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续更新,它会通过 diff 算法找出变更点,并且只会更新需要更新的内容。
组件其实就是一组虚拟 DOM 元素的集合,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象。
vue.js 的模板会编译器编译为渲染函数。编译器和渲染器都是 vue.js 的核心组成部分,它们共同构成一个有机整体,不同模板之间互相配合,可以进一步提升框架性能。