Skip to content
目录

第1章 权衡的艺术

框架的各个模块并非相互独立,而是相互关联和制约。

1.1 命令式和声明式

视图层框架通常分为命令式和声明式。 命令式框架关注过程,如jQuery,以下是用 jQuery 和原生 JavaScript 实现的一个例子:

javascript
// jQuery
$('#app').text('hello world').on('click', () => { alert('ok') });

// 原生 JavaScript
const div = document.querySelector('#app');
div.innerText = 'hello world';
div.addEventListener('click', () => { alert('ok') });

声明式框架关注结果,如 Vue.js。以下是用 Vue.js 实现相同功能的例子:

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

我们提供了一个“结果”,Vue.js 负责实现。这意味着 Vue.js 的内部实现是命令式的,但暴露给用户的 API 是声明式的。这种设计有助于简化代码,提高可维护性。

1.2 性能与可维护性的权衡

命令式和声明式范式在框架设计中体现为性能和可维护性的权衡。 在理论上,命令式代码可以优化到极致性能,因为我们知道哪些发生了变更,只需做必要的修改。 而声明式代码描述的是结果,为了实现最优的更新性能,框架需要找到前后的差异并只更新变化的地方,因此其性能无法超越命令式代码。 尽管命令式代码在性能上具有优势,但 Vue.js 选择声明式设计是因为声明式代码具有更强的可维护性。声明式代码展示的是结果,更直观,而过程则由框架封装。在保持可维护性的同时,框架设计者要做的是最小化性能损失。

1.3 虚拟 DOM 的性能如何?

命令式和声明式范式在性能与可维护性方面各有优缺点。 声明式代码的性能通常不优于命令式代码的性能,因为声明式代码需要计算差异来更新结果。 以一个文本内容修改的例子为例,命令式代码可以直接修改:

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

而声明式代码需要找出前后的差异:

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

框架会先找出差异,再进行更新。这使得声明式代码的性能消耗总是大于或等于命令式代码的性能消耗。 尽管在性能上命令式代码更优,但 Vue.js 选择了声明式设计,因为声明式代码更直观和易于理解。声明式代码展示我们要的结果,而过程由框架封装。 框架设计需要在可维护性与性能之间权衡。设计者的目标是在保持可维护性的同时最小化性能损失。

1.4 运行时与编译时

当设计一个框架时,我们可以选择纯运行时、运行时 + 编译时或纯编译时 纯运行时框架: 它提供了一个Render函数,用户可以提供一个树型结构的数据对象,Render函数会根据该对象渲染DOM元素

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

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) {
    obj.children.forEach((child) => Render(child, el));
  }
  root.appendChild(el);
}

Render(obj, document.body);

上面是一个纯运行时框架。当用户希望使用HTML标签描述结构时,我们可以引入编译的手段。 运行时 + 编译时框架: 引入一个Compiler程序,把HTML字符串编译成树型结构的数据对象。

javascript
const html = `
<div>
  <span>hello world</span>
</div>
`;

const obj = Compiler(html);
Render(obj, document.body);

这是运行时+编译时框架。可以在构建时执行Compiler程序以提高性能。 纯编译时框架(Svelte 就是一个纯编译时框架): 编译器可以将HTML字符串编译成命令式代码。

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

这是一个纯编译时框架。优势在于性能,但损失了灵活性。 总结: 纯运行时框架易用但性能有限;纯编译时框架性能优越但灵活性差;运行时+编译时框架兼具灵活性与性能优化。例如,Vue.js 3保留了运行时+编译时架构,在灵活性基础上进行优化。

第2章 框架设计核心要素

框架设计比想象中复杂。设计中需考虑:构建产物、模块格式、警告信息、开发与生产构建区别、HMR 支持以及功能选择。需要熟悉 rollup.js 和 webpack。

2.1 提升用户开发体验

优秀框架的一个标准是开发体验。以 Vue.js 3 为例,当尝试挂载一个不存在的 DOM 节点时,会收到一条警告信息。这有助于快速定位问题。提供友好的警告信息对框架设计至关重要。 Vue.js 源码中有 warn 函数调用,例如:

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

warn 函数最终调用 console.warn 函数,同时收集错误的组件栈信息。 为进一步提升开发体验,Vue.js 3 允许自定义 formatter 以优化控制台输出。例如:

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

要启用自定义 formatter,请在 DevTools 设置中勾选 “Console” → “Enable custom formatters”。这样,控制台输出会变得更加直观。

2.2 控制框架代码体积

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

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

DEV 常量通过 rollup.js 插件配置预定义,类似于 webpack 的 DefinePlugin 插件。Vue.js 输出开发和生产环境资源,如 vue.global.js 和 vue.global.prod.js。 在开发环境构建中,DEV 常量设为 true。这段输出警告信息的代码在开发环境中存在。 在生产环境构建中,DEV 常量设为 false。此时,这段代码成为 dead code,不会出现在最终产物中。这样,在开发环境中提供友好警告信息,同时不增加生产环境代码体积。

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

Tree-Shaking 是一种消除永远不会被执行的代码(即 dead code)的技术,它能有效减小项目的构建资源体积。 Tree-Shaking 要求模块必须使用 ESM (ES Module) 格式,因为它依赖于 ESM 的静态结构。现在无论是 rollup.js 还是 webpack,都支持 Tree-Shaking。 以 rollup.js 为例,首先安装 rollup.js:

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

接下来,创建 input.js 和 utils.js 文件:

javascript
// input.js
import { foo } from './utils.js'
foo()
// utils.js
export function foo(obj) {
  obj && obj.foo
}
export function bar(obj) {
  obj && obj.bar
}

执行构建命令:

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

命令执行后,查看 bundle.js,可以发现 bar 函数被 Tree-Shaking 移除了。然而,foo 函数调用仅读取对象值,其执行似乎没有必要。那为何 rollup.js 不将其作为 dead code 移除呢? 这涉及到 Tree-Shaking 的第二个关键点——副作用。若一个函数调用会产生副作用,便不能移除。副作用是指调用函数时会对外部产生影响,如修改全局变量。虽然上述代码似乎无副作用,但在某些情况下可能产生,如通过 Proxy 创建的代理对象。要静态分析 JavaScript 代码的 dead code 非常困难,但工具如 rollup.js 提供了机制让我们告知其哪些代码无副作用,可放心移除。 如下所示,修改 input.js 文件:

javascript
import { foo } from './utils'

/*#__PURE__*/ foo()

使用注释代码 /#PURE/ 告诉 rollup.js,foo 函数调用不会产生副作用,可以进行 Tree-Shaking。再次构建并查看 bundle.js 文件,会发现它的内容为空,说明 Tree-Shaking 生效了。 基于此案例,我们应在编写框架时合理使用 /#PURE/ 注释。查看 Vue.js3 源码,你会发现大量使用了这个注释,如:

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

这会对编写代码造成很大的心智负担吗?其实不会,因为通常产生副作用的代码都是模块内函数的顶级调用。例如:

javascript
foo() // 顶级调用

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

对于顶级调用,可能产生副作用;但对于函数内调用,只要函数 bar 没有被调用,foo 函数的调用自然不会产生副作用。因此,在 Vue.js 3 源码中,基本都是在一些顶级调用的函数上使用 /#PURE/ 注释。当然,这个注释不仅仅作用于函数,它可以应用于任何语句上。此外,不仅 rollup.js 能识别这个注释,webpack 以及压缩工具(如 terser)也能识别它。 总之,在编写框架时,要充分利用 Tree-Shaking 优化框架体积。这意味着要确保代码遵循 ESM 规范,并合理使用 /#PURE/ 注释。这样,框架用户在构建时,可以确保只打包实际使用的代码,从而降低最终项目的体积。

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

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

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

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

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

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

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

使用 <script> 标签直接引入 vue.global.js 文件后,全局变量 Vue 便可用。在 rollup.js 中,我们可以通过配置 format: 'iife' 来输出 IIFE 形式的资源。 现在主流浏览器对原生 ESM 支持良好,因此用户可以直接引入 ESM 格式的资源。例如,Vue.js 3 还会输出 vue.esm-browser.js 文件,用户可以直接用 <script type="module"> 标签引入:

html
<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 字段指向的资源。Vue.js 源码的 packages/vue/package.json 文件如下:

json
{
  "main": "index.js",
  "module": "dist/vue.runtime.esm-bundler.js",
}

其中 module 字段指向的是 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 常量。例如下面的源码:

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

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

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

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

javascript
const Vue = require('vue')

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

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

export default config

通过以上配置,我们可以满足不同场景下的资源引入需求,包括直接在 HTML 页面使用 <script> 标签引入、在打包工具中使用 ESM 格式资源以及在 Node.js 环境中使用 CommonJS 格式资源。这样,Vue.js 框架就可以在各种环境和场景下顺利工作。

2.5 特性开关

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

javascript
{
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? '__VUE_OPTIONS_API__' : true,
}

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

javascript
// support for 2.x options
if (__FEATURE_OPTIONS_API__) {
  currentInstance = instance;
  pauseTracking();
  applyOptions(instance, Component);
  resetTracking();
  currentInstance = null;
}

当 Vue.js 构建资源时,如果构建的资源是供打包工具使用的(即带有 -bundler 字样的资源),那么上述代码在资源中会变成:

javascript
// 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 插件来实现:

javascript
// 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,包含多个函数。为简化用户使用,我们可以内部处理错误,如下:

javascript
// utils.js
export default {
  foo(fn) {
    callWithErrorHandling(fn);
  },
  registerErrorHandler(fn) {
    handleError = fn;
  },
};

function callWithErrorHandling(fn) {
  try {
    fn && fn();
  } catch (e) {
    handleError(e);
  }
}

用户可以使用 registerErrorHandler 函数注册统一的错误处理函数。当内部捕获错误后,将错误传递给用户注册的错误处理程序。用户侧代码简洁且健壮:

javascript
import utils from 'utils.js';

utils.registerErrorHandler((e) => {
  console.log(e);
});

utils.foo(() => {/*...*/});
utils.bar(() => {/*...*/});

这种方法将错误处理的能力交给用户,可以选择忽略错误或上报给监控系统。 实际上,这是 Vue.js 错误处理的原理,其中包含 callWithErrorHandling 函数。在 Vue.js 中,可以注册统一的错误处理函数:

javascript
import App from 'App.vue';
const app = createApp(App);

app.config.errorHandler = () => {
  // 错误处理程序
};

2.7 良好的 TypeScript 类型支持

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

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

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

typescript
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)的支持。后续章节会详细讨论这部分内容。

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

3.1 以声明式方式描述 UI

Vue.js 3 是一个声明式 UI 框架,用户在使用 Vue.js 3 开发页面时以声明式方式描述 UI,让我们思考一下如何设计一个声明式 UI 框架。首先,我们需要明确前端页面涉及的内容,包括:

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

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

  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 对象描述。例如:

javascript
const title = {
  tag: 'h1',
  props: {
    onClick: handler,
  },
  children: [{ tag: 'span' }],
};

这与 Vue.js 模板等价:

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

使用模板和 JavaScript 对象描述 UI 的主要区别是:使用 JavaScript 对象描述 UI 更加灵活。 例如,当我们需要表示一个根据级别不同采用 h1~h6 标签的标题时,使用 JavaScript 对象描述可以简单地使用一个变量代表 h 标签:

javascript
let level = 3;
const title = {
  tag: `h${level}`, // h3 标签
};

可以看到,当变量 level 值改变时,对应的标签名称也会在 h1 和 h6 之间变化。然而,如果使用模板描述,就需要穷举:

html
<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,例如:

javascript
import { h } from 'vue';

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

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

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

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

3.2 深入了解渲染器

渲染器的核心功能是将虚拟 DOM 渲染为真实 DOM image.png 我们平时编写的 Vue.js 组件都依赖于渲染器来工作,假设我们有以下虚拟 DOM:

javascript
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:

javascript
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 函数:

javascript
renderer(vnode, document.body) // body 作为挂载点

在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出 alert('hello') 如此一来,渲染器的实现似乎并不神秘。但请注意,我们目前只是创建了节点,渲染器的核心功能在于更新节点。假设我们对 vnode 做一些修改:

javascript
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 元素代表组件要渲染的内容,因此,我们可以用一个函数来代表组件,函数的返回值代表组件要渲染的内容:

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

从上面的代码可见,组件的返回值也是虚拟DOM,代表组件要渲染的内容。 我们这时候可以让虚拟DOM 对象中的 tag 属性存储组件函数:

javascript
const vnode = {
  tag: MyComponent
}

就像 tag: 'div' 用来描述 <div> 标签一样,tag: MyComponent 用来描述组件。为了能够渲染组件,我们需要修改之前提到的 renderer 函数

javascript
function renderer(vnode, container) {
  if (typeof vnode.tag === 'string') {
    mountElement(vnode, container);
  } else if (typeof vnode.tag === 'function') {
    mountComponent(vnode, container);
  }
}

我们来看一下 mountComponent 函数如何实现:

javascript
function mountComponent(vnode, container) {
  const subtree = vnode.tag();
  renderer(subtree, container);
}

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

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

为了满足使用对象表示组件的需求,我们需要修改 renderer 渲染器以及 mountComponent 函数。 首先,修改渲染器的判断条件:

javascript
function renderer(vnode, container) {
  if (typeof vnode.tag === 'string') {
    mountElement(vnode, container);
  } else if (typeof vnode.tag === 'object') {
    mountComponent(vnode, container);
  }
}

现在我们使用对象而不是函数来表达组件,因此需要将 typeof vnode.tag === 'function' 修改为 typeof vnode.tag === 'object'。 接着,修改 mountComponent 函数:

javascript
function mountComponent(vnode, container) {
  const subtree = vnode.tag.render();
  renderer(subtree, container);
}

在上述代码中,vnode.tag 是表示组件的对象,调用该对象的 render 函数得到组件要渲染的内容,即虚拟 DOM。 经过这些修改,我们就可以满足使用对象来表示组件的需求,实际上,在 Vue.js 中,有状态组件就是使用对象结构来表示的。

3.4 模板的工作原理

无论是手写虚拟 DOM(渲染函数)还是使用模板,它们都是在 Vue 中声明式地描述 UI 之前我们了解虚拟 DOM 如何渲染成真实 DOM,那么模板是如何工作的呢?这就涉及到 Vue.js 框架中另一个重要组成部分:编译器。 编译器的任务是将模板编译成渲染函数,例如,给定如下模板:

html
<div @click="handler">
  click me
</div>

对于编译器来说,模板是一个普通的字符串,它会分析这个字符串并生成一个功能相同的渲染函数:

javascript
render() {
  return h('div', { onClick: handler }, 'click me')
}

以熟悉的 .vue 文件为例,一个 .vue 文件代表一个组件,如下所示:

vue
<template>
  <div @click="handler">
    click me
  </div>
</template>

<script>
export default {
  data() {/* ... */},
  methods: {
    handler: () => {/* ... */}
  }
}
</script>

其中,<template> 标签内的内容就是模板。编译器会将模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上。因此,在浏览器中运行的代码如下:

javascript
export default {
  data() {/* ... */},
  methods: {
    handler: () => {/* ... */}
  },
  render() {
    return h('div', { onClick: handler }, 'click me')
  }
}

所以,无论是使用模板还是直接编写渲染函数,对于组件来说,要渲染的内容最终都是通过渲染函数产生的,然后,渲染器将渲染函数返回的虚拟 DOM 渲染成真实 DOM。这就是模板的工作原理以及 Vue.js 渲染页面的流程。

3.5 Vue.js 是各个模块组成的有机整体

Vue 组件实现依赖于渲染器,模板编译依赖于编译器,而编译后生成的虚拟DOM是根据渲染器的设计决定的 以编译器和渲染器这两个关键模块为例,我们来看看它们如何协同工作以提升性能。 假设我们有如下模板:

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

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

javascript
render() {
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls
    }
  }
}

上面的模板为例,我们可以很容易地看出其中 id="foo" 是永远不会变化的,而 :class="cls" 是一个 v-bind 绑定,它可能发生变化,因此,编译器能够识别静态属性和动态属性,并在生成代码时附带这些信息:

javascript
render() {
  return {
    tag: 'div',
    props: {
      id: 'foo',
      class: cls
    },
    patchFlags: 1 // 假设数字 1 表示 class 是动态的
  }
}

如上所示,在生成的虚拟 DOM 对象中,新增了一个 patchFlags 属性。我们假设数字 1 表示“class 是动态的”,这对于渲染器来说,这就相当于减轻了寻找变更点的负担,从而提高了性能。

May all encounters not be in vain