Vue.js 3 的设计思路
声明式地描述 UI
用户在使用 Vue.js 的时候,只需要声明式地描述 UI,而不需要关心 DOM 的操作,Vue.js 会根据用户提供的声明式描述自动生成对应的 DOM。它设计需要考虑到编写前端页面时涉及到的几个方面:
- DOM 元素,是
div
还是a
标签 - 属性,
class
类名或者id
、超链接的href
等 - 事件,比如点击事件
click
、鼠标移入事件mouseover
等 - 层级结构,比如
div
包含a
标签等是否有子元素
Vue.js 的相应解决方案是:
- 和 HTML 一样,采取一致的方式描述 DOM 元素,如
<div></div>
- 和 HTML 一样,采取一致的方式描述属性,如
<div id="div1"></div>
- 使用
v-bind
或:
指令描述动态的属性和事件,如<div :id="id"></div>
- 使用
v-on
或@
指令描述事件,如<div @click="handleClick"></div>
- 和 HTML 一样,使用标签嵌套描述层级结构,如
<div><a></a></div>
除了使用模版来声明 UI 之外,Vue.js 还支持使用 JavaScript 对象来描述 UI。
const vnode = {
tag: 'div', // DOM 元素
// 属性
props: {
id: 'div1'
},
// 事件
on: {
click: () => {
console.log('div clicked');
}
},
// 子元素
children: [
{
tag: 'a',
props: {
href: 'https://www.baidu.com'
},
children: 'click me'
}
]
};
与使用模版创建 UI 相比,使用 JavaScript 对象创建 UI 的好处是,描述 UI 更灵活。举个例子,根据一个变量 level
渲染对应的 <h>
标签,使用模版需要写多个标签,而使用 JavaScript 对象则只需要一个对象:
const level = 1;
const vnode = {
tag: `h${level}`,
children: 'Hello, world'
};
<h1 v-if="level === 1">Hello, world</h1>
<h2 v-else-if="level === 2">Hello, world</h2>
<h3 v-else-if="level === 3">Hello, world</h3>
<h4 v-else-if="level === 4">Hello, world</h4>
<h5 v-else-if="level === 5">Hello, world</h5>
<h5 v-else="level === 6">Hello, world</h5>
通过对比可以看出,使用 JavaScript 对象描述 UI 更加灵活,而且可以和 JavaScript 代码无缝结合,比如可以很方便地使用 JavaScript 表达式。而这种方式就是虚拟 DOM,Vue.js 封装了一个 h
函数让使用者更轻松地创建虚拟 DOM。
const vnode = h('div', { id: 'div1' }, [
h('a', { href: 'https://www.baidu.com' }, 'click me')
]);
const vnode = {
tag: `div`,
props: {
id: 'div1'
},
children: [
{
tag: 'a',
props: {
href: 'https://www.baidu.com'
},
children: 'click me'
}
]
};
初识渲染器
渲染器的作用是将虚拟 DOM 渲染为真实 DOM。渲染器是一个函数,接收两个参数:
- 虚拟 DOM
- 挂载 DOM 的容器
渲染器会根据虚拟 DOM 创建真实 DOM,然后将真实 DOM 插入到挂载 DOM 的容器中。
const vnode = {
tag: 'div', // DOM 元素
// 属性
props: {
id: 'div1'
},
// 事件
on: {
click: () => {
console.log('div clicked');
}
},
// 子元素
children: [
{
tag: 'a',
props: {
href: 'https://www.baidu.com'
},
children: 'click me'
}
]
};
renderer(vnode, document.body);
function renderer(vnode, container) {
// 创建真实 DOM
const el = document.createElement(vnode.tag);
// 处理 props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
}
// 处理 children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children;
} else {
vnode.children.forEach(child => {
renderer(child, el);
});
}
}
// 将真实 DOM 插入到容器中
container.appendChild(el);
}
上方代码会把一个 div
包裹 a
标签挂载到 document.body
中,并且给 div
绑定了一个点击事件。来看看它是如何运作的。
- 创建真实 DOM,
vnode.tag
属性作为真实 DOM 元素的标签名 - 处理
props
- 如果是
on
开头,说明该属性是事件,截取后面部分作为事件名,调用addEventListener
方法绑定事件 - 否则,说明该属性是属性,设置属性
- 如果是
- 处理
children
- 如果是字符串,则设置文本内容
- 否则,递归调用渲染器函数,将当前元素作为挂载元素
- 将真实 DOM 插入到容器中
这样渲染器初步就完成了,还有一些改动和优化需求,如修改了 vnode
对象的 children
后,如何快速查找到更新的内容并针对更新,而不是全部重新渲染重走一遍流程。
组件的本质
虚拟 DOM 的本质是描述真实 DOM 的 JavaScript 对象,渲染器是把虚拟 DOM 渲染为真实 DOM 的函数,而组件是一组 DOM 元素的封装,这组 DOMM 元素就是组件要渲染的内容。
此时可以定义一个函数来代表组件,该函数的返回值就是组件要渲染的内容。判断 vnode.tag
是否是函数,如果不是,说明是普通标签,调用 mountElement
函数(和前面的渲染器函数一致)直接创建真实 DOM;反之,则说明该节点是一个组件,递归调用 mountComponent
函数获取组件要渲染的内容,将组件渲染为真实 DOM。
const myComponent = () => {
return {
tag: 'div',
children: 'hello world'
}
}
const vnode = {
tag: myComponent, // 组件
}
function mountElement(vnode, container) {
const el = document.createElement(vnode.tag);
// 处理 props
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
el.setAttribute(key, value);
}
}
}
// 处理 children
if (vnode.children) {
if (typeof vnode.children === 'string') {
el.textContent = vnode.children;
} else {
vnode.children.forEach(child => {
renderer(child, el);
});
}
}
// 将真实 DOM 插入到容器中
container.appendChild(el);
}
function mountComponent(vnode, container) {
const subtree = vnode.tag();
renderer(subtree, container);
}
模版的工作原理
无论是手写虚拟 DOM 渲染函数还是使用模板,都是声明式代码,Vue.js 同时支持这两种方式。使用模板会通过编译器编译为渲染函数,然后用渲染器把渲染函数返回的虚拟 DOM 转为真实 DOM。
<template>
<div @click="handleClick">hello world</div>
</template>
export default {
data() { /* ... */},
methods: {
handleClick() { /* ... */ }
},
render() {
return {
tag: 'div',
props: {
onClick: this.handleClick
},
children: 'hello world'
}
}
/**
* 或者
*/
render() {
eturn h('div', { onClick: this.handleClick }, 'hello world');
}
}
Vue.js是各个模块组成的有机整体
综上所述,组件模板依赖编译器编译成渲染函数返回虚拟 DOM,虚拟 DOM 通过渲染器渲染为真实 DOM。Vue.js 是一个有机整体,各个模块相互协作,共同完成工作。从编译器和渲染器两个关键模块出发,看看 Vue.js 是如何配合工作并实现性能优化的。
<div id="foo" :class="cls">hello world</div>
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
children: 'hello world',
patchFlags: 1 // 动态属性
}
/* 等价于 */
// return h('div', { id: 'foo', class: cls }, 'hello world');
}
在上方代码中,类名 cls
是一个变量,可能会发生变化,前文也提到过,渲染器越快找到需要更新的节点,性能越好。所以,Vue.js 在编译阶段把动态内容提取出来交给渲染器,这样渲染器就不需要花费时间寻找变更点,从而提升性能。
总结
Vue.js 3 是由编译器、渲染器等模块组成的有机整体,各模块相互协作,共同实现高效、灵活的前端开发体验。采用声明式编程,让用户通过模板或 JavaScript 对象描述 UI,无需手动操作 DOM。
- 声明式 UI 描述:通过模板或虚拟 DOM 对象(如使用
h
函数创建)来声明 UI,包括 DOM 元素、属性、事件和层级结构等。 - 渲染器渲染虚拟 DOM:渲染器接收虚拟 DOM 和挂载容器两个参数,将虚拟 DOM 转换为真实 DOM 并插入容器。渲染器会处理元素创建、属性设置、事件绑定和子元素递归渲染等操作。
- 组件封装与渲染:组件是一组 DOM 元素的封装,通过函数定义组件,返回要渲染的虚拟 DOM。渲染器根据
vnode.tag
是否为函数判断是否为组件,进而调用相应函数进行渲染。 - 模板编译:模板会被编译器编译成渲染函数,渲染函数返回虚拟 DOM,再由渲染器渲染为真实 DOM。编译阶段会提取动态内容,为渲染器提供性能优化依据,使其能快速找到更新点。