《Vue.js设计与实现》学习笔记 | Vue 3 的设计思路
声明式地描述UI
Vue 3 是一个声明式的 UI 框架,意思是说用户在使用 Vue 3 开发页面时是声明式地描述 UI 的。
我们知道在编写前端页面的时候主要涉及 4 点内容,它们包含:DOM元素、元素属性、事件(如 click
、keydown
)和元素的层级结构(DOM 树的结构)。
使用模板语法
为了实现声明式的特性, Vue.js 创造了一套名为 模板(template) 的语法,其长相酷似 HTML,但是又在 HTML 的基础上定义了一些特别的描述性语法。例如:
- 使用
:
或v-bind
来描述 动态 绑定的属性; - 使用
@
或v-on
来描述事件;
使用 JS 对象
在 Vue3 中,除了可以使用模板语法对 UI 进行描述,我们也可以使用 JavaScript 对象对 UI 进行描述:
const title = {
// 标签名字
tag: 'h1',
// 标签属性
props: {
onClick: handler,
},
// 标签的子元素
children: [{ tag: 'span' }],
};
而这种对象,其实就是虚拟DOM。 它们等同于以下的模板语法:
<h1 @click="handler">
<span></span>
</h1>
这两者的优势也显而易见,前者更加灵活,后者更加直观。书中举了个很好的例子,假设我们要根据一个名为 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>
这看起来非常繁琐!如果此时我们用 JavaScript 对象的形式进行编写,那么就会显得非常简单:
let level = 6; // 1 ~ 6
const title = {
tag: `h${level}`, // 将 'h' 和 level 拼接就是标签名啦
};
实际上,模板语法只是帮助开发者描述 UI 的工具而已,其类似于 JSX,并不等同于 HTML 。所有模板在运行前都会被编译器编译成 JS 代码,并在运行时交由渲染器渲染为真实 DOM。
使用 h 函数
我们都知道, Vue 中有个名为 h
的函数,大多数人第一次见到这个函数的时候可能都会感到迷惑,不知道它是干啥的 —— 其实这个函数是为了帮助我们更加方便地创造虚拟 DOM 而存在的。
譬如对于这段代码:
const title = {
tag: 'h1',
props: {
onClick: handler,
},
children: '这是标题文本',
};
而 h
函数的返回值就是这样的对象。如果我们借助 h
函数进行改写,代码是长这样的:
const title = h('h1', { onClick: handler }, '这是标题文本');
差别显而易见,这个函数帮助开发者省去了书写属性名的功夫,让手写虚拟 DOM 变得更加简单。
在 Vue 组件中,我们可以在渲染函数中编写虚拟 DOM:
import { h } from 'vue';
export default {
render() {
return h('h1', {}, 'Hello world!');
},
};
那么这个组件的渲染内容就通过渲染函数描述出来了,而且并不需要借助模板语法。
初识渲染器
渲染器的作用就是把虚拟 DOM 渲染为真实 DOM
假设我们有如下虚拟 DOM:
const vnode = {
tag: 'div',
props: {
onClick: handler,
},
children: [{ tag: 'span', children: 'hello world' }],
};
我们应该如何把它渲染为真实 DOM 呢?要实现自动渲染,我们要借助一个名为 renderer
的渲染函数来实现。renderer
函数接收两个参数,第一个参数为一个虚拟 DOM 对象,第二个参数为渲染结果要挂载的目标 DOM。
这里假设我们要将上述虚拟 DOM 渲染并挂载在 document.body
节点下:
renderer(vnode, document.body);
实现简单的渲染器函数
⭐接下来实现一个最简单 renderer
函数:
/**
* @param vnode 虚拟DOM
* @param container 挂载点
*/
function renderer(vnode, container) {
// 创建vnode的tag标签对应的元素,此处为div
const el = document.createElement(vnode.tag);
// 遍历vnode的props属性,将每个属性和事件都添加到元素中
for (const attr in vnode.props) {
// 以on开头的是事件
// 可以把头两个字母去掉,转化为小写,并添加对应的监听器
if (attr.startsWith('on')) {
el.addEvenListener(attr.subStr(2).toLowerCase(), vnode.props[attr]);
continue;
}
// 其他的就是普通属性
el[attr] = vnode.props[attr];
}
// 处理children
if (typeof vnode.children === 'string') {
// 如果children是字符串,那么创建文本节点
el.appendChild(document.createTextNode(vnode.children));
} else if (Array.isArray(document.children)) {
// 如果children是数组,那么遍历每个孩子并递归渲染
vnode.children.forEach((child) => renderer(child, el));
}
// 将el挂载到container上
container.appendChild(el);
}
上述关于渲染器的代码其实并不复杂。主要就是四个步骤:
- 创建
tag
属性对应的节点 - 循环创建事件监听,并对元素属性进行赋值
- 利用递归分类处理子元素
- 将创建的节点挂载到挂载点上
但是渲染器的精髓其实在于更新节点的阶段,如果我们对 vnode
做一些小小的修改,渲染器需要精确地找到变更处并更新变更的内容。这里涉及到 Diff 算法的应用,书中的后续章节将会重点讲解。
组件的本质
重要特性
如果你有学习过 React,那你一定对它的函数式组件印象深刻。在函数组件之前,React 使用曾经提倡使用对象式组件。为什么这两种方式都能实现组件呢?因为它们都具备闭包和可复用这两个重要特性。
Vue 中的组件本质上是对模板的复用,这就要求我们的组件必须是能够被重复创建且能够相互独立存在的。我们在使用虚拟 DOM 形式编写组件的时候,会将 UI 编写在 render
方法中,那么每当这个方法被调用的时候,它就会产生一组虚拟 DOM,想要多少组,你就调用多少次。这样就实现了组件的重复创建。并且由于函数的闭包特性,产生的每组虚拟 DOM 之间都相互独立、互不相干,这样就实现了组件间的独立性。
我们都知道,虚拟 DOM 属于树形数据结构,因此一组虚拟 DOM 是可以挂载在另外一组虚拟 DOM 上的,那么组件就可以通过这种方式发挥作用。
如何在虚拟DOM中表示组件
在上述 vnode
的示例中,tag
属性都表示了对应元素的标签名。但是对于组件而言,并没有一个明确的标签名,因此,我们可以选择使用该组件的 render
函数来代替元素的标签字符串。就像这样:
const vnode = {
// tag不再是字符串,而是一个返回虚拟DOM的函数
tag: () => {
return { tag: 'h1' };
},
props: { onClick: handler },
};
实现组件渲染函数
上面实现的 renderer
实际上是元素的渲染函数,为了实现组件的渲染函数,我们需要对其进行重构:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 字符串代表该虚拟DOM是一个元素
mountElement(vnode, container);
} else if (typeof vnode.tag === 'function') {
// 函数代表该虚拟DOM是一个组件
mountComponent(vnode, container);
}
}
其中 mountElement
函数的实现与最开始实现的 renderer
函数是一样的,而接下来我们将给出 mountComponent
函数的实现:
function mountComponent(vnode, container) {
// 因为vnode.tag是一个函数,所以直接调用它获得对于虚拟DOM即可
const subtree = vnode.tag();
// 递归调用renderer进行渲染
renderer(subtree, container);
}
可以看到,非常简单。因为我们已经知道 vnode.tag
是一个返回虚拟 DOM 的函数,所以我们直接调用它,获取返回的对象,然后继续使用 renderer
函数进行递归渲染即可。
我们也可以使用对象来创建组件,Vue.js 的有状态组件就是使用对象进行实现的。
模板的工作原理
上面已经提到,模板在编译阶段会被编译器编译为 JS 代码,然后再在运行阶段交由渲染器渲染为真实 DOM。
对于下属 .vue 格式的代码:
<template>
<div @click="handler">Click me</div>
</template>
<script>
export default {
data() {
/* ... */
},
methods: {
handler: () => {
/* ... */
},
},
};
</script>
在编译阶段交由编译器编译后会产生以下代码:
export default {
data() {
/* ... */
},
methods: {
handler: () => {
/* ... */
},
},
// 多出来的内容
render() {
return h('div', { onClick: handler }, 'Click me');
},
};
很容易可以发现前后的差异:<template>
不见了,多出来了一个 render
函数,而 render
中的内容正是原先 <template>
中的内容经过编译后产生的虚拟 DOM 形式的代码。
我们可以用一张图来直观地了解这个过程:
总结
本章主要讲解了 Vue.js 3 如何通过声明式描述 UI,使开发者可以使用模板语法和 JavaScript 对象来定义 DOM 元素、属性和事件。
然后,讲解了如何使用 JavaScript 对象描述虚拟 DOM,使代码更加灵活,以及如何使用
h
函数简化虚拟 DOM 的创建,使手写虚拟 DOM 更加简便。
最后讲解了渲染器的作用,并指出模板在编译阶段被编译为 JavaScript 代码,在运行时由渲染器渲染为真实 DOM。