书名:Vue.js设计与实现
作者:霍春阳(HcySunYang)
Vue.js 3.0 的扩展能力非常强,我们可以编写自定义的渲染器,甚至可以编写编译器插件来自定义模板语法。
通过阅读 ECMAScript 规范,基于 Proxy 实现一个完善的响应系统;通过阅读 WHATWG 规范,实现一个类 HTML 语法的模板解析器,并在此基础上实现一个支持插件架构的模板编译器。
第一篇 框架设计概览
第 1 章 权衡的艺术
1.1 命令式和声明式
jQuery 就是典型的命令式框架,命令式框架关注过程。
$('#app') // 获取 div
.text('hello world') // 设置文本内容
.on('click', () => { alert('ok') })
声明式框架关注结果。
<div @click="() => alert('ok')">hello world</div>
Vue.js 帮我们封装了过程。因此,我们能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。
1.2 性能与可维护性的权衡
声明式代码的性能不优于命令式代码的性能。
声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,
在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化。
1.3 虚拟 DOM 的性能到底如何
虚拟 DOM,就是为了最小化找出差异这一步的性能消耗而出现的。
采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。这里我们强调了理论上三个字,因为这很关键,为什么呢?因为在大部分情况下,我们很难写出绝对优化的命令式代码。
innerHTML 比较特殊。使用 innerHTML 操作页面和虚拟 DOM 相比性能如何?innerHTML 和 document.createElement 等 DOM 操作方法有何差异?
innerHTML 为了渲染出页面,首先要把字符串解析成 DOM 树,这是一个 DOM 层面的计算。我们知道,涉及DOM 的运算要远比 JavaScript 层面的计算性能差。
纯 JavaScript 层面的操作要比 DOM 操作快得多,它们不在一个数量级上。基于这个背景,我们可以用一个公式来表达通过 innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM 计算量。
虚拟 DOM 创建页面的过程分为两步:第一步是创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述;第二步是递归地遍历虚拟 DOM 树并创建真实 DOM。我们同样可以用一个公式来表达:创建 JavaScript 对象的计算量 + 创建真实 DOM 的计算量。
图 1-2 innerHTML 和虚拟 DOM 在创建页面时的性能
可以看到,无论是纯 JavaScript 层面的计算,还是 DOM 层面的计算,其实两者差距不大。这里我们从宏观的角度只看数量级上的差异。
接下来我们看看它们在更新页面时的性能。
使用 innerHTML 更新页面的过程是重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML 属性,而重新设置 innerHTML 属性就等价于销毁所有旧的 DOM 元素,再全量创建新的 DOM 元素。
再来看虚拟 DOM 是如何更新页面的。它需要重新创建 JavaScript 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化的元素并更新它。
图 1-3 虚拟 DOM 和 innerHTML 在更新页面时的性能
可以发现,在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时多出一个 Diff 的性能消耗,然而它毕竟也是 JavaScript 层面的运算,所以不会产生数量级的差异。再观察 DOM 层面的运算,可以发现虚拟 DOM 在更新页面时只会更新必要的元素,但 innerHTML 需要全量更新。这时虚拟 DOM 的优势就体现出来了。
对于虚拟 DOM 来说,无论页面多大,都只会更新变化的内容,而对于 innerHTML 来说,页面越大,就意味着更新时的性能消耗越大。
图 1-4 虚拟 DOM 和 innerHTML 在更新页面时的性能(加上性能因素)
基于此,我们可以粗略地总结一下 innerHTML、虚拟 DOM 以及原生 JavaScript(指 createElement 等方法)在更新页面时的性能,如图 1-5 所示。
图 1-5 innerHTML、虚拟 DOM 以及原生 JavaScript 在更新页面时的性能
至此,我们有必要思考一下:有没有办法做到,既声明式地描述 UI,又具备原生 JavaScript 的性能呢?
1.4 运行时和编译时
当设计一个框架的时候,我们有三种选择:纯运行时的、运行时 + 编译时的或纯编译时的。
假设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。
function Render(obj, root) {
const el = document.createElement(obj.tag)
if (typeof obj.children === 'string') {
const text = document.createTextNode(obj.children)
el.appendChild(text)
} else if (obj.children) {
// 数组,递归调用 Render,使用 el 作为 root 参数
obj.children.forEach(child => Render(child, el))
}
// 将元素添加到 root
root.appendChild(el)
}
const obj = {
tag: 'div',
children: [{ tag: 'span', children: 'hello world' }]
}
// 渲染到 body 下
Render(obj, document.body)
这就是一个纯运行时的框架。
能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象。
图 1-6 把 HTML 标签编译成树型结构的数据对象
编写一个叫作 Compiler 的程序,它的作用就是把 HTML 字符串编译成树型结构的数据对象。
那么用户该怎么用呢?其实这也是我们要思考的问题,最简单的方式就是让用户分别调用 Compiler 函数和 Render 函数:
const html = `
<div>
<span>hello world</span>
</div>
`
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)
这时我们的框架就变成了一个运行时 + 编译时的框架。它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。准确地说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译,而这会产生一定的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
不过,聪明的你一定意识到了另外一个问题:既然编译器可以把 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?
图 1-7 将 HTML 字符串编译为命令式代码的过程
这样我们只需要一个 Compiler 函数就可以了,连 Render 都不需要了。其实这就变成了一个纯编译时的框架。
一个框架既可以是纯运行时的,也可以是纯编译时的,还可以是既支持运行时又支持编译时的。那么,它们都有哪些优缺点呢?
首先是纯运行时的框架。由于它没有编译的过程,因此我们没办法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。然而,假如我们设计的框架是纯编译时的,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。实际上,在这三个方向上业内都有探索,其中 Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。等到后面讲解 Vue.js 3 的编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。
1.5 总结
在本章中,我们先讨论了命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果。命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化。
接着,我们讨论了虚拟 DOM 的性能,并给出了一个公式:声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。我们发现,用原生 JavaScript 操作 DOM 的方法(如 document.createElement)、虚拟 DOM 和 innerHTML 三者操作页面的性能,不可以简单地下定论,这与页面大小、变更部分的大小都有关系,除此之外,与创建页面还是更新页面也有关系,选择哪种更新策略,需要我们结合心智负担、可维护性等因素综合考虑。一番权衡之后,我们发现虚拟 DOM 是个还不错的选择。
最后,我们介绍了运行时和编译时的相关知识,了解纯运行时、纯编译时以及两者都支持的框架各有什么特点,并总结出 Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。
第 2 章 框架设计的核心要素
我们的框架应该给用户提供哪些构建产物?产物的模块格式如何?当用户没有以预期的方式使用框架时,是否应该打印合适的警告信息从而提供更好的开发体验,让用户快速定位问题?开发版本的构建和生产版本的构建有何区别?热更新(hot module replacement,HMR)需要框架层面的支持,我们是否也应该考虑?另外,当你的框架提供了多个功能,而用户只需要其中几个功能时,用户能否选择关闭其他功能从而减少最终资源的打包体积?
2.1 提升用户的开发体验
那么有没有办法在打印 count 的时候让输出的信息更友好呢?当然可以,浏览器允许我们编写自定义的 formatter,从而自定义输出形式。在 Vue.js 3 的源码中,你可以搜索到名为 initCustomFormatter 的函数,该函数就是用来在开发环境下初始化自定义 formatter 的。以 Chrome 为例,我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项,如图 2-3 所示。
图 2-3 勾选“Console”→“Enable custom formatters” 选项
2.2 控制框架代码的体积
if (__DEV__ && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}
Vue.js 使用 rollup.js 对项目进行构建,这里的 __DEV__ 常量实际上是通过 rollup.js 的插件配置来预定义的,其功能类似于 webpack 中的 DefinePlugin 插件。
当 Vue.js 用于构建生产环境的资源时,会把 __DEV__ 常量设置为 false。
if (false && !res) {
warn(
`Failed to mount app: mount target selector "${container}" returned null.`
)
}
这时我们发现这段分支代码永远都不会执行,因为判断条件始终为假,这段永远不会执行的代码称为 dead code,它不会出现在最终产物中,在构建资源的时候就会被移除。
在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积。
2.3 框架要做到良好的 Tree-Shaking
Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。
想要实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖 ESM 的静态结构。
- ESM: export + import
- CommonJS: module.exports + require
ES Module 是静态引入,编译时引入;CommonJS 是动态引入,运行时引入。
ES Module 特点:
- 只能作为模块顶层的语句出现
- import 的模块名只能是字符串常量
- import binding 是 immutable 的
ES Module 采用的是静态分析,从字面量对代码进行分析。静态分析程序流,判断哪些模块和变量未被使用或者引用,进而删除对应代码。import 的模块名只能是字符串常量,ES Module 依赖关系是确定的,和运行时的状态无关,因此可以进行可靠的静态分析,判断哪些模块最终没有被引用,这就是 Tree-Shaking 的基础。
CommonJS 是动态分析,必须执行到才知道引用什么模块。
Dead Code,也叫死码,无用代码,它的范畴主要包含了以下:
- 代码不会被执行,不可到达
- 代码执行的结果不会被用到
- 代码只会影响死变量(只写不读)
在传统的静态编程语言编译器中,编译器可以判断出某些代码根本不影响输出,我们可以借助编译器将 Dead Code 从 AST(抽象语法树)中删除。但 JavaScript 是动态语言,编译器不能帮助我们完成死码消除,我们需要自己实现 Dead code elimination。而我们说的 Tree-Shaking,就是 Dead code elimination 的一种实现,它借助于 ECMAScript 6 的模块机制原理,更多关注的是对无用模块的消除,消除那些引用了但并没有被使用的模块。
Tree Shaking概念和原理以及es6 module和commonjs的区别
Webpack 4 和 Webpack 5 中 Tree Shaking 的区别
Webpack 4:
1、Tree Shaking 只支持ES模块的使用,不支持 require 这种动态引入模块的方式。
2、只分析浅层的模块导出与引入关系,进行 dead-code 的去除。
Webpack 5 :
1、Webpack 5 中增加了对一些 CommonJS 风格模块代码的静态分析功功能。
支持 exports.xxx、this.exports.xxx、module.exports.xxx 语法的导出分析。
支持 object.defineProperty(exports, "xxxx", ...) 语法的导出分析。
支持 require('xxxx').xxx 语法的导入分析。
2、支持对嵌套引入模块的依赖分析优化,还增加了分析模块中导出项与导入项的依赖关系的功能。
通过 optimization.innerGraph(生产环境下默认开启)选项,Webpack 5 可以分析特定类型导出项中对导入项的依赖关系,从而找到更多未被使用的导入模块并加以移除
Webpack 曾经不进行对 CommonJs 导出和 require() 调用时的导出使用分析。
Webpack 5 增加了对一些 CommonJs 构造的支持,允许消除未使用的 CommonJs 导出,并从 require() 调用中跟踪引用的导出名称。
当检测到不可分析的代码时,webpack 会放弃,并且完全不跟踪这些模块的导出信息(出于性能考虑)。
ESM 动态导入
ES2020 引入了 import() 函数
import(), a syntax to asynchronously import Modules with a dynamic specifier.
document.getElementById("button")
.addEventListener("click", async () => {
const { app } = await import(`./app.${type}.js`);
app();
});
所以 import() 是不支持 Tree Shaking 的。
我们以 rollup.js 为例看看 Tree-Shaking 如何工作,其目录结构如下:
├── demo
│ └── package.json
│ └── input.js
│ └── utils.js
下面是 input.js 和 utils.js 文件的内容:
// input.js
import { foo } from './utils.js'
foo()
// utils.js
export function foo(obj) {
obj && obj.foo
}
export function bar(obj) {
obj && obj.bar
}
注意,我们并没有导入 bar 函数。
npx rollup input.js -f esm -o bundle.js
// bundle.js
function foo(obj) {
obj && obj.foo
}
foo()
由于我们并没有使用 bar 函数,因此它作为 dead code 被删除了。但是仔细观察会发现,foo 函数的执行也没有什么意义,仅仅是读取了对象的值,所以它的执行似乎没什么必要。既然把这段代码删了也不会对我们的应用程序产生影响,那么为什么 rollup.js 不把这段代码也作为 dead code 移除呢?
这就涉及 Tree-Shaking 中的第二个关键点——副作用。如果一个函数调用会产生副作用,那么就不能将其移除。
因为静态地分析 JavaScript 代码很困难,所以像 rollup.js 这类工具都会提供一个机制,让我们能明确地告诉 rollup.js:“放心吧,这段代码不会产生副作用,你可以移除它。
2.4 框架应该输出怎样的构建产物
首先我们希望用户可以直接在 HTML 页面中使用 <script> 标签引入框架并使用:
为了实现这个需求,我们需要输出一种叫作 IIFE 格式的资源。IIFE 的全称是 Immediately Invoked Function Expression,即“立即调用的函数表达式”。vue.global.js 文件就是 IIFE 形式的资源:
var Vue = (function (exports) {
// ...
exports.createApp = createApp
// ...
return exports
})({})
用户除了能够使用 <scrip\t> 标签引用 IIFE 格式的资源外,还可以直接引入 ESM 格式的资源,例如 Vue.js 3 还会输出 vue.esm-browser.js 文件,用户可以直接用 <script type="module"> 标签引入:
其实对于 ESM 格式的资源来说,Vue.js 还会输出一个vue.esm-bundler.js 文件。为什么这么做呢?我们知道,无论是 rollup.js 还是 webpack,在寻找资源时,如果 package.json 中存在 module 字段,那么会优先使用 module 字段指向的资源来代替 main 字段指向的资源。我们可以打开 Vue.js 源码中的 packages/vue/package.json 文件看一下:
{
"main": "index.js",
"module": "dist/vue.runtime.esm-bundler.js",
}
其中 module 字段指向的是 vue.runtime.esm-bundler.js 文件,意思是说,如果项目是使用 webpack 构建的,那么你使用的 Vue.js 资源就是vue.runtime.esm-bundler.js 也就是说,带有 -bundler 字样的 ESM 资源是给 rollup.js 或 webpack 等打包工具使用的,而带有 -browser 字样的 ESM 资源是直接给 <script type="module"> 使用的。
它们之间有何区别?这就不得不提到上文中的 __DEV__ 常量。当构建用于<script> 标签的 ESM 资源时,如果是用于开发环境,那么 __DEV__ 会设置为 true;如果是用于生产环境,那么 __DEV__ 常量会设置为 false,从而被 Tree-Shaking 移除。但是当我们构建提供给打包工具的 ESM 格式的资源时,不能直接把 __DEV__ 设置为 true或 false,而要使用 (process.env.NODE_ENV !== 'production') 替换 __DEV__ 常量。
我们还希望用户可以在 Node.js 中通过 require 语句引用资源,例如:
const Vue = require('vue')
当进行服务端渲染时,Vue.js 的代码是在 Node.js 环境中运行的,而非浏览器环境。在 Node.js 环境中,资源的模块格式应该是 CommonJS。
2.5 特性开关
使最终打包的资源体积最小化。
那怎么实现特性开关呢?其实很简单,原理和上文提到的 __DEV__ 常量一样,本质上是利用 rollup.js 的预定义常量插件来实现。拿 Vue.js 3 源码中的一段 rollup.js 配置来说:
{
__FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true
}
其中 __FEATURE_OPTIONS_API__ 类似于 __DEV__。在 Vue.js 3 的源码中搜索,可以找到很多类似于如下代码的判断分支:
// support for 2.x options
if (__FEATURE_OPTIONS_API__) {
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}
当 Vue.js 构建资源时,如果构建的资源是供打包工具使用的(即带有 -bundler 字样的资源),那么上面的代码在资源中会变成:
// support for 2.x options
if (__VUE_OPTIONS_API__) { // 注意这里
currentInstance = instance
pauseTracking()
applyOptions(instance, Component)
resetTracking()
currentInstance = null
}
其中 __VUE_OPTIONS_API__ 是一个特性开关,用户可以通过设置 __VUE_OPTIONS_API__ 预定义常量的值来控制是否要包含这段代码。通常用户可以使用 webpack.DefinePlugin 插件来实现:
// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性
})
最后详细解释 __VUE_OPTIONS_API__ 开关有什么用。在 Vue.js 2 中,我们编写的组件叫作组件选项 API,但是在 Vue.js 3 中,推荐使用 Composition API 来编写代码。
但是为了兼容 Vue.js 2,在 Vue.js 3 中仍然可以使用选项 API 的方式编写代码。但是如果明确知道自己不会使用选项 API,用户就可以使用 __VUE_OPTIONS_API__ 开关来关闭该特性,这样在打包的时候 Vue.js 的这部分代码就不会包含在最终的资源中,从而减小资源体积。
2.6 错误处理
假设我们开发了一个工具模块,代码如下:
// utils.js
export default {
foo(fn) {
fn && fn()
}
}
该模块导出一个对象,其中 foo 属性是一个函数,接收一个回调函数作为参数,调用 foo 函数时会执行该回调函数,在用户侧使用时:
import utils from 'utils.js'
utils.foo(() => {
// ...
})
大家思考一下,如果用户提供的回调函数在执行的时候出错了,怎么办?此时有两个办法,第一个办法是让用户自行处理,这需要用户自己执行 try...catch:
import utils from 'utils.js'
utils.foo(() => {
try {
// ...
} catch (e) {
// ...
}
})
但是这会增加用户的负担。试想一下,如果 utils.js 不是仅仅提供了一个 foo 函数,而是提供了几十上百个类似的函数,那么用户在使用的时候就需要逐一添加错误处理程序。
第二个办法是我们代替用户统一处理错误,如以下代码所示:
// utils.js
export default {
foo(fn) {
try {
fn && fn()
} catch (e) {
/* ... */
}
},
bar(fn) {
try {
fn && fn()
} catch (e) {
/* ... */
}
}
}
在每个函数内都增加 try...catch 代码块,实际上,我们可以进一步将错误处理程序封装为一个函数,假设叫它 callWithErrorHandling:
// utils.js
export default {
foo(fn) {
callWithErrorHandling(fn)
},
bar(fn) {
callWithErrorHandling(fn)
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
console.log(e)
}
}
可以看到,代码变得简洁多了。但简洁不是目的,这么做真正的好处是,我们能为用户提供统一的错误处理接口,如以下代码所示:
// utils.js
let handleError = null
export default {
foo(fn) {
callWithErrorHandling(fn)
},
// 用户可以调用该函数注册统一的错误处理函数
registerErrorHandler(fn) {
handleError = fn
}
}
function callWithErrorHandling(fn) {
try {
fn && fn()
} catch (e) {
// 将捕获到的错误传递给用户的错误处理程序
handleError(e)
}
}
我们提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序,然后在 callWithErrorHandling 函数内部捕获错误后,把错误传递给用户注册的错误处理程序。
这样用户侧的代码就会非常简洁且健壮:
import utils from 'utils.js'
// 注册错误处理程序
utils.registerErrorHandler((e) => {
console.log(e)
})
utils.foo(() => {
/*...*/
})
utils.bar(() => {
/*...*/
})
这时错误处理的能力完全由用户控制,用户既可以选择忽略错误,也可以调用上报程序将错误上报给监控系统。
实际上,这就是 Vue.js 错误处理的原理,你可以在源码中搜索到 callWithErrorHandling 函数。另外,在 Vue.js 中,我们也可以注册统一的错误处理函数:
import App from 'App.vue'
const app = createApp(App)
app.config.errorHandler = () => {
// 错误处理程序
}
2.7 良好的 TypeScript 类型支持
使用 TS 的好处有很多,如代码即文档、编辑器自动提示、一定程度上能够避免低级 bug、代码的可维护性更强等。
下面是使用 TS 编写的函数:
function foo(val: any) {
return val
}
这个函数很简单,它接收参数 val 并且该参数可以是任意类型(any),该函数直接将参数作为返回值,这说明返回值的类型是由参数决定的,如果参数是 number 类型,那么返回值也是 number 类型。然后我们尝试使用一下这个函数,如图 2-5 所示。
图 2-5 返回值类型丢失
在调用 foo 函数时,我们传递了一个字符串类型的参数 'str',按照之前的分析,得到的结果 res 的类型应该也是字符串类型,然而当我们把鼠标指针悬浮到 res 常量上时,可以看到其类型是 any,这并不是我们想要的结果。为了达到理想状态,我们只需要对 foo 函数做简单的修改即可:
function foo<T extends any>(val: T): T {
return val
}
大家不需要理解这段代码,我们直接来看现在的表现,如图 2-6 所示。
图 2-6 能够推导出返回值类型
可以看到,res 的类型是字符字面量 'str' 而不是 any 了,这说明我们的代码生效了。
通过这个简单的例子我们认识到,使用 TS 编写代码与对 TS 类型支持友好是两件事。
2.8 总结
提供友好的警告信息至关重要,这有助于开发者快速定位问题,在框架层面抛出有意义的警告信息是非常必要的。
为了框架体积不受警告信息的影响,我们需要利用 Tree-Shaking 机制,配合构建工具预定义常量的能力,实现仅在开发环境中打印警告信息。
Tree-Shaking 是一种排除 dead code 的机制。Tree-Shaking 本身基于 ESM,并且 JavaScript 是一门动态语言,通过纯静态分析的手段进行 Tree-Shaking 难度较大,因此大部分工具能够识别 /*#__PURE__*/ 注释,在编写框架代码时,我们可以利用 /*#__PURE__*/ 来辅助构建工具进行 Tree-Shaking。
框架不同类型的输出产物是为了满足不同的需求。
- 通过 <script> 标签直接引用并使用,需要输出 IIFE 格式的资源,即立即调用的函数表达式。
- 通过 <script type="module"> 引用并使用,需要输出 ESM 格式的资源。这里需要注意的是,ESM 格式的资源有两种:用于浏览器的 esm-browser.js 和用于打包工具的 esm-bundler.js。它们的区别在于对预定义常量 __DEV__ 的处理,
- 前者直接将 __DEV__ 常量替换为字面量 true 或 false,
- 后者则将 __DEV__ 常量替换为 process.env.NODE_ENV !== 'production' 语句。
Vue.js 中的选项对象式 API 和组合式 API 都能用来完成页面的开发,两者虽然不互斥,但从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。
框架需要为用户提供统一的错误处理接口,这样用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
使用 TS 编写框架和框架对 TS 类型支持友好是两件完全不同的事。
第 3 章 Vue.js 3 的设计思路
3.1 声明式地描述 UI
Vue.js 3 是一个声明式的 UI 框架,意思是说用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。
编写前端页面都涉及哪些内容,具体如下。
-
DOM 元素:例如是 div 标签还是 a 标签。
-
属性:如 a 标签的 href 属性,再如 id、class 等通用属性。
-
事件:如 click、keydown 等。
-
元素的层级结构:DOM 树的层级结构,既有子节点,又有父节点。
那么,如何声明式地描述上述内容呢?拿 Vue.js 3 来说,相应的解决方案是:
- 使用与 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。
除了上面这种使用模板来声明式地描述 UI 之外,我们还可以用 JavaScript 对象来描述,代码如下所示:
const title = {
// 标签名称
tag: 'h1',
// 标签属性
props: {
onClick: handler
},
// 子节点
children: [{ tag: 'span' }]
}
对应到 Vue.js 模板,其实就是:
<h1 @click="handler"><span></span></h1>
使用 JavaScript 对象描述 UI 更加灵活。
// h 标签的级别
let level = 3
const title = {
tag: `h${level}` // h3 标签
}
可以看到,当变量 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 对象来描述 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
}
}
h 函数的返回值就是一个对象,其作用是让我们编写虚拟 DOM 变得更加轻松。如果把上面 h 函数调用的代码改成 JavaScript 对象,就需要写更多内容:
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}
h 函数就是一个辅助创建虚拟 DOM 的工具函数。一个组件要渲染的内容是通过渲染函数来描述的,也就是上面代码中的 render 函数,Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了。
3.2 初识渲染器
现在我们已经了解了什么是虚拟 DOM,它其实就是用 JavaScript对象来描述真实的 DOM 结构。
渲染器的作用就是把虚拟 DOM 渲染为真实 DOM,如图 3-1 所示。
图 3-1 渲染器的作用
渲染器是非常重要的角色,大家平时编写的 Vue.js 组件都是依赖渲染器来工作的。
假设我们有如下虚拟 DOM:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
首先简单解释一下上面这段代码。
-
tag 用来描述标签名称,所以 tag: 'div' 描述的就是一个<div> 标签。
-
props 是一个对象,用来描述 <div> 标签的属性、事件等内容。可以看到,我们希望给 div 绑定一个点击事件。
-
children 用来描述标签的子节点。在上面的代码中,children是一个字符串值,意思是 div 标签有一个文本子节点:<div>click me</div>
接下来,我们需要编写一个渲染器,把上面这段虚拟 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)
}
这里的 renderer 函数接收如下两个参数。
-
vnode:虚拟 DOM 对象。
-
container:一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下。
接下来,我们可以调用 renderer 函数:
renderer(vnode, document.body) // body 作为挂载点
在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出 alert('hello')。
现在我们回过头来分析渲染器 renderer 的实现思路,总体来说分为三步。
-
创建元素
-
为元素添加属性和事件
-
处理 children
我们现在所做的还仅仅是创建节点,渲染器的精髓都在更新节点的阶段。假设我们对 vnode 做一些小小的修改:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click again' // 从 click me 改成 click again
}
对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 DOM 操作 API 来完成渲染工作。
3.3 组件的本质
虚拟 DOM 其实就是用来描述真实 DOM 的普通 JavaScript 对象,渲染器会把这个对象渲染为真实 DOM 元素。那么组件又是什么呢?组件和虚拟 DOM 有什么关系?渲染器如何渲染组件?接下来,我们就来讨论这些问题。
其实虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。
如何使用虚拟 DOM 来描述组件呢?组件的本质就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
可以看到,组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。搞清楚了组件的本质,我们就可以定义用虚拟 DOM 来描述组件了。很简单,我们可以让虚拟 DOM 对象中的 tag 属性来存储组件函数:
const vnode = {
tag: MyComponent
}
就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名称,而是组件函数。
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
如果 vnode.tag 的类型是函数,则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
组件一定得是函数吗?当然不是,我们完全可以使用一个 JavaScript 对象来表达组件,例如:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
为了完成组件的渲染,我们需要修改 renderer 渲染器以及 mountComponent 函数。
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') {
// 如果是对象,说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
其实 Vue.js 中的有状态组件就是使用对象结构来表达的。
3.4 模板的工作原理
无论是手写虚拟 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> 标签块的组件对象上,所以最终在浏览器里运行的代码就是:
export default {
data() {
/* ... */
},
methods: {
handler: () => {
/* ... */
}
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
所以,无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。
3.5 Vue.js 是各个模块组成的有机整体
如前所述,组件的实现依赖于渲染器,模板的编译依赖于编译器,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的,因此 Vue.js 的各个模块之间是互相关联、互相制约的,共同构成一个有机整体。
这里我们以编译器和渲染器这两个非常关键的模块为例,看看它们是如何配合工作,并实现性能提升的。
假设我们有如下模板:
<div id="foo" :class="cls"></div>
根据上文的介绍,我们知道编译器会把这段代码编译成渲染函数:
render() {
// 为了效果更加直观,这里没有使用 h 函数,而是直接采用了虚拟 DOM 对象
// 下面的代码等价于:
// 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
},
patchFlags: 1 // 假设数字 1 代表 class 是动态的
}
}
如上面的代码所示,在生成的虚拟 DOM 对象中多出了一个 patchFlags 属性,我们假设数字 1 代表“ class 是动态的”,这样渲染器看到这个标志时就知道:“哦,原来只有 class 属性会发生改变。”对于渲染器来说,就相当于省去了寻找变更点的工作量,性能自然就提升了。
通过这个例子,我们了解到编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟 DOM 对象。
3.6 总结
Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟 DOM 更加直观。
然后我们讲解了最基本的渲染器的实现。渲染器的作用是,把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。
接着,我们讨论了组件的本质。组件其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,我们称之为 subtree,最后再递归地调用渲染器将 subtree 渲染出来即可。
Vue.js 的模板会被一个叫作编译器的程序编译为渲染函数。最后,编译器、渲染器都是 Vue.js 的核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。