Vue 3.0 源码解读

作者:艾光远日期:2025/11/18

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
16LIS:             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 3.0 源码解读》 是转载文章,点击查看原文


相关推荐


用 Rust 构建 Git 提交历史可视化工具
掘金者阿豪2025/11/17

在软件开发中,版本控制系统的历史记录往往承载着项目的演进脉络。然而,当项目规模扩大、分支增多时,纯文本的 git log 输出很难直观地展现提交之间的复杂关系。今天,我想分享一个用 Rust 构建的轻量级工具 —— git-graph-rs,它能把 Git 仓库的提交历史转换为可视化的图结构,为代码审查、项目复盘和工程决策提供直观的支持。 @TOC 为什么需要可视化? 在参与大型项目时,我经常会遇到这样的场景: 需要快速了解某个功能分支的合并路径 在代码审查时想知道某个提交在整体历史中的位置


Python 的内置函数 oct
IMPYLH2025/11/16

Python 内建函数列表 > Python 的内置函数 oct Python 的内置函数 oct() 用于将一个整数转换为八进制(以 8 为基数)字符串表示形式。该函数返回的字符串以 0o 为前缀,表示这是一个八进制数。 语法 oct(x) 参数 x:必须是一个整数(可以是十进制、二进制、十六进制或其他形式的整数)。如果 x 不是整数,则需要先实现 __index__() 方法返回一个整数。 返回值 返回一个以 0o 为前缀的八进制字符串。 示例 十进制转八进制 prin


【大模型】重磅升级!文心 ERNIE-5.0 新一代原生全模态大模型,这你都不认可它吗?!
南方者2025/11/14

🧩 前言速读 11 月 13 日,2025百度世界大会上,新一代「原生全模态」大模型文心 5.0 正式亮相,2.4 万亿参数量,采用原生全模态统一建模技术,具备全模态理解与生成能力,支持「文本、图像、音频、视频」等多种信息的输入与输出,将国内大模型竞争力推向全球顶尖水准。 大会上强调:“智能本身是最大的应用,技术迭代速度是唯一的护城河”,而文心 5.0 正是这一理念的最新实践 —— 它不仅是参数规模的跃升,更标志着 AI 从 “单模态处理” 迈入 “原生多模态融合” 的新阶段。 接下来,让


OpenCVSharp:ArUco 标记检测与透视变换
mingupup2025/11/13

前言 对于.NET开发者而言,入门OpenCV的一个很舒适的方式就是先去使用OpenCVSharp,它是 OpenCV 的 .NET 封装,而且作者还开源了一个示例库,可以通过示例库进行入门学习。 OpenCVSharp仓库地址:github.com/shimat/open… opencvsharp_samples仓库地址:github.com/shimat/open… 作者提供了几十个可以直接运行的示例代码,一开始可以先大概运行一下这些示例,看一下用这个库可以实现哪些功能。 入门第一步就是


🚀 MateChat发布V1.10.0版本,支持附件上传及体验问题修复,欢迎体验~
2025/11/12

✨ 本期亮点 最新发布的 MateChat V1.10.0 版本新增文件列表组件和重新生成功能等特性,希望这个版本为你带来全新的体验! 🎯 核心功能升级(新特性) 🔄 新增文件列表组件 1、基本用法 McFileList 组件的核心功能是接收一个文件对象数组,并将它们渲染为信息卡片。通过 fileItems 属性传入数据,并可使用 context 属性控制其在不同场景下的外观,详情点击文件列表组件Demo 2、不同上下文与状态 McFileList 提供了两种上下文模式和多种文件状态,以适


Service Worker 深度解析:让你的 Web 应用离线也能飞
前端嘿起2025/11/10

在现代 Web 开发中,用户体验已经成为了衡量一个应用成功与否的重要标准。用户不仅希望网站加载速度快,还希望即使在网络不稳定或完全断网的情况下也能正常使用应用。这就引出了我们今天的主角——Service Worker。 前言 Service Worker 是一种在浏览器后台运行的脚本,它独立于网页主线程,可以拦截网络请求、缓存资源,甚至在离线状态下也能提供完整的用户体验。它是实现 PWA(渐进式 Web 应用)的核心技术之一,为 Web 应用带来了原生应用般的离线能力。 在本文中,我们将从基础


Thread.sleep 与 Task.sleep 终极对决:Swift 并发世界的 “魔法休眠术” 揭秘
大熊猫侯佩2025/11/8

📜 引子:霍格沃茨的 “并发魔咒” 危机 在霍格沃茨城堡顶层的 “魔法程序与咒语实验室” 里,金色的阳光透过彩绘玻璃洒在悬浮的魔法屏幕上。哈利・波特正对着一段闪烁着蓝光的 Swift 代码抓耳挠腮,罗恩在一旁急得直戳魔杖 —— 他们负责的 “魁地奇赛事实时计分器” 又卡住了。 赫敏抱着厚厚的《Swift 并发魔法指南》凑过来,眉头紧锁:“肯定是上次加的‘休眠咒语’出了问题!我早就说过 Thread.sleep 像‘摄魂怪的拥抱’,会吸干线程的活力,你们偏不信!” 这时,实验室的门 “吱呀”


Godot游戏开发——C# (一)
云缘若仙2025/11/6

1. 素材管理 核心内容:明确游戏开发所需基础素材类型,为场景与节点提供资源支撑,具体包括: AssetBundle:资源打包容器,用于统一管理与加载资源; Audio 音频素材:提供游戏音效、背景音乐等音频资源; Sprites 精灵图片素材:提供角色、道具、场景元素等可视化图片资源。 2. 场景树与核心节点 节点类型 功能描述 Root Node(根节点) 场景树顶层节点,所有子节点均嵌套于其下,构成场景层级框架的基础。


高并发电商架构设计与落地:从微服务拆分到全链路优化
kennylee262025/10/31

一、交易核心 - 高并发订单的生成与落地 1.1 引言:为什么“收单”是系统的生命线 在电商体系中,交易是核心,而订单是起点。一个高效、稳定的收单系统,决定了平台的承载能力与用户体验。在高并发场景(如秒杀、大促)下,系统的挑战早已超越传统的“增删改查”,转向对性能极限、数据一致性与弹性扩展的全面考验。本章将解析如何通过微服务拆分与架构优化,构建一个能从容应对瞬时流量洪峰的订单处理系统。 1.2 架构总览:微服务拆分与职责边界 微服务架构的核心价值在于解耦、弹性伸缩与容错。在订单处理流程中


SpringBoot 时间轮实现延时任务
风象南2025/10/30

传统方案的困境 在日常开发中,我们经常需要处理各种定时任务:用户注册后的欢迎邮件、订单超时自动取消、缓存定期刷新等。传统的定时器方案在面对大规模定时任务时往往力不从心: 性能瓶颈日益凸显 ScheduledExecutor在处理上千个任务时性能急剧下降 Timer类不仅线程不安全,还存在单点故障风险 每次调度都要在堆中查找最小元素,时间复杂度O(log n) 频繁的GC压力导致系统吞吐量受限 业务需求日益复杂 消息重试需要指数退避策略 分布式系统需要精确的延迟调度 会话管理需要动态添加删除

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0