1. 工程架构设计
Vue 3 是一个现代化的前端框架,采用模块化设计,源码项目被划分为多个模块,每个模块负责不同的功能。
1.1. compiler-core
compiler-core 是 Vue 3 的编译核心模块,主要负责将模板转换为渲染函数。其模块如下:
-
Parser(解析器):将模板字符串解析成抽象语法树(AST)。
-
Transform(转换器):遍历 AST,进行必要的转换,比如处理指令、插值、事件等。
-
Codegen(代码生成器):将转换后的 AST 转换成 JavaScript 渲染函数。
这个模块是通用的,可以被用于不同平台的编译器。
1.2. compiler-dom
compiler-dom 是 compiler-core 的一个扩展,针对浏览器环境进行了优化。其架构在 compiler-core 的基础上增加了以下功能:
-
DOM 特定的解析与转换:处理与浏览器 DOM 相关的特性,例如 HTML 标签、属性和事件。
-
平台特定优化:通过优化特定于浏览器的代码生成,来提升性能。
1.3. compiler-sfc
compiler-sfc 是用来处理单文件组件的模块。其主要职责包括:
-
解析 SFC:将 .vue 文件解析为模板、脚本和样式三部分。
-
处理块:使用 compiler-core 和 compiler-dom 对模板部分进行编译。
-
生成代码:最终生成包含渲染函数的组件对象。
1.4. reactivity
reactivity 模块是 Vue 3 响应式系统的核心。其主要组件包括:
-
Reactive API:提供 reactive 和 ref 等 API,将普通对象转换为响应式对象。
-
Effect:追踪依赖关系并在依赖变化时触发重新计算。
-
依赖收集和触发机制:实现了依赖收集(track)和依赖触发(trigger)逻辑。
1.5. runtime-core
runtime-core 是 Vue 3 的核心运行时模块,负责创建组件实例并处理组件生命周期。其主要架构包括:
-
组件实例:管理组件的状态、生命周期钩子、以及与模板的绑定。
-
渲染器:负责调用渲染函数生成虚拟 DOM(vDOM),并进行后续的 DOM 操作。
-
虚拟 DOM 和 Diff 算法:用于高效的更新 UI。
-
调度器:控制组件的更新顺序和优先级。
1.6. runtime-dom
runtime-dom 是 runtime-core 的一个扩展,专门为浏览器环境提供支持。其架构包括:
-
DOM 操作:提供操作 DOM 元素的能力,如创建、更新和删除 DOM 元素。
-
事件处理:处理浏览器事件,并将其分派给组件。
-
平台特定优化:针对浏览器环境进行性能优化。
1.7. 综合架构
整体上,Vue 3 的架构设计是高度模块化和可扩展的。各个模块之间通过明确的接口进行交互,保证了代码的清晰度和可维护性。以下是一个简化的架构图示意:
1.7.1. 架构设计图
javascript
+-------------------+
| Vue3 Source Code |
+----------+----------+
|
+----------+----------+ +-------------------+
| compiler-core | | reactivity |
+----------+----------+ +-------------------+
| |
+----------+----------+ +----------+----------+
| compiler-dom | | runtime-core |
+----------+----------+ +----------+----------+
| |
^ ^
+----------+----------+ +----------+----------+
| compiler-sfc | | runtime-dom |
+---------------------+ +---------------------+
-
compiler-core 和 reactivity 模块提供了核心的编译和响应式机制。
-
compiler-dom 和 runtime-core 分别针对编译和运行时进行具体实现。
-
compiler-sfc 和 runtime-dom 则在此基础上进行扩展,适应浏览器和单文件组件的需求。
在 Vue 3源码中,除了前面提到的几个核心模块之外,还有一些其他重要的文件夹和文件,它们分别负责不同的功能。
1.7.2. 源码项目文件树
javascript
vue-next
├── packages
│ ├── compiler-core
│ ├── compiler-dom
│ ├── compiler-sfc
│ ├── reactivity
│ ├── runtime-core
│ ├── runtime-dom
│ └── shared
├── scripts
├── test-dts
├── typings
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .prettierrc
├── .yarnrc
├── LICENSE
├── package.json
├── README.md
├── tsconfig.json
└── yarn.lock
1.7.3. 文件夹和文件详细说明
- packages
packages 文件夹包含了 Vue 3 的各个模块,前面已经详细介绍了 compiler-core、compiler-dom、compiler-sfc、reactivity、runtime-core 和 runtime-dom 这六个核心模块。除此之外,还有以下两个重要模块:
-
shared:包含一些共享的工具函数和类型定义,这些工具函数和类型在多个模块中都会用到。它提供了基础的功能支持和类型约束。
-
vue:这个文件夹是整个 Vue3 框架的入口,它将前面提到的各个模块组合起来,形成完整的 Vue 框架。它还包含一些全局 API 的定义和实现,例如 createApp、h 函数等。
- scripts
scripts 文件夹包含了各种脚本文件,用于构建、打包和发布项目。这些脚本主要用于自动化处理任务,如测试、构建发布等。
- test-dts
test-dts 文件夹用于测试 TypeScript 类型定义,确保类型定义的准确性和完整性。Vue 3 是用 TypeScript 编写的,所以类型定义和类型检查是非常重要的一部分。
- typings
typings 文件夹包含项目中使用的一些自定义 TypeScript 类型定义。这些定义补充了 TypeScript 本身的类型系统,使得项目类型更为精确。
- 根目录文件
-
.editorconfig:定义编辑器配置,确保不同开发者在不同编辑器中代码风格一致。
-
.eslintignore:指定 ESLint 需要忽略的文件和目录。
-
.eslintrc.js:ESLint 配置文件,定义代码检查规则。
-
.gitignore:定义 Git 版本控制中需忽略的文件和目录。
-
.npmignore:定义发布到 npm 时需忽略的文件和目录。
-
.prettierrc:Prettier 配置文件,定义代码格式化规则。
-
.yarnrc:Yarn 配置文件,自定义 Yarn 行为。
-
LICENSE:项目许可证文件。
-
package.json:npm 项目配置文件,定义依赖、脚本等信息。
-
README.md:项目说明文件,提供基本信息、安装和使用方法。
-
tsconfig.json:TypeScript 配置文件,定义编译器选项。
-
yarn.lock:Yarn 锁定文件,确保不同环境安装相同依赖版本。
1.7.4. 详细文件树
javascript
vue-next
├── packages
│ ├── compiler-core
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── compiler-dom
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── compiler-sfc
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── reactivity
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── runtime-core
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── runtime-dom
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ ├── shared
│ │ ├── src
│ │ ├── __tests__
│ │ ├── package.json
│ │ └── README.md
│ └── vue
│ ├── src
│ ├── __tests__
│ ├── package.json
│ └── README.md
├── scripts
│ ├── build.js
│ ├── release.sh
│ └── test.js
├── test-dts
│ ├── component-type-checks.ts
│ ├── ref-type-checks.ts
│ └── vue-type-checks.ts
├── typings
│ ├── vue.d.ts
│ └── compiler-sfc.d.ts
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .prettierrc
├── .yarnrc
├── LICENSE
├── package.json
├── README.md
├── tsconfig.json
└── yarn.lock
2. 编译过程
Vue 3 编译过程涉及多个模块的协同工作,主要包括 compiler-core、compiler-dom 和 compiler-sfc。以下将详细说明这些模块在整体编译过程中的作用,并以 vue-loader 和 vite-plugin-vue 为例说明编译工具的应用。
2.1. 编译过程简述
- compiler-sfc
compiler-sfc 负责处理单文件组件。它的主要职责包括:
-
解析 SFC:将 .vue 文件解析为模板、脚本和样式三部分。
-
处理块:使用 compiler-core 和 compiler-dom 对模板部分进行编译。
-
生成代码:最终生成包含渲染函数的组件对象。
具体过程如下:
-
解析 SFC 文件:使用 parse 函数将 .vue 文件解析成 SFCDscriptor 对象。这个对象包含了 template、script 和 styles 等部分。
-
处理模板:如果存在模板部分,将其传递给 compiler-core 和 compiler-dom 进行编译。
-
生成代码:将处理后的模板代码、脚本和样式组合成一个完整的组件对象,最终生成 JavaScript 代码。
- compiler-core
compiler-core 是 Vue 3 的编译核心模块,主要负责将模板转换为渲染函数。其主要步骤包括:
-
解析(Parser):使用 baseParse 函数将模板字符串解析成抽象语法树(AST)。
-
转换(Transform):使用一系列转换插件(transforms)对 AST 进行转换,例如处理指令、插值和事件等。
-
代码生成(Codegen):将转换后的 AST 转换成 JavaScript 渲染函数。使用 generate 函数生成最终的渲染代码。
- compiler-dom
compiler-dom 是 compiler-core 的一个扩展,针对浏览器环境进行了优化。其主要步骤包括:
-
DOM 特定的解析与转换:处理与浏览器 DOM 相关的特性,例如 HTML 标签、属性和事件。compiler-dom 提供了一些特定于 DOM 的转换插件,这些插件在 compiler-core 的基础上进行扩展。
-
平台特定优化:通过优化特定于浏览器的代码生成,来提升性能。
2.2. 详细编译过程
- compiler-sfc
compiler-sfc 模块用于解析和处理 .vue 单文件组件。以下是解析和处理的过程,基于 Vue3 最新源码。
主要文件:
-
packages/compiler-sfc/src/parse.ts
-
packages/compiler-sfc/src/compileTemplate.ts
解析 SFC 文件:
parse 函数将 .vue 文件解析为 SFCDescriptor 对象,包含 template、script、styles 和 customBlocks 部分。
javascript
import { parse } from '@vue/compiler-sfc';
const source = `<template><div>{{ message }}</div></template>
<script>
export default {
data() {
return {
message: 'Hello Vue3'
}
}
}
</script>`;
const descriptor = parse(source);
console.log(descriptor);
处理模板:
compileTemplate 函数编译模板部分,生成渲染函数。
javascript
import { compileTemplate } from '@vue/compiler-sfc';
const template = descriptor.descriptor.template;
const result = compileTemplate({
source: template.content,
filename: 'example.vue',
id: 'example'
});
console.log(result);
- compiler-core
compiler-core 模块是 Vue3 的编译核心,负责将模板转换为渲染函数。
主要文件:
-
packages/compiler-core/src/parse.ts
-
packages/compiler-core/src/transform.ts
-
packages/compiler-core/src/codegen.ts
解析模板:
baseParse 函数将模板字符串解析成抽象语法树(AST)。
javascript
import { baseParse } from '@vue/compiler-core';
const ast = baseParse('<div>{{ message }}</div>');
console.log(ast);
转换 AST:
transform 函数使用一系列转换插件对 AST 进行转换。
javascript
import { transform } from '@vue/compiler-core';
import { transformExpression } from '@vue/compiler-core';
import { transformElement } from '@vue/compiler-core';
transform(ast, {
nodeTransforms: [transformExpression, transformElement]
});
console.log(ast);
代码生成:
generate 函数将转换后的 AST 转换成 JavaScript 渲染函数。
javascript
import { generate } from '@vue/compiler-core';
const { code } = generate(ast);
console.log(code);
- compiler-dom
compiler-dom 是 compiler-core 的一个扩展,处理与浏览器 DOM 相关的特性。
主要文件:
- packages/compiler-dom/src/index.ts
DOM 特定的解析与转换:
compiler-dom 提供了一些特定于 DOM 的转换插件,如 transformStyle、transformVHtml 和 transformModel。
javascript
import { baseCompile } from '@vue/compiler-dom';
const { code } = baseCompile('<div v-html="message"></div>', {
nodeTransforms: [],
directiveTransforms: {
html: transformVHtml
}
});
console.log(code);
- 整体流程示意图
javascript
.vue 文件
|
v
解析 (compiler-sfc)
|
v
模板 -> 抽象语法树 (AST) (compiler-core)
|
v
AST 转换 (compiler-core & compiler-dom)
|
v
渲染函数 (compiler-core)
|
v
生成 JavaScript 模块 (vue-loader / vite-plugin-vue)
|
v
Webpack/Vite 打包
|
v
运行时 (runtime-core & runtime-dom)
执行流程如下:

- 总结
-
解析 SFC 文件:使用 compiler-sfc 将 .vue 文件解析成 SFCDscriptor 对象。
-
编译模板:使用 compiler-core 和 compiler-dom 将模板编译成渲染函数。
-
生成模块:将处理后的模板、脚本和样式组合成 JavaScript 模块。
-
打包工具应用:使用 vue-loader 或 vite-plugin-vue 将生成的模块打包,供浏览器使用。
这种流程确保了 Vue 3 的高效编译和运行,结合现代打包工具,为开发者提供了良好的开发体验。
2.3. 编译时工具的应用
- vue-loader
vue-loader 是用于 Webpack 的 Vue 单文件组件加载器,它在编译时将 .vue 文件转换为 JavaScript 模块。
以下是 vue-loader 的主要工作流程:
-
解析 SFC 文件:使用 compiler-sfc 解析 .vue 文件,将其分解为模板、脚本和样式。
-
编译模板:使用 compiler-core 和 compiler-dom 编译模板部分,生成渲染函数。
-
处理脚本和样式:将脚本和样式部分提取出来,通过相应的 loader(如 babel-loader 和 css-loader)进行处理。
-
生成模块:最终将处理后的模板、脚本和样式组合成一个完整的 JavaScript 模块,交给 Webpack 进行打包。
javascript
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
// 其他规则
]
},
plugins: [
new VueLoaderPlugin()
]
}
- vite-plugin-vue
vite-plugin-vue 是用于 Vite 的 Vue 插件,它在编译时处理 Vue 单文件组件。以下是 vite-plugin-vue 的主要工作流程:
-
解析 SFC 文件:使用 compiler-sfc 解析 .vue 文件,将其分解为模板、脚本和样式。
-
编译模板:使用 compiler-core 和 compiler-dom 编译模板部分,生成渲染函数。
-
处理脚本和样式:将脚本和样式部分提取出来,通过 Vite 的内置处理器或其他插件进行处理。
-
生成模块:最终将处理后的模板、脚本和样式组合成一个完整的 JavaScript 模块,交给 Vite 进行打包。
javascript
// vite.config.js
import vue from '@vitejs/plugin-vue'
export default {
plugins: [vue()]
}
2.4. 总结
Vue 3 的编译过程是一个复杂但模块化的系统,通过 compiler-sfc、compiler-core 和 compiler-dom 的协同工作,最终将 Vue 单文件组件编译成高效的 JavaScript 代码。vue-loader 和 vite-plugin-vue 是分别适用于 Webpack 和 Vite 的编译工具,它们在编译时利用 Vue3 的编译模块,提供了一种高效的方式来处理和打包 Vue 单文件组件。
3. 数据响应时
3.1. 编译工具与响应式原理
Vue 3 使用 JavaScript 的 Proxy 对象来拦截对对象的操作,并结合 Reflect 来处理这些操作,从而实现响应式。核心思想是通过代理模式拦截对象属性的访问和修改,进行依赖收集和触发更新。
3.2. Proxy 与 Reflect 的使用
Vue 3 通过 Proxy 对象创建响应式对象,该对象会拦截并监听所有对其属性的操作。Reflect 提供了一组方法用于默认行为处理,如 Reflect.get 和 Reflect.set。
javascript
import { mutableHandlers } from './baseHandlers';
import { ReactiveFlags } from './constants';
export const reactiveMap = new WeakMap();
function createReactiveObject(target, baseHandlers, proxyMap) {
if (typeof target !== 'object' || target === null) {
return target;
}
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
const proxy = new Proxy(target, baseHandlers);
proxyMap.set(target, proxy);
return proxy;
}
export function reactive(target) {
// ...
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap,
);
}
3.3. 响应式系统中的数据结构
Vue 3 中使用到的数据结构相对较多,服务于依赖收集与缓存:
-
Map
-
WeakMap
-
Set
Vue 3 的响应式系统主要依赖两个核心机制:依赖收集和触发更新。
-
依赖收集:用于收集哪些副作用函数依赖于某个属性。
-
触发更新:用于在属性变化时通知所有依赖于该属性的副作用函数执行。
javascript
const targetMap = new WeakMap();
function track(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
dep.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
- 整体流程
-
创建响应式对象:通过 reactive 函数将普通对象转换为响应式对象。
-
依赖收集:在读取对象属性时,收集依赖于该属性的副作用函数。
-
触发更新:在设置对象属性时,通知所有依赖于该属性的副作用函数执行。
- 响应式处理
Vue 3 的响应式系统不仅能处理普通类型,也能处理引用类型。为了支持这一点,Vue3 提供了不同的 handlers 来处理不同的数据类型。
普通类型:对于普通类型,直接通过类的 get 和 set 进行拦截处理。
引用类型:对于引用类型,需要递归地将其转换为响应式对象。Vue 3 使用 createReactiveObject 函数来处理对象的递归转换。
javascript
function createReactiveObject(
target, isReadonly, baseHandlers, collectionHandlers, proxyMap
) {
if (!isObject(target)) {
return target;
}
const existingProxy = proxyMap.get(target);
if (existingProxy) {
return existingProxy;
}
const proxy = new Proxy(
target,
getTargetType(target) === TargetType.COLLECTION ? collectionHandlers : baseHandlers
);
proxyMap.set(target, proxy);
return proxy;
}
- 示例代码
通过以下示例代码,展示了 Vue 3 响应式系统的基本原理和实现过程,定义了 reactive 和 shallowReactive 函数,用于创建深度和浅度响应式对象:
javascript
import { reactive, shallowReactive } from 'vue';
const state = reactive({ count: 0, nested: { value: 10 } });
effect(() => {
console.log(`Count: ${state.count}`);
});
effect(() => {
console.log(`Nested value: ${state.nested.value}`);
});
state.count++;
state.nested.value++;
const shallowState = shallowReactive({ foo: 1, nested: { bar: 2 } });
effect(() => {
console.log(`Foo: ${shallowState.foo}`);
});
shallowState.foo++;
// shallowState.nested.bar 不会触发 effect,因为是浅响应式
shallowState.nested.bar++;
通过代码展示了如何使用 Vue 3 的响应式系统创建响应式对象,并通过副作用函数响应数据变化。使用 Proxy 和 Reflect 创建高效响应式系统,实现对普通类型和引用类型的精确监听和处理。
4. 运行时
4.1. diff 过程初探
下面是一个简单的步骤图,来说明在 Vue 3 的 diff 过程中如何利用最长递增子序列(LIS)算法来优化节点的更新和移动操作。
假设有以下旧子节点和新子节点:
javascript
1 旧子节点 (c1): [a, b, c, d, e]
2 新子节点 (c2): [b, d, e, a, c]
步骤1:初始化和比较
初始化两个指针 i 和 e1 分别指向旧节点数组的开始和结束位置。
e2 指向新节点数组的结束位置。
javascript
1 i: 0 e1: 4
2 旧子节点: [a, b, c, d, e]
3 新子节点: [b, d, e, a, c]
4 e2: 4
步骤2:前缀匹配
从头开始比较旧节点和新节点,直到遇到不相同的节点。
javascript
1 i: 1 e1: 4
2 旧子节点: [a, b, c, d, e]
3 新子节点: [b, d, e, a, c]
4 e2: 4
步骤3:后缀匹配
从尾部开始比较旧节点和新节点,直到遇到不相同的节点。
javascript
1 i: 1 e1: 2
2 旧子节点: [a, b, c, d, e]
3 新子节点: [b, d, e, a, c]
4 e2: 2
步骤4:处理中间部分
使用最长递增子序列算法处理中间部分。
首先构建新节点的索引映射 keyToNewIndexMap。
javascript
1 keyToNewIndexMap: { b: 0, d: 1, e: 2, a: 3, c: 4 }
步骤5:建立新旧节点的映射关系
遍历旧节点,建立新旧节点的映射关系 newIndexToOldIndexMap。
javascript
1 newIndexToOldIndexMap: [0, 1, 2, 0, 0]
步骤6:求最长递增子序列
使用 getSequence 求解最长递增子序列。
javascript
1 LIS: [0, 1, 2]
步骤7:节点移动和更新
遍历新节点,通过最长递增子序列来决定哪些节点需要移动,哪些节点可以保持不动。
javascript
1 最优移动顺序:
2 旧节点 [a, b, c, d, e]
3 新节点 [b, d, e, a, c]
4
5 步骤:
6 1. 移动 a 到新位置 3
7 2. 移动 c 到新位置 4
步骤图示意:
javascript
1 旧子节点 (c1): [a, b, c, d, e]
2 新子节点 (c2): [b, d, e, a, c]
3
4 前缀匹配结束后: i: 1
5 c1: [a, b, c, d, e]
6 c2: [b, d, e, a, c]
7
8 后缀匹配结束后: e1: 2, e2: 2
9 c1: [a, b, c, d, e]
10 c2: [b, d, e, a, c]
11
12 建立新旧节点索引映射: keyToNewIndexMap: { b: 0, d: 1, e: 2, a: 3, c: 4 }
13
14 建立新旧节点映射: newIndexToOldIndexMap: [0, 1, 2, 0, 0]
15
16 求 LIS: LIS: [0, 1, 2]
17
18 最优移动顺序: 移动 a 到新位置 3
19 移动 c 到新位置 4
通过以上步骤图,可以更清晰地理解在 Vue 3 diff 过程中如何利用最长递增子序列来优化节点的移动和更新,从而减少不必要的 DOM 操作,提高性能。
4.2. diff 与 rerender 过程详解
4.2.1. 简单 diff
- patch
在上一节中,我们通过减少 DOM 操作的次数,提升了更新性能。 但这种方式仍然存在可优化的空间。举个例子,假设新旧两组子节点 的内容如下:
javascript
const oldchildren = [
{ type: 'p' }, { type: 'div' }, { type: 'span' }
]
const newchildren = [
{ type: 'span' }, { type: 'p' }, { type: 'div' }
]
如果使用上一节介绍的算法来完成上述两组子节点的更新,则需要 6 次 DOM 操作。
-
调用 patch 函数在旧子节点 { type: 'p' } 与新子节点 { type: "span"},了之间打补丁,由于两者是不同的标签,所以patch 函数会卸载 {type: 'p'},然后再挂载 {type: 'span'},这需要执行 2 次 DOM 操作。
-
与第 1 步类似,卸载旧子节点 { type: 'div'} 了,然后再挂载新子节点 {type: 'p'} 了,这也需要执行 2 次 DOM 操作。
-
与第 1 步类似,卸载旧子节点 {type: 'span'},然后再挂载新子节点 { type:'div'} 了,同样需要执行 2 次 DOM 操作。
因此,一共进行 6 次 DOM 操作才能完成上述案例的更新。但是,观察新旧两组子节点,很容易发现,二者只是顺序不同。所以最优的处理方式是,通过 DOM 的移动来完成子节点的更新,这要比不断地执行子节点的卸载和挂载性能更好。但是,想要通过 DOM 的移动来完成更新,必须要保证一个前提:新旧两组子节点中的确存在可复用的节点。这个很好理解,如果新的子节点没有在旧的一组子节点中出现,就无法通过移动节点的方式完成更新。所以现在问题变成了:应该如何确定新的子节点是否出现在旧的一组子节点中呢?拿上面的例子来说,怎么确定新的一组子节点中第 1 个子节点 ^ type 'span' 与旧的一组子节点中第 3 个子节点相同呢?一种解决方案是,通过 vnode.type 来判断,只要 vnode.type 的值相同,我们就认为两者是相同的节点。但这种方式并不可靠,思考如下例子:
javascript
const oldChildren = [
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
{ type: 'p', children: '3' }
]
const newChildren = [
{ type: 'p', children: '3' },
{ type: 'p', children: '1' },
{ type: 'p', children: '2' }
]
观察上面两组子节点,我们发现,这个案例可以通过移动 DOM 的 方式来完成更新。但是所有节点的 vnode.type 属性值都相同,这导 致我们无法确定新旧两组子节点中节点的对应关系,也就无法得知应 该进行怎样的 DOM 移动才能完成更新。这时,我们就需要引入额外的 key 来作为 vnode 的标识,如下面的代码所示:
javascript
const oldChildren = [
{ type: 'p', children: '1', key: '1' },
{ type: 'p', children: '2', key: '2' },
{ type: 'p', children: '3', key: '3' }
]
const newChildren = [
{ type: 'p', children: '3', key: '3' },
{ type: 'p', children: '1', key: '1' },
{ type: 'p', children: '2', key: '2' }
]
key 属性就像虚拟节点的身份证号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它 们是相同的,即可以进行 DOM 的复用。下图展示了有 key 和 无 key 时新旧两组子节点 的映射情况。

接下来会进行 patch 操作,patch 理解为打补丁,这个操作是不需要移动元素的,而只需要更新元素即可,不改变顺序。
接下来找寻需要移动的元素,进行元素移动。

详述整个过程:
-
第一步:取新的一组子节点中的第一个节点 p-3,它的 key 为 3。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 2。
-
第二步:取新的一组子节点中的第二个节点 p-1,它的 key 为 1。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 0。到了这一步我们发现,索引值递增的顺序被打破了。节点p-1在旧 children 中的索引是 0,它小于节点p-3在 children 中的索引2。这说明节点p-1 在旧children 中排在节点p-3前面,但在新的children 中,它排在节点p-3 后面。因此,我们能够得出一个结论:节点p-1对应的真实 DOM 需要移动。
-
第三步:取新的一组子节点中的第三个节点 p-2,它的 key 为 2。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为 1。到了这一步我们发现,节点p-2 在旧children 中的索引1要小于节点口-3在旧children 中的索引2。这说明,节点 p-2在旧 children 中排在节点p-3前面,但在新的 children 中,它排在节点口-3后面。因此,节点口-2对应的真实 DOM 也需要移动。
- 复用

- 移动
-
第一步:取新的一组子节点中第一个节点 p-3,它的 key 为 3,尝试在旧的一组子节点中找到具有相同 key 值的可复用节点。发现能够找到,并且该节点在旧的一组子节点中的索引为 2。此时变量 lastIndex 的值为0,索引 2 不小于 0,所以节点 p-3 对应的真实 DOM 不需要移动,但需要更新变量 lastIndex 的值为 2。
-
第二步:取新的一组子节点中第二个节点 p-1,它的 key 为 1,尝试在旧的一组子节点中找到具有相同 key 值的可复用节点。发现能够找到,并且该节点在旧的一组子节点中的索引为 0。此时变量 lastIndex 的值为 2,索引 0 小于 2,所以节点 p-1 对应的真实 DOM 需要移动。到了这一步,我们发现,节点 p-1 对应的真实 DOM 需要移动,但应该移动到哪里呢?我们知道,新 children 的顺序其实就是更新后真实 DOM 节点应有的顺序。所以节点 p-1 在新 children 中的位置就代表了真实 DOM 更新后的位置。由于节点 p-1 在新 children 中排在节点 p-3 后面,所以我们应该把节点 p-1 所对应的真实 DOM 移动到节点 p-3 所对应的真实 DOM 后面。可以看到,这样操作之后,此时真实 DOM 的顺序为 p-2、p-3、p-1。

- 第三步:取新的一组子节点中第三个节点 p-2,它的 key 为 2。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点。发现能够找到,并且该节点在旧的一组子节点中的索引为 1。此时变量 lastIndex 的值为 2,索引 1 小于 2,所以节点 p-2 对应的真实 DOM 需要移动。原理与第二步相似。

- 新增

新增后结果如下:

- 删除

节点 p-2 对应的真实 DOM 仍然存在,所以需要增加额外的逻辑来删除遗留节点。思路很简单,当基本的更新结 束时,我们需要遍历旧的一组子节点,然后去新的一组子节点中寻找具有相同 key 值的节点。如果找不到,则说明应该删除该节点。
简单 Diff 算法的核心逻辑是,拿新的一组子节点中的节点去旧的一组 子节点中寻找可复用的节点。如果找到了, 则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。
4.2.2. 双端diff
双端 diff 顾名思义,对比的逻辑分别从两端收敛进行。双端 Diff 算法是一种同时对新旧两组子节点的两个端 点进行比较的算法。因此,我们需要四个索引值,分别指向新旧两组 子节点的端点。

详述一下执行步骤:
-
第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
-
第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
-
第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看看它们是否相同。由于两者的 key 值不同,因此不相同,不可复用,于是什么都不做。
-
第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4。由于它们的 key 值相同,因此可以进行 DOM 复用。

对比完以后,接下来反复如此,直到不满足 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 这一条件。



- 双端diff VS 简单diff
简单diff一次 diff,共需要进行两次 dom 移动。

而双端 diff 只需要一次,如下图所示:

- 双端 diff 的非理想情况处理
如果一轮对比发现根本就没有可以复用的内容,该怎么处理?

我们从上图可以发现,第一轮对比都没有命中可复用节点,怎么办呢?我们就只能拿新子节点中第一个节点 p-2 去旧子节点中找。

找到啦,那就移动,且将旧子节点 p-2 赋为 undefined。

以此,完成后续对比。
- 添加新元素

对比完,发现,糟糕,没有任何可复用的节点,那么接下来尝试拿出新节点中第一个节点去旧节点找。

新子节点第一个节点 p-4 在旧子节点也没有找到,情况不妙,说明这个 p-4 是新成员,那么我们就需要创建 dom,然后 newStartIdx 后移。

还有一种情况,就是比对时,尾部有可复用元素,直到新子节点前端发现无法复用。

- 删除元素
删除元素其实很简单,就是在 diff 过程完成后,如果 newEndIdx < newStartIdx 了,但是此时旧子节点的 oldStartIdx <= oldEndIdx,则需要将 oldStartIdx ~ oldEndIdx 的所有元素删除。

4.2.3. 快速diff
快速 diff 这一算法借鉴了 ivi 或者 inferno 这两个库的实现。
- 最长递增子序列
这是快速 diff 算法的核心,对应 LeetCode 这个题。
比如我现在有一个数组,[4,6,7],那么它的递增子序列有以下几个:
-
4,6
-
4,6,7
-
4,7
-
6,7
-
7
那么可以看出,最长递增子序列是 [4,6,7]。
- 预处理
Vue3 在 diff 时会预先进行优化处理,怎么做呢?我们可以看看如下示例:
javascript
const text1 = 'Hello World'
const text2 = 'Hello'
那其实,我们真正需要 diff 的只有 'World',为什么,因为字符串前后我们可以先剔除掉相同子串。
那在 Vue diff 时,也一样。

我们先将首尾相同节点 diff 并在发现相同元素时进行 patch 操作。

先定义一个索引 j,逐步递增,对比新旧子节点如果相同则进行 patch 操作,直至不相同,当不相同时,我们转变处理方法,从尾部开始进行对比,如下:

很显然,这里执行完以后,只剩下 p-4,所以这个作为新节点进行挂载,需要创建新 dom。

还存在另外一种情况,就是最终剩下旧子节点,那么旧子节点对应 dom 会被移除。如图:

总结下来就是:
-
遍历完后,如果新子节点 newEnd > j,则 newEnd~j 的子元素全部作为新元素挂载,需创建 dom。
-
遍历完后,如果旧子节点 j > oldEnd,则 j~oldEnd 的子元素全部移除,需删除 dom。
- DOM 移动

接下来构造 source 数组,这就是为我们后面计算最长递增子序列做准备的。
初始化的逻辑简化代码如下:
javascript
const count = newEnd - j + 1;
const source = new Array(count);
source.fill(-1)

然后将每一位的值设置为该节点在旧子节点中的索引,填充后,source 数组更新为:`[2,3,1,-1]`,其中 -1 表示在旧子节点中没有对应节点。

接下来为了性能优化考虑,需要额外新建一张索引表,用于标志新旧子节点的对照关系,构建为:

我们来看看,这个 source 数组 [2, 3, 1, -1] 的最长递增子序列是?答案是:[2, 3]
但是我们会发现,在 Vue 源码中并不是,Vue 源码计算出的结果是 [0, 1],为什么呢?因为我们需要得到的是索引,因为索引才是后续我们该如何移动的关键。

接下来我们重点关注需要更新的节点序列,并重新编号。

对比思路如下:
-
首先看 source[i] 是否为 -1,如果是则表示需要新增 dom,否则走下一步判断逻辑。
-
再看 seq[s] 是否等于 i,如果是,则不需要处理,否则需要移动 dom。
-
如果 seq[s] 等于 i,则 s--,直至结束。
4.2.4. Vue 3 diff 优化
-
静态标记+ 非全量 Diff:Vue 3在创建虚拟DOM树的时候,会根据DOM中的内容会不会发生变化,添加一个静态标记。之后在与上次虚拟节点进行对比的时候,就只会对比这些带有静态标记的节点。
-
使用最长递增子序列优化对比流程,可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作。
5. 手写简版 Vue
5.1. 依赖追踪系统
首先,我们需要一个依赖追踪系统,用于追踪依赖并在数据变化时通知相关副作用函数。
javascript
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect);
}
}
notify() {
this.subscribers.forEach(effect => effect.update());
}
}
5.2. Watcher 实现
Watcher 类用于管理副作用函数,并在数据变化时重新执行副作用函数。
javascript
let activeEffect = null;
class Watcher {
constructor(effect) {
this.effect = effect;
this.run();
}
run() {
activeEffect = this;
this.effect();
activeEffect = null;
}
update() {
this.run();
}
}
function effect(eff) {
new Watcher(eff);
}
5.3. 创建响应式对象
实现一个 reactive 函数,将一个普通对象转换为一个响应式对象。我们使用 Proxy 来拦截对象的 get 和 set 操作,从而实现依赖收集和通知。
javascript
const targetMap = new WeakMap();
function getDep(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
return dep;
}
function reactive(target) {
const handler = {
get(target, key, receiver) {
const dep = getDep(target, key);
dep.depend();
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
const dep = getDep(target, key);
dep.notify();
return result;
}
};
return new Proxy(target, handler);
}
5.4. 更新 DOM 视图
我们需要一个 render 函数来更新 DOM 视图。当响应式数据发生变化时,这个函数将重新渲染视图。
javascript
function render() {
document.getElementById('count-display').innerText = `count is: ${state.count}`;
}
5.5. 完整实现
现在我们将所有部分整合起来,通过 effect 函数来监控响应式数据的变化,并在数据变化时调用 render 函数更新视图。
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reactive System with Vue 3</title>
</head>
<body>
<div id="app">
<p id="count-display"></p>
<button id="increment-btn">Increment</button>
</div>
<script>
// 简单的依赖追踪系统
class Dep {
constructor() {
this.subscribers = new Set();
}
depend() {
if (activeEffect) {
this.subscribers.add(activeEffect);
}
}
notify() {
this.subscribers.forEach(effect => effect.update());
}
}
let activeEffect = null;
class Watcher {
constructor(effect) {
this.effect = effect;
this.run();
}
run() {
activeEffect = this;
this.effect();
activeEffect = null;
}
update() {
this.run();
}
}
function effect(eff) {
new Watcher(eff);
}
const targetMap = new WeakMap();
function getDep(target, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Dep();
depsMap.set(key, dep);
}
return dep;
}
function reactive(target) {
const handler = {
get(target, key, receiver) {
const dep = getDep(target, key);
dep.depend();
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
const dep = getDep(target, key);
dep.notify();
return result;
}
};
return new Proxy(target, handler);
}
// 使用实现的响应式系统
const state = reactive({
count: 0
});
// 渲染函数
function render() {
document.getElementById('count-display').innerText = `count is: ${state.count}`;
}
// 注册副作用函数
effect(() => {
render();
});
// 按钮点击事件
document.getElementById('increment-btn').addEventListener('click', () => {
state.count++;
});
</script>
</body>
</html>
通过以上步骤,我们实现了一个简单的 Vue3 响应式系统,并在响应数据变化时自动更新 DOM 视图。通过 effect 监控响应式数据,结合 render 函数实现视图更新,当按钮点击时修改 state.count,视图会自动重新渲染。
6. 补充资料
-
编译处理-vue-loader: https://github.com/vuejs/vue-loader
-
编译处理-vite-plugin-vue: https://github.com/vitejs/vite-plugin-vue
-
响应式处理(Vue2) - defineProperty: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
-
响应式处理(Vue3) - Proxy+Reflect: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy、https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
-
运行时处理: https://github.com/vuejs/core/tree/main/packages/runtime-core
-
运行时 DOM 操作: https://github.com/vuejs/core/tree/main/packages/runtime-dom
-
Vue2 diff(双端): https://github.com/vuejs/vue/blob/73486cb5f5862a44b42c2aff68b82320218cbd/src/core/vdom/patch.ts#L801
-
Vue3 diff(快速): https://github.com/vuejs/core/blob/2a29a71d8ae2aabb4b57aee782dfb482ee914121/packages/runtime-core/src/renderer.ts#L363