• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

精读 Vuejs 设计和实现第 1 ~ 3 章框架设计概览

武飞扬头像
云牧
帮助1

第1章 权衡的艺术

框架的各个模块并非相互独立,而是相互关联和制约。 因此,框架设计者需全局审视,以保障后续模块设计与拆分的精确。同样地,作为学习者,我们也需全局理解框架设计,以防陷入细节失去全局视角。

1.1 命令式和声明式

视图层框架通常分为命令式和声明式。 命令式框架关注过程,如jQuery,其关注点在过程。例如,我们可以将以下描述转化为对应代码:

  1. 获取 id 为 app 的 div 标签
  2. 设定其文本为 hello world
  3. 为其绑定点击事件
  4. 点击时,显示提示:ok

此过程在 jQuery 中的代码为:

$('#app') // 获取 div
.text('hello world') // 设置文本内容
.on('click', () => { alert('ok') }) // 绑定点击事件

同样的过程在原生 JavaScript 中的代码为:

const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件

这些代码展现了“过程”,符合逻辑直觉。 然而,声明式框架则更关注结果。以 Vue.js 为例,实现上述功能的代码为:

<div @click="() => alert('ok')">hello world</div>

在这里,我们给出了期望的“结果”,至于“过程”,由 Vue.js 自动完成。 这就好像我们告诉 Vue.js:“我需要一个 div,内容为 hello world,绑定点击事件,你来处理吧。” Vue.js 将过程封装,而用户看到的是声明式的结果。

1.2 性能与可维护性的权衡

命令式和声明式各有优点与缺陷。对框架设计而言,它们的选择反映在性能与可维护性的权衡上。 我们首先要明白一点:相比于命令式代码,声明式代码的性能并无优势。 我们还是以前述示例来说明。假设我们要将 div 标签的文本内容修改为 'hello vue3',采用命令式代码可以简单直接地进行修改:

div.textContent = 'hello vue3' // 直接修改

在这个例子中,我们知道要修改的具体内容,因此直接调用相关命令即可。这种方式理论上可以达到最优的性能,因为我们只对变更的部分进行了修改。然而,声明式代码则不一定能做到这一点,它的关注点在结果上:

<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>

框架在进行更新时,需要找出前后的差异并只更新变化的部分。然而,完成这次更新的代码依然是:

div.textContent = 'hello vue3' // 直接修改

假设我们将直接修改的性能消耗定义为 A,找出差异的性能消耗定义为 B,那么有:

  • 命令式代码的更新性能消耗 = A
  • 声明式代码的更新性能消耗 = B A

因此,声明式代码相较于命令式代码多出了找出差异的性能消耗。 最理想的情况是,如果找出差异的性能消耗为 0,则声明式代码与命令式代码的性能相当,但不可能超越。这是因为框架本身封装了命令式代码来实现面向用户的声明式,因此声明式代码的性能不可能优于命令式代码。 那么,既然命令式代码在性能上更具优势,为何 Vue.js 会选择声明式设计呢?答案在于声明式代码更易于维护。使用命令式代码时,我们需要维护实现目标的整个过程,包括创建、更新、删除 DOM 元素等工作。而声明式代码只需要展示我们期望的结果,它将过程的实现隐藏,使代码更易理解,也更易维护。 因此,在框架设计上,我们需要平衡可维护性与性能。采用声明式的方式可以提升可维护性,但性能可能会有所损失。框架设计者的任务就是在保持可维护性的同时,尽可能地降低性能的损失。

1.3 虚拟 DOM 的性能如何?

虚拟DOM(Virtual DOM)的设计初衷是最小化差异计算的性能消耗,进而让声明式代码的性能尽可能接近命令式代码。 虚拟 DOM 的性能理论上不会超越原生 JavaScript 对 DOM 的操作。但实际上,编写高效的命令式代码是困难的,尤其在大型应用中。因此,虚拟 DOM 通过降低编程难度(写声明式代码),确保应用性能的底线,并尽可能靠近命令式代码的性能。 前文中所说的原生 JavaScript 实际上指的是像 document.createElement 之类的 DOM 操作方法,并不包含 innerHTML,因为它比较特殊,需要单独讨论,早年使用 jQuery 或者直接使用 JavaScript 编写页面的时候,使用 innerHTML 来操作页面非常常见。 首先,我们来看创建页面的过程。 对于 innerHTML,我们需要构造 HTML 字符串,然后将该字符串赋值给 DOM 元素的 innerHTML 属性。这包括将字符串解析成 DOM 树的性能消耗。 而虚拟 DOM 则通过创建 JavaScript 对象(即真实 DOM 的描述)和遍历虚拟 DOM 树创建真实 DOM。虽然两者在性能上有所差距,但在创建新的所有 DOM 元素时,两者的性能差异并不大。 但是,当我们讨论更新页面时,虚拟 DOM 的优势就体现出来了。使用 innerHTML 更新页面,即使只更改了一点内容,也需要重新设置整个 innerHTML 属性,这相当于全面替换所有旧的 DOM 元素。而虚拟 DOM 只需要更新发生变化的元素。因此,在更新页面时,虚拟 DOM 的性能明显优于 innerHTML,尤其在页面规模较大的情况下。 虚拟 DOM 和 innerHTML 在创建页面时的性能: 学新通 虚拟 DOM 和 innerHTML 在更新页面时的性能: 学新通 innerHTML、虚拟 DOM 以及原生 JavaScript 在更新页面时的性能: 学新通 最后,我们总结了 innerHTML、虚拟 DOM 和原生 JavaScript 在更新页面时的性能,心智负担和可维护性。 原生 DOM 操作需要手动创建、删除、修改大量 DOM 元素,性能最高,但维护困难。 innerHTML 则最终的内容可能需要拼接很多的 HTML 字符串,也会产生一定的心智负担,性能也较差。 虚拟 DOM 提供了声明式的编程方式,易于理解,易于维护,性能也在可接受的范围内。

1.4 运行时与编译时

当设计一个框架时,我们可以选择纯运行时、运行时 编译时或纯编译时 纯运行时框架: 假设我们设计了一个名为 Render 的函数,它接收一个树形结构的数据对象并将其渲染为 DOM 元素。例如,我们规定树形数据对象如下:

const obj = {
  tag: 'div',
  children: [
    { tag: 'span', children: 'hello world' }
  ]
}

每个对象都有两个属性:tag 代表标签名称,children 既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点) 我们可以编写 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)
}

然后,用户可以直接使用 Render 函数渲染内容:

const obj = {
  tag: 'div',
  children: [
    { tag: 'span', children: 'hello world' }
  ]
}
// 渲染到 body 下
Render(obj, document.body)

但是,如果用户想使用更自然的 HTML-like 语法来描述对象结构,我们就需要引入编译步骤。 运行时 编译时框架: 我们编写一个 Compiler 函数,该函数将 HTML 字符串编译为树形结构的数据对象: 学新通

const html = `
<div>
  <span>hello world</span>
</div>
`
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html)
// 再调用 Render 进行渲染
Render(obj, document.body)

现在,我们的框架是一个运行时 编译时框架。我们既支持运行时(用户可以直接提供数据对象,无需编译),也支持编译时(用户可以提供 HTML 字符串,我们将其编译为数据对象然后交给运行时处理)。 准确说,上面代码是运行时编译,意思是代码运行的时候才开始编译,这会产生一定的性能开销,因此我们也可以在构建的时候执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了。

纯编译时框架: 编译器可以将 HTML 字符串编译成命令式代码。 学新通

const compiledCode = Compiler(html);
eval(compiledCode);

这是一个纯编译时框架。优势在于性能,但损失了灵活性。我们不支持任何运行时内容,用户的代码通过编译器编译后才能运行。 总结: 纯运行时框架简单易用,但缺乏对用户提供内容的深入理解和优化的能力。 纯编译时框架直接分析用户提供的内容,直接编译为可执行的 JavaScript 代码,因此性能可能更好,但用户内容必须编译后才能使用,会丧失部分灵活性。 运行时 编译时的框架试图在这两者之间取得平衡。我们就可以在编译的时候提取信息传递给 render 函数进行优化。 实际上,在这三个方向上业内都有探索,比如,Svelte 是一个纯编译时框架,但实际性能可能无法达到理论值。Vue3 保持了运行时 编译时的架构,在保持灵活性的同时,还尽可能地进行优化。你会在后面的 Vue3 的编译优化相关内容中看到,即使保留了运行时,Vue.js 3 的性能也并不输给纯编译时的框架。

1.5 总结

本章中,我们首先对比了命令式和声明式编程范式,强调了命令式的过程导向和声明式的结果导向。虽然命令式理论上可以做到极度优化,但用户需要承担更大的心智负担。而声明式则有效降低了用户的心智负担,但相应地会牺牲一部分性能。因此,框架设计者的挑战在于如何最小化这种性能损失。 然后,我们讨论了虚拟 DOM 的性能,总结了声明式更新性能消耗的公式:声明式的更新性能消耗 = 找出差异的性能消耗 直接修改的性能消耗。虚拟 DOM 的核心就是优化这个“找出差异”的过程。我们注意到,使用原生 JavaScript 操作 DOM(如 document.createElement)、虚拟 DOM 和 innerHTML 等不同方法操作页面的性能并无定论,它受到页面大小、改动范围、创建或更新的页面类型等因素影响。在权衡心智负担、可维护性等因素后,虚拟 DOM 展现出其优秀的效果。 最后,我们探讨了运行时和编译时的概念,了解了纯运行时、纯编译时以及运行时 编译时框架的各自特性。我们总结了 Vue3 作为一个运行时 编译时的框架,如何在保持灵活性的同时,通过编译步骤分析用户提供的内容,从而进一步提升性能。

第2章 框架设计核心要素

2.1 提升用户开发体验

评估框架的优秀程度,其中一个指标就是其开发体验。以 Vue3 为例:

createApp(App).mount('#not-exist')

如果我们试图将 Vue.js 应用挂载到一个不存在的 DOM 节点,我们会接收到一个警告信息。这个信息不仅告诉我们挂载失败,而且明确了失败原因。 学新通 因此,在框架设计和开发过程中,提供友好的警告信息至关重要。若处理不当,可能会招致用户抱怨。及时的友好警告不仅帮助用户快速定位问题、节省时间,还能赢得良好口碑,提升框架的专业形象。 例如,在 Vue.js 的源码中,我们经常看到 warn 函数的调用:

warn(`Failed to mount app: mount target selector "${container}" returned null.`)

warn 函数致力于提供尽可能多的有用信息,因此会收集当前错误的组件栈信息。虽然源码有些复杂,但最后都是调用 console.warn 函数。 除了提供必要的警告信息,还有其他方式可以进一步提升用户的开发体验。比如,在 Vue3 中,当我们打印一个 ref 数据时:

const count = ref(0)
console.log(count)

学新通 我们可以选择打印 count.value,输出会更清晰。 浏览器 允许我们编写自定义的 formatter,从而自定义输出形式。 在 Vue3 源码中,你可以搜索到名为 initCustomFormatter 的函数,该函数就是用来在开发环境下初始化自定义 formatter 的。以 Chrome 为例,我们可以打开 DevTools 的设置,然后勾选“Console”→“Enable custom formatters”选项, 学新通 然后刷新浏览器并查看控制台,会发现输出内容变得非常直观: 学新通

2.2 控制框架代码体积

框架大小也是一个评价标准。在实现相同功能时,代码当然是越少越好。 但提供完善的警告信息需要更多的代码。解决方法是使用 DEV 常量。 Vue.js 3 的源码中,每个 warn 函数的调用都配合 DEV 常量检查:

if (__DEV__ && !res) {
  warn(`Failed to mount app: mount target selector "${container}" returned null.`)
}

如上,只有当 DEV 常量为 true 时才打印警告信息。这里,DEV 常量起到关键作用。 Vue.js 项目构建使用 rollup.js,其中 DEV 常量通过 rollup.js 的插件预定义,其功能类似于 webpack 的 DefinePlugin 插件。 Vue.js 输出两个版本的资源,一个用于开发环境(如 vue.global.js),另一个用于生产环境(如 vue.global.prod.js),文件名可以区分环境。 在构建开发环境资源时,Vue.js 将 DEV 常量设置为 true,所以警告信息代码等价于:

if (true && !res) {
  warn(`Failed to mount app: mount target selector "${container}" returned null.`)
}

当 Vue.js 用于构建生产环境的资源时,会把 DEV 常量设置为 false,这时上面那段输出警告信息的代码就等价于:

if (false && !res) {
	warn(`Failed to mount app: mount target selector "${container}" returned null.`)
}

现在,DEV 常量替换为字面量 false,警告信息代码将永不执行,成为所谓的"死代码"。 这种永不执行的代码在构建过程中会被移除,因此它不会出现在最终产物 vue.global.prod.js 中。 这样我们实现了在开发环境中提供友好的警告信息,同时不增加生产环境代码的体积。

2.3 框架要做到良好的 Tree-Shaking

Tree-Shaking,这个在前端领域由 rollup.js 普及的概念,简单地说,是消除那些永远不会被执行的代码,也就是移除 dead code。 目前,无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。 要实现 Tree-Shaking,模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖于 ESM 的静态结构。 我们以 rollup.js 为例看看 Tree-Shaking 如何工作,其目录结构如下:

├── demo
│   └── package.json
│   └── input.js
│   └── utils.js

安装 rollup.js:

yarn add rollup -D
# 或者 npm install rollup -D

假设有以下 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
}

我们在 utils.js 文件中定义并导出了两个函数,然后在 input.js 中导入并执行了 foo 函数。接着,执行如下命令进行构建:

npx rollup input.js -f esm -o bundle.js

打开构建后的 bundle.js:

// bundle.js
function foo(obj) {
  obj && obj.foo
}
foo();

我们会发现其中并不包含 bar 函数,这说明 Tree-Shaking 起了作用。 但如果 foo 函数的执行不产生意义,为何 rollup.js 不将它也移除呢? 这涉及到 Tree-Shaking 的第二个关键概念—副作用。 如果函数调用产生副作用,我们不能移除它。副作用是指函数调用时对外部环境产生影响,比如改变全局变量。 你可能会疑问,上面代码只是读取对象值,怎会产生副作用呢? 但考虑到 Proxy 创建的对象,读取它的属性就可能触发其 get trap,其中可能产生副作用,如修改全局变量。这是否会发生,只有在代码运行时才能确认。由于 JavaScript 的动态性,静态分析 dead code 存在困难。 由于静态分析 JavaScript 存在挑战,工具如 rollup.js 提供机制,允许我们明确指示代码不产生副作用,可以安全移除。如何操作呢? 以修改 input.js 文件为例:

import {foo} from './utils'

/*#__PURE__*/ foo()

注意 /*#__PURE__*/ 注释,它告知 rollup.js,foo 函数的调用无副作用,可以安全地进行 Tree-Shaking。 如果再次执行构建命令并查看 bundle.js 文件,会发现其内容已空,证明 Tree-Shaking 已生效。 从这个例子中,我们应该理解,编写框架时需合理使用 /*#__PURE__*/ 注释。 如果查看 Vue3 的源码,会发现它广泛应用这种注释,例如:

export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)

这是否给编写代码带来复杂性呢? 其实不会,因为通常产生副作用的代码都是模块内顶级调用的函数。

foo() // 顶级调用

function bar() {
  foo() // 函数内调用
}

对于顶级调用来说,是可能产生副作用的;但对于函数内调用来说,只要函数 bar 没有被调用,那么 foo 函数的调用自然不会产生副作用。 因此,Vue3 的源码主要在顶级调用的函数上使用 /#PURE/ 注释。 这个注释不仅作用于函数,也适用于任何语句,且不仅 rollup.js 可以识别,webpack 和压缩工具(如 terser)也都能识别它。

2.4 框架应该输出怎样的构建产物

Vue.js 的构建产物除了根据环境的不同(开发环境和生产环境)输出不同的包,还会根据使用场景的不同而输出其他形式的产物。 假如用户希望可以直接在 HTML 页面中通过 <script> 标签引入框架并使用:

<body>
  <script src="/path/to/vue.js"></script>
  <script>
    const { createApp } = Vue
    // ...
  </script>
</body>

为满足此需求,我们需要输出一种称为 IIFE(Immediately Invoked Function Expression,立即调用的函数表达式)格式的资源:

(function () {
  // ...
}())

实际上,vue.global.js 文件就是 IIFE 形式的资源,其代码结构如下:

var Vue = (function(exports){
  // ...
  exports.createApp = createApp;
  // ...
  return exports
}({}))

使用 <script> 标签直接引入 vue.global.js 文件后,全局变量 Vue 便可用。 在 rollup.js 中,我们可以通过配置 format: 'iife' 来输出 IIFE 形式的资源:

// rollup.config.js
const config = {
  input: 'input.js',
  output: {
    file: 'output.js',
    format: 'iife' // 指定模块形式
  }
}

export default config

现在主流浏览器对原生 ESM 支持良好,因此用户可以直接引入 ESM 格式的资源。 例如,Vue.js 3 还会输出 vue.esm-browser.js 文件,用户可以直接用 <script type="module"> 标签引入:

<script type="module" src="/path/to/vue.esm-browser.js"></script>

为输出 ESM 格式的资源,rollup.js 的输出格式需要配置为:format: 'esm'。 可能注意到 vue.esm-browser.js 文件中带有 -browser 字样。 对于 ESM 格式的资源,Vue.js 还会输出一个 vue.esm-bundler.js 文件,其中 -browser 变成了 -bundler。 这么做的原因是:当使用 rollup.js 或 webpack 等打包工具时,它们会优先使用 package.json 中的 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 常量。 例如下面的源码:

if (__DEV__) {
  warn(`useCssModule() is not supported in the global build.`)
}

在带有 -bundler 字样的资源中会变成:

if ((process.env.NODE_ENV !== 'production')) {
  warn(`useCssModule() is not supported in the global build.`)
}

这样做的好处是,用户可以通过 webpack 配置自行决定构建资源的目标环境,但最终效果其实一样,这段代码也只会出现在开发环境中。 除了可以直接使用 <script> 标签引入资源,我们还希望用户可以在 Node.js 中通过 require 语句引用资源,例如:

const Vue = require('vue')

为什么会有这种需求?答案是“服务端渲染”。当进行服务端渲染时,Vue.js 的代码是在 Node.js 环境中运行的,而非浏览器环境。 在 Node.js 环境中,资源的模块格式应该是 CommonJS(简称 cjs)。 为了输出 cjs 模块的资源,我们可以通过修改 rollup.config.js 的配置 format: 'cjs' 来实现:

// rollup.config.js
const config = {
  input: 'input.js',
  output: {
    file: 'output.js',
    format: 'cjs' // 指定模块形式
  }
}

export default config

2.5 特性开关

在框架设计过程中,通常会为用户提供多种特性(或功能)。例如,我们为用户提供了 A、B 和 C 三个特性,同时还提供了 a、b 和 c 三个对应的特性开 关。用户可通过设定这些开关为 true 或 false 来开启或关闭对应特性,实现极高的灵活性和自定义度。 首先,对于用户禁用的特性,我们可以利用 Tree-Shaking 机制使其不包含在最终的资源中。 这种机制为框架设计提供了灵活性,可以通过特性开关随意为框架添加新特性,而不必担心资源体积的增加。 其次,在框架升级过程中,我们还可以通过特性开关来支持遗留 API,这样新用户可以选择不使用遗留 API,从而使最终打包的资源体积最小化。 实现特性开关的方法很简单,原理与上文提到的 DEV 常量类似,本质上是利用 rollup.js 的预定义常量插件来实现。 以 Vue.js 3 源码中的一段 rollup.js 配置为例:

{
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}

其中,FEATURE_OPTIONS_API 类似于 DEV。在 Vue3 的源码中,我们可以找到很多类似以下代码的判断分支:

// 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 是一个特性开关,用来是否启用 Vue2 的选项 API,用户可以通过设置 VUE_OPTIONS_API 预定义常量的值来控制是否包含 此段代码。通常,用户可以使用 webpack.DefinePlugin 插件来实现:

// webpack.DefinePlugin 插件配置
new webpack.DefinePlugin({
  __VUE_OPTIONS_API__: JSON.stringify(true) // 开启特性
})

尽管如此,为了兼容 Vue.js 2,Vue.js 3 仍然支持使用选项 API 编写代码。 但是,如果用户明确知道他们不会使用选项 API,他们可以通过 VUE_OPTIONS_API 开关来关闭该特性。 这样,在打包过程中,Vue.js 的这部分代码将不会包含在最终的资源中,从而减小资源体积。

2.6 错误处理

错误处理对于框架开发十分重要,它影响用户应用程序的健壮性和用户开发时的心智负担。 一个实用的错误处理机制是提供统一的错误处理接口,降低用户的负担。 假设有一个工具模块,如下:

// utils.js
export default {
  foo(fn) {
    fn && fn()
  }
}

用户在使用时,可能如下:

import utils from 'utils.js'
utils.foo(() => {
  // ...
})

当用户提供的回调函数执行出错时,一种处理方式是让用户自行处理,如:

import utils from 'utils.js'
utils.foo(() => {
  try {
    // ...
  } catch (e) {
    // ...
  }
})

这种方式会增加用户负担,尤其在 utils.js 提供了大量函数时,用户需逐一添加错误处理程序。 另一种方式是统一处理错误:

// utils.js
export default {
  foo(fn) {
    try {
      fn && fn()
    } catch(e) {/* ... */}
  },
  bar(fn) {
    try {
      fn && fn()
    } catch(e) {/* ... */}
  },
}

实际上,我们可以封装一个错误处理函数,如 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)
  }
}

用户在使用时,可以简洁且稳健地处理错误:

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 类型支持

TypeScript(TS)是 JavaScript 的超集,提供了类型支持。 越来越多的开发者和团队在项目中使用 TS,因为它带来了诸多好处。 因此,对 TS 类型的支持是否完善也成为评价一个框架的重要指标。 对 TS 类型支持友好与使用 TS 编写框架是两件不同的事。举一个简单的例子,下面是使用 TS 编写的函数:

function foo(val: any) {
  return val;
}

该函数接收任意类型的参数(any),并将参数作为返回值。 当调用 foo 函数时,如果传递了一个字符串类型的参数 'str',我们希望返回值类型也是字符串类型。 然而,如果不进行类型推导,返回值类型会被识别为 any。 为了达到理想状态,我们需要对 foo 函数进行修改:

function foo<T extends any>(val: T): T {
  return val;
}

现在,返回值类型将正确地推导出来。 这个简单的例子说明了使用 TS 编写代码与对 TS 类型支持友好是两个不同的概念。 在编写大型框架时,想要做到完善的 TS 类型支持很不容易。 例如,Vue.js 源码中的 runtime-core/src/apiDefineComponent.ts 文件中,整个文件里真正会在浏览器中运行的代码只有 3 行,但是全部的代码接近 200 行,这些代码都是为了类型支持服务。由此可见,框架想要做到完善的类型支持,需要付出相当大的努力。 除了要花大力气做类型推导,以实现更好的类型支持外,还要考虑对 TSX(TypeScript 的 JSX)的支持。后续章节会详细讨论这部分内容。

2.8 小结

本章我们着重探讨了框架设计中的开发体验,一项评价框架质量的关键标准。有效的警告信息有助于开发者快速定位问题,因为框架通常比开发者更清楚问题所在。然而,详尽的警告信息可能会导致框架体积增大。为此,我们可利用 Tree-Shaking 机制与构建工具的常量预定义能力,如 DEV 常量,以确保仅在开发环境下显示警告信息。这样可以在保证线上代码体积可控性的同时,提升开发体验。 Tree-Shaking 是一种剔除无用代码的技术,框架会提供内建的多种功能,如 Vue.js 的组件等。对于可能未被用户使用的功能,我们可以利用 Tree-Shaking 使最终打包的代码体积最小。考虑到 JavaScript 是动态语言,大多数工具能够识别 /#PURE/ 注释,我们可以用这个标注来帮助构建工具进行 Tree-Shaking。 我们还讨论了框架的输出形式,它们以不同的格式满足不同的需求。为了让用户能通过 <script>标签引用,我们需要提供 IIFE 格式的资源。同时,也需提供 ESM 格式的资源,使用户能通过 <script type="module"> 引用。这里要特别注意的是,浏览器用的 esm-browser.js 和打包工具用的 esm-bundler.js 在处理 DEV 常量上有所区别。 框架会提供多种解决方案,出于灵活性和兼容性的考虑,如 Vue.js 的选项式 API 和组合式 API。用户可根据需要通过特性开关关闭不需要的特性,从而使相关代码在打包时被 Tree-Shaking 排除。 框架的错误处理决定了用户应用程序的健壮性以及开发时的心智负担。框架应提供统一的错误处理接口,以便用户注册自定义的错误处理函数来处理所有的框架异常。 最后,我们指出了一个常见的误解:使用 TS 编写框架和框架对 TS 的友好支持是两回事。有时候,为了提供更友好的类型支持,可能需要比实现框架功能本身更多的时间和精力。

第 3 章 Vue.js 3 的设计思路与原理

3.1 以声明式方式描述 UI

Vue3 是一个声明式 UI 框架,用户在使用 Vue.js 3 开发页面时以声明式方式描述 UI。 那么在构建一个声明式 UI 框架时,我们需要考虑哪些要素?我们需要理解前端页面编写的主要内容:

  • DOM 元素:例如 div 标签还是 a 标签。
  • 属性:如 a 标签的 href 属性,以及 id、class 等通用属性。
  • 事件:如 click、keydown 等。
  • 元素的层级结构:DOM 树的层级结构,包括子节点和父节点。

那么,如何声明式地描述这些内容呢?在 Vue3 中,解决方案包括:

  1. 使用与 HTML 标签一致的方式描述 DOM 元素,例如描述 div 标签时可以使用 <div></div>
  2. 使用与 HTML 标签一致的方式描述属性,例如 <div id="app"></div>
  3. 使用 : 或 v-bind 描述动态绑定的属性,例如 <div :id="dynamicId"></div>
  4. 使用 @ 或 v-on 描述事件,例如点击事件 <div @click="handler"></div>
  5. 使用与 HTML 标签一致的方式描述层级结构,例如一个具有 span 子节点的 div 标签 <div><span></span></div>

在 Vue.js 中,包括事件在内的所有内容都有对应的描述方式。用户无需编写任何命令式代码,这就是声明式地描述 UI。 除了使用模板声明式地描述 UI 之外,我们还可以使用 JavaScript 对象描述。例如:

const title = {
  tag: 'h1', // 标签名称
  props: { onClick: handler }, // 标签属性
  children: [ { tag: 'span' } ] // 子节点
}

这与 Vue.js 模板等价:

<h1 @click="handler"><span></span></h1>

使用模板和 JavaScript 对象描述 UI 的主要区别是:使用 JavaScript 对象描述 UI 更加灵活。 例如,如果我们需要展示一个标题,根据标题级别,可能需要使用 h1 到 h6 的标签。 如果使用 JavaScript 对象来描述,我们只需使用一个变量来表示 h 标签:

let level = 3; // h 标签的级别
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 对象灵活,而使用 JavaScript 对象描述 UI 的方式,实际上就是虚拟 DOM,在 Vue.js 组件中编写的渲染函数实际上就是使用虚拟 DOM 描述 UI,例如:

import { h } from 'vue'

export default {
  render() {
    return h('h1', { onClick: handler }) // 虚拟 DOM
  }
}

这里使用了 h 函数调用,而不是直接使用 JavaScript 对象。实际上,h 函数返回的就是一个对象。 它使得编写虚拟 DOM 更加轻松。如果将上面的 h 函数调用改为直接使用 JavaScript 对象,代码会变得更加繁琐:

export default {
  render() {
    return {
      tag: 'h1',
      props: { onClick: handler },
    };
  },
};

因此,h 函数实际上是一个辅助创建虚拟 DOM 的工具函数 组件要渲染的内容通过渲染函数来描述,即上述代码中的 render 函数。 Vue.js 根据组件的 render 函数返回值获取虚拟 DOM,然后将组件内容渲染出来。

3.2 深入了解渲染器

渲染器的核心功能是将虚拟 DOM 渲染为真实 DOM,每个 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 的结构可以自行设计,例如可以使用 tagName 代替 tag,因为它本身就是一个 JavaScript 对象,没有特殊含义。 我们需要编写一个渲染器,将上述虚拟 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') 如此一来,渲染器的实现似乎并不神秘。但请注意,我们目前只是创建了节点,渲染器的核心功能在于更新节点。假设我们对 vnode 做一些修改:

const vnode = {
  tag: 'div',
  props: {
    onClick: () => alert('hello')
  },
  children: 'click again' // 从 click me 改成 click again
}

对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容,以这个例子来说,渲染器应该只更新元素的文本内容即可,无需重新挂载,后面章节继续介绍这部分。

3.3 组件的本质与渲染

事实上,虚拟 DOM 不仅可以描述真实的DOM,还能描述组件。 例如,我们可以用 { tag: 'div' } 来描述 <div> 标签,但组件并非真实 DOM 元素,那么如何用虚拟 DOM 来描述组件呢? 首先得知道组件的本质是一组 DOM 元素的封装,这组 DOM 元素代表组件要渲染的内容。 因此,我们可以用一个函数来代表组件,函数的返回值代表组件要渲染的内容:

const MyComponent = function () {
  return {
    tag: 'div',
    props: {
      onClick: () => alert('hello')
    },
    children: 'click me'
  }
}

在这里,组件 MyComponent 的返回值是一个虚拟 DOM,代表了它所要渲染的内容。 进一步说,我们可以用虚拟 DOM 来描述一个组件,只需将组件函数存储在虚拟 DOM 对象的 tag 属性中:

const vnode = {
  tag: MyComponent
}

这里的 tag 属性就是一个组件,不再是一个标签名。当然,这需要渲染器的支持。我们可以稍微调整上文提到的 renderer 函数:

function renderer(vnode, container) {
  if (typeof vnode.tag === 'string') {
    // 如果 vnode 描述的是标签元素
    mountElement(vnode, container);
  } else if (typeof vnode.tag === 'function') {
    // 如果 vnode 描述的是组件
    mountComponent(vnode, container);
  }
}

在这个新的 renderer 函数中:

  • 如果 vnode.tag 是字符串,说明它描述的是普通标签元素,此时调用 mountElement 函数完成渲染。
  • 如果 vnode.tag 是函数,则说明它描述的是组件,此时调用 mountComponent 函数完成渲染。

mountElement 函数的内容与上章中 renderer 函数的内容一致。 而 mountComponent 函数是这样的:

function mountComponent(vnode, container) {
  // 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
  const subtree = vnode.tag();
  // 递归地调用 renderer 渲染 subtree
  renderer(subtree, container);
}

我们首先调用 vnode.tag 函数,这实际上就是组件函数本身,它的返回值是虚拟 DOM,即组件要渲染的内容,我们把它叫做 subtree。 由于 subtree 也是虚拟 DOM,我们可以直接调用 renderer 函数完成渲染。 当然,组件并不一定要用函数来表示。我们也可以用一个JavaScript对象来表示组件,例如:

const MyComponent = {
  render() {
    return {
      tag: 'div',
      props: {
        onClick: () => alert('hello')
      },
      children: 'click me'
    }
  }
}

这个例子中,我们使用一个对象来代表组件,该对象有一个名为 render 的函数,其返回值代表了组件要渲染的内容。 为了完成组件的渲染,我们需要稍微调整一下 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(即渲染函数)还是使用模板,它们都是在 Vue 中声明式地描述 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 是各个模块组成的有机整体

Vue.js 中,组件的实现依赖渲染器,模板的编译依赖编译器。 编译后生成的代码是受渲染器和虚拟 DOM 设计的影响。 因此,Vue.js 中的各个模块相互关联,相互影响,共同构成了一个有机整体。 以下以编译器和渲染器这两个关键模块为例,让我们来看看它们如何协同工作以提高性能。 假设我们有如下模板:

<div id="foo" :class="cls"></div>

根据前面的介绍,我们知道编译器会将这段代码编译成渲染函数:

render() {
  // 为了直观,这里直接使用虚拟 DOM 对象,而非 h 函数
  // 下面的代码等同于:return h('div', { id: 'foo', class: cls })
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls
    }
  }
}

上面的模板为例,我们可以很容易地看出其中 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 对象协同工作,进一步提升性能。 在后续的学习中,我们会发现虚拟 DOM 对象中包含了多种数据字段,每个字段都有特定的含义。

3.6 小结

本章中,我们首先探讨了声明式 UI 的理念。Vue.js 作为一个声明式框架,允许用户直接描述结果,无需关心具体过程。虽然 Vue.js 主要通过模板来描述 UI,但它也支持使用虚拟 DOM 来实现。相比之下,虚拟 DOM 更加灵活,而模板则更直观。 其次,我们深入了解了渲染器的基础实现。渲染器的主要任务是将虚拟 DOM 对象转化为真实 DOM 元素。其工作原理主要是递归遍历虚拟 DOM 对象,并通过原生 DOM API 创建真实 DOM。值得一提的是,渲染器的核心在于其更新策略,它利用 Diff 算法找出变更点,并仅更新需要更新的内容。在后续章节中,我们会专门讲解渲染器相关知识。 然后,我们探讨了组件的本质。简单来说,组件就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象必须包含一个用于生成组件渲染虚拟 DOM 的函数。当渲染器渲染组件时,它会先执行组件的渲染函数并得到返回值(我们称之为子树),再递归调用渲染器将子树渲染出来。 最后,我们介绍了编译器的角色,它会将 Vue.js 的模板编译成渲染函数。编译器和渲染器是 Vue.js 的核心组成部分,共同构成了一个有机的整体,各模块之间互相配合,以进一步提升框架的性能。在后续章节中,我们将详细讲解编译器相关知识。

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhfhkhif
系列文章
更多 icon
同类精品
更多 icon
继续加载