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. 架构设计图
1+-------------------+ 2| Vue3 Source Code | 3+----------+----------+ 4 | 5+----------+----------+ +-------------------+ 6| compiler-core | | reactivity | 7+----------+----------+ +-------------------+ 8 | | 9+----------+----------+ +----------+----------+ 10| compiler-dom | | runtime-core | 11+----------+----------+ +----------+----------+ 12 | | 13 ^ ^ 14+----------+----------+ +----------+----------+ 15| compiler-sfc | | runtime-dom | 16+---------------------+ +---------------------+
- compiler-core 和 reactivity 模块提供了核心的编译和响应式机制。
- compiler-dom 和 runtime-core 分别针对编译和运行时进行具体实现。
- compiler-sfc 和 runtime-dom 则在此基础上进行扩展,适应浏览器和单文件组件的需求。
在 Vue 3源码中,除了前面提到的几个核心模块之外,还有一些其他重要的文件夹和文件,它们分别负责不同的功能。
1.7.2. 源码项目文件树
1vue-next 2├── packages 3│ ├── compiler-core 4│ ├── compiler-dom 5│ ├── compiler-sfc 6│ ├── reactivity 7│ ├── runtime-core 8│ ├── runtime-dom 9│ └── shared 10├── scripts 11├── test-dts 12├── typings 13├── .editorconfig 14├── .eslintignore 15├── .eslintrc.js 16├── .gitignore 17├── .npmignore 18├── .prettierrc 19├── .yarnrc 20├── LICENSE 21├── package.json 22├── README.md 23├── tsconfig.json 24└── yarn.lock
1.7.3. 文件夹和文件详细说明
1. packages
packages 文件夹包含了 Vue 3 的各个模块,前面已经详细介绍了 compiler-core、compiler-dom、compiler-sfc、reactivity、runtime-core 和 runtime-dom 这六个核心模块。除此之外,还有以下两个重要模块:
- shared:包含一些共享的工具函数和类型定义,这些工具函数和类型在多个模块中都会用到。它提供了基础的功能支持和类型约束。
- vue:这个文件夹是整个 Vue3 框架的入口,它将前面提到的各个模块组合起来,形成完整的 Vue 框架。它还包含一些全局 API 的定义和实现,例如 createApp、h 函数等。
2. scripts
scripts 文件夹包含了各种脚本文件,用于构建、打包和发布项目。这些脚本主要用于自动化处理任务,如测试、构建发布等。
3. test-dts
test-dts 文件夹用于测试 TypeScript 类型定义,确保类型定义的准确性和完整性。Vue 3 是用 TypeScript 编写的,所以类型定义和类型检查是非常重要的一部分。
4. typings
typings 文件夹包含项目中使用的一些自定义 TypeScript 类型定义。这些定义补充了 TypeScript 本身的类型系统,使得项目类型更为精确。
5. 根目录文件
- .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. 详细文件树
1vue-next 2├── packages 3│ ├── compiler-core 4│ │ ├── src 5│ │ ├── __tests__ 6│ │ ├── package.json 7│ │ └── README.md 8│ ├── compiler-dom 9│ │ ├── src 10│ │ ├── __tests__ 11│ │ ├── package.json 12│ │ └── README.md 13│ ├── compiler-sfc 14│ │ ├── src 15│ │ ├── __tests__ 16│ │ ├── package.json 17│ │ └── README.md 18│ ├── reactivity 19│ │ ├── src 20│ │ ├── __tests__ 21│ │ ├── package.json 22│ │ └── README.md 23│ ├── runtime-core 24│ │ ├── src 25│ │ ├── __tests__ 26│ │ ├── package.json 27│ │ └── README.md 28│ ├── runtime-dom 29│ │ ├── src 30│ │ ├── __tests__ 31│ │ ├── package.json 32│ │ └── README.md 33│ ├── shared 34│ │ ├── src 35│ │ ├── __tests__ 36│ │ ├── package.json 37│ │ └── README.md 38│ └── vue 39│ ├── src 40│ ├── __tests__ 41│ ├── package.json 42│ └── README.md 43├── scripts 44│ ├── build.js 45│ ├── release.sh 46│ └── test.js 47├── test-dts 48│ ├── component-type-checks.ts 49│ ├── ref-type-checks.ts 50│ └── vue-type-checks.ts 51├── typings 52│ ├── vue.d.ts 53│ └── compiler-sfc.d.ts 54├── .editorconfig 55├── .eslintignore 56├── .eslintrc.js 57├── .gitignore 58├── .npmignore 59├── .prettierrc 60├── .yarnrc 61├── LICENSE 62├── package.json 63├── README.md 64├── tsconfig.json 65└── yarn.lock
2. 编译过程
Vue 3 编译过程涉及多个模块的协同工作,主要包括 compiler-core、compiler-dom 和 compiler-sfc。以下将详细说明这些模块在整体编译过程中的作用,并以 vue-loader 和 vite-plugin-vue 为例说明编译工具的应用。
2.1. 编译过程简述
1. compiler-sfc
compiler-sfc 负责处理单文件组件。它的主要职责包括:
- 解析 SFC:将 .vue 文件解析为模板、脚本和样式三部分。
- 处理块:使用 compiler-core 和 compiler-dom 对模板部分进行编译。
- 生成代码:最终生成包含渲染函数的组件对象。
具体过程如下:
- 解析 SFC 文件:使用 parse 函数将 .vue 文件解析成 SFCDscriptor 对象。这个对象包含了 template、script 和 styles 等部分。
- 处理模板:如果存在模板部分,将其传递给 compiler-core 和 compiler-dom 进行编译。
- 生成代码:将处理后的模板代码、脚本和样式组合成一个完整的组件对象,最终生成 JavaScript 代码。
2. compiler-core
compiler-core 是 Vue 3 的编译核心模块,主要负责将模板转换为渲染函数。其主要步骤包括:
- 解析(Parser):使用 baseParse 函数将模板字符串解析成抽象语法树(AST)。
- 转换(Transform):使用一系列转换插件(transforms)对 AST 进行转换,例如处理指令、插值和事件等。
- 代码生成(Codegen):将转换后的 AST 转换成 JavaScript 渲染函数。使用 generate 函数生成最终的渲染代码。
3. compiler-dom
compiler-dom 是 compiler-core 的一个扩展,针对浏览器环境进行了优化。其主要步骤包括:
- DOM 特定的解析与转换:处理与浏览器 DOM 相关的特性,例如 HTML 标签、属性和事件。compiler-dom 提供了一些特定于 DOM 的转换插件,这些插件在 compiler-core 的基础上进行扩展。
- 平台特定优化:通过优化特定于浏览器的代码生成,来提升性能。
2.2. 详细编译过程
1. 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 部分。
1import { parse } from '@vue/compiler-sfc'; 2 3const source = `<template><div>{{ message }}</div></template> 4<script> 5export default { 6 data() { 7 return { 8 message: 'Hello Vue3' 9 } 10 } 11} 12</script>`; 13 14const descriptor = parse(source); 15console.log(descriptor);
处理模板:
compileTemplate 函数编译模板部分,生成渲染函数。
1import { compileTemplate } from '@vue/compiler-sfc'; 2 3const template = descriptor.descriptor.template; 4const result = compileTemplate({ 5 source: template.content, 6 filename: 'example.vue', 7 id: 'example' 8}); 9console.log(result);
2. 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)。
1import { baseParse } from '@vue/compiler-core'; 2 3const ast = baseParse('<div>{{ message }}</div>'); 4console.log(ast);
转换 AST:
transform 函数使用一系列转换插件对 AST 进行转换。
1import { transform } from '@vue/compiler-core'; 2import { transformExpression } from '@vue/compiler-core'; 3import { transformElement } from '@vue/compiler-core'; 4 5transform(ast, { 6 nodeTransforms: [transformExpression, transformElement] 7}); 8console.log(ast);
代码生成:
generate 函数将转换后的 AST 转换成 JavaScript 渲染函数。
1import { generate } from '@vue/compiler-core'; 2 3const { code } = generate(ast); 4console.log(code);
3. compiler-dom
compiler-dom 是 compiler-core 的一个扩展,处理与浏览器 DOM 相关的特性。
主要文件:
- packages/compiler-dom/src/index.ts
DOM 特定的解析与转换:
compiler-dom 提供了一些特定于 DOM 的转换插件,如 transformStyle、transformVHtml 和 transformModel。
1import { baseCompile } from '@vue/compiler-dom'; 2 3const { code } = baseCompile('<div v-html="message"></div>', { 4 nodeTransforms: [], 5 directiveTransforms: { 6 html: transformVHtml 7 } 8}); 9 10console.log(code);
4. 整体流程示意图
1.vue 文件 2| 3v 4解析 (compiler-sfc) 5| 6v 7模板 -> 抽象语法树 (AST) (compiler-core) 8| 9v 10AST 转换 (compiler-core & compiler-dom) 11| 12v 13渲染函数 (compiler-core) 14| 15v 16生成 JavaScript 模块 (vue-loader / vite-plugin-vue) 17| 18v 19Webpack/Vite 打包 20| 21v 22运行时 (runtime-core & runtime-dom)
执行流程如下:

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

3. 移动
- 第一步:取新的一组子节点中第一个节点 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 需要移动。原理与第二步相似。

4. 新增

新增后结果如下:

5. 删除

节点 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 这一条件。



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

接下来构造 source 数组,这就是为我们后面计算最长递增子序列做准备的。
初始化的逻辑简化代码如下:
1const count = newEnd - j + 1; 2const source = new Array(count); 3source.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. 依赖追踪系统
首先,我们需要一个依赖追踪系统,用于追踪依赖并在数据变化时通知相关副作用函数。
1class Dep { 2 constructor() { 3 this.subscribers = new Set(); 4 } 5 6 depend() { 7 if (activeEffect) { 8 this.subscribers.add(activeEffect); 9 } 10 } 11 12 notify() { 13 this.subscribers.forEach(effect => effect.update()); 14 } 15}
5.2. Watcher 实现
Watcher 类用于管理副作用函数,并在数据变化时重新执行副作用函数。
1let activeEffect = null; 2 3class Watcher { 4 constructor(effect) { 5 this.effect = effect; 6 this.run(); 7 } 8 9 run() { 10 activeEffect = this; 11 this.effect(); 12 activeEffect = null; 13 } 14 15 update() { 16 this.run(); 17 } 18} 19 20function effect(eff) { 21 new Watcher(eff); 22}
5.3. 创建响应式对象
实现一个 reactive 函数,将一个普通对象转换为一个响应式对象。我们使用 Proxy 来拦截对象的 get 和 set 操作,从而实现依赖收集和通知。
1const targetMap = new WeakMap(); 2 3function getDep(target, key) { 4 let depsMap = targetMap.get(target); 5 if (!depsMap) { 6 depsMap = new Map(); 7 targetMap.set(target, depsMap); 8 } 9 10 let dep = depsMap.get(key); 11 if (!dep) { 12 dep = new Dep(); 13 depsMap.set(key, dep); 14 } 15 16 return dep; 17} 18 19function reactive(target) { 20 const handler = { 21 get(target, key, receiver) { 22 const dep = getDep(target, key); 23 dep.depend(); 24 return Reflect.get(target, key, receiver); 25 }, 26 set(target, key, value, receiver) { 27 const result = Reflect.set(target, key, value, receiver); 28 const dep = getDep(target, key); 29 dep.notify(); 30 return result; 31 } 32 }; 33 return new Proxy(target, handler); 34}
5.4. 更新 DOM 视图
我们需要一个 render 函数来更新 DOM 视图。当响应式数据发生变化时,这个函数将重新渲染视图。
1function render() { 2 document.getElementById('count-display').innerText = `count is: ${state.count}`; 3}
5.5. 完整实现
现在我们将所有部分整合起来,通过 effect 函数来监控响应式数据的变化,并在数据变化时调用 render 函数更新视图。
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Reactive System with Vue 3</title> 7</head> 8<body> 9 <div id="app"> 10 <p id="count-display"></p> 11 <button id="increment-btn">Increment</button> 12 </div> 13 14 <script> 15 // 简单的依赖追踪系统 16 class Dep { 17 constructor() { 18 this.subscribers = new Set(); 19 } 20 21 depend() { 22 if (activeEffect) { 23 this.subscribers.add(activeEffect); 24 } 25 } 26 27 notify() { 28 this.subscribers.forEach(effect => effect.update()); 29 } 30 } 31 32 let activeEffect = null; 33 34 class Watcher { 35 constructor(effect) { 36 this.effect = effect; 37 this.run(); 38 } 39 40 run() { 41 activeEffect = this; 42 this.effect(); 43 activeEffect = null; 44 } 45 46 update() { 47 this.run(); 48 } 49 } 50 51 function effect(eff) { 52 new Watcher(eff); 53 } 54 55 const targetMap = new WeakMap(); 56 57 function getDep(target, key) { 58 let depsMap = targetMap.get(target); 59 if (!depsMap) { 60 depsMap = new Map(); 61 targetMap.set(target, depsMap); 62 } 63 64 let dep = depsMap.get(key); 65 if (!dep) { 66 dep = new Dep(); 67 depsMap.set(key, dep); 68 } 69 70 return dep; 71 } 72 73 function reactive(target) { 74 const handler = { 75 get(target, key, receiver) { 76 const dep = getDep(target, key); 77 dep.depend(); 78 return Reflect.get(target, key, receiver); 79 }, 80 set(target, key, value, receiver) { 81 const result = Reflect.set(target, key, value, receiver); 82 const dep = getDep(target, key); 83 dep.notify(); 84 return result; 85 } 86 }; 87 return new Proxy(target, handler); 88 } 89 90 // 使用实现的响应式系统 91 const state = reactive({ 92 count: 0 93 }); 94 95 // 渲染函数 96 function render() { 97 document.getElementById('count-display').innerText = `count is: ${state.count}`; 98 } 99 100 // 注册副作用函数 101 effect(() => { 102 render(); 103 }); 104 105 // 按钮点击事件 106 document.getElementById('increment-btn').addEventListener('click', () => { 107 state.count++; 108 }); 109 </script> 110</body> 111</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%5FObjects/Object/defineProperty
- 响应式处理(Vue3) - Proxy+Reflect: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Proxy、https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/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
《Vue 3.0 源码解读》 是转载文章,点击查看原文。
