本文内容提炼于《Vue.js设计与实现》,全书共 501 页,对 Vue.js 的设计原理从 0 到 1,循序渐进的讲解。
篇幅比较长,需要花些时间慢慢阅读,在合适的位置会给出在线示例以供调试。
一、概览
Vue.js 是一款声明式框架,注重结果;早年间流行的 jQuery 是典型的命令式框架,注重过程。命令式的代码需要维护实现目标的整个过程,例如手动完成 DOM 元素的创建、更新、删除等工作。
Vue.js 帮我们封装了过程, 其内部是命令式的实现,而暴露给用户的则是声明式。虽然声明式代码的性能劣于命令式,但是可维护性更强,其更新性能公式如下。
声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗
虚拟 DOM,就是为了公式中找出差异的性能消耗而出现的。虽然其更新性能理论上不可能比原生的 JavaScript 直接操作 DOM 更高,但是它能保证应用程序的性能下限而不至于太差。
虽然在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时会多出一个 Diff 的性能消耗,但是它毕竟也是 JavaScript 层面的运算,所以不会产生数量级的差异。
1)架构
Vue.js 3 采用了运行时 + 编译时的架构,运行时是指用户可以直接提供数据对象从而无须编译。而编译时是指用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。
纯运行时没办法分析用户提供的内容,也就无法对内容做进一步优化。纯编译时虽然能直接编译成可执行的 JavaScript 代码(性能可能会更好),但是有损灵活性,即用户提供的内容必须编译后才能用。
2)核心要素
Vue.js 作为一款优秀的框架,其核心要素包括:
- 开发体验,例如提供友好的警告信息,输出更友好的信息等。
- 代码体积,例如支持 Tree-Shaking,构建生产环境时移除开发代码等。
- 特性开关,例如为框架添加新特性,支持遗留 API 等。
- 错误处理,例如为用户提供统一的错误处理接口。
- 支持TypeScript,为 JavaScript 提供类型检查等功能。
3)声明式地描述
Vue.js 3 是一个声明式的 UI 框架,即用户在使用 Vue.js 3 开发页面时可以声明式地描述 UI。
除了使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述(其实就是虚拟 DOM),而使用 JavaScript 对象描述 UI 会更加灵活。虚拟 DOM 其实就是用 JavaScript 对象来描述真实的 DOM 结构,如下所示。
const vnode = { tag: "div", props: { onClick: () => alert("hello") }, children: "click me" };
4)渲染器
渲染器(renderer)的作用就是把虚拟 DOM 渲染为真实 DOM,平时编写的 Vue.js 组件都是依赖渲染器来工作的,下面是一个简单的渲染器实现。
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); }
现在所做的还仅仅是创建节点,渲染器的精髓是在更新节点的阶段,涉及 Diff 算法。
const vnode = { tag: "div", props: { onClick: () => alert("hello") }, children: "click again" // 从 click me 改成 click again };
对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 API 操作 DOM 来完成渲染工作。
5)组件的本质
虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。
组件的本质就是一组 DOM 元素的封装,可以定义一个函数来代表组件,其返回值就是组件要渲染的内容。
const MyComponent = function () { return { tag: "div", props: { onClick: () => alert("hello") }, children: "click me" }; };
可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,就可以用虚拟 DOM 来描述组件了,用 tag 属性来存储组件函数:
const vnode = {
tag: MyComponent
};
为了能够渲染组件,需要渲染器的支持。修改前面提到的 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 函数的内容一致。如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。
function mountComponent(vnode, container) { // 调用组件函数,获取组件要渲染的内容(虚拟 DOM) const subtree = vnode.tag(); // 递归地调用 renderer 渲染 subtree renderer(subtree, container); }
6)模板
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且 Vue.js 同时支持这两种描述 UI 的方式。提到模板的工作原理,那就需要讲解一下 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>
其中 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到
