Vue 3.0 源码解读

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. 文件夹和文件详细说明

  1. packages

packages 文件夹包含了 Vue 3 的各个模块,前面已经详细介绍了 compiler-core、compiler-dom、compiler-sfc、reactivity、runtime-core 和 runtime-dom 这六个核心模块。除此之外,还有以下两个重要模块:

  • shared:包含一些共享的工具函数和类型定义,这些工具函数和类型在多个模块中都会用到。它提供了基础的功能支持和类型约束。

  • vue:这个文件夹是整个 Vue3 框架的入口,它将前面提到的各个模块组合起来,形成完整的 Vue 框架。它还包含一些全局 API 的定义和实现,例如 createApp、h 函数等。

  1. scripts

scripts 文件夹包含了各种脚本文件,用于构建、打包和发布项目。这些脚本主要用于自动化处理任务,如测试、构建发布等。

  1. test-dts

test-dts 文件夹用于测试 TypeScript 类型定义,确保类型定义的准确性和完整性。Vue 3 是用 TypeScript 编写的,所以类型定义和类型检查是非常重要的一部分。

  1. typings

typings 文件夹包含项目中使用的一些自定义 TypeScript 类型定义。这些定义补充了 TypeScript 本身的类型系统,使得项目类型更为精确。

  1. 根目录文件
  • .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. 编译过程简述

  1. compiler-sfc

compiler-sfc 负责处理单文件组件。它的主要职责包括:

  • 解析 SFC:将 .vue 文件解析为模板、脚本和样式三部分。

  • 处理块:使用 compiler-core 和 compiler-dom 对模板部分进行编译。

  • 生成代码:最终生成包含渲染函数的组件对象。

具体过程如下:

  • 解析 SFC 文件:使用 parse 函数将 .vue 文件解析成 SFCDscriptor 对象。这个对象包含了 template、script 和 styles 等部分。

  • 处理模板:如果存在模板部分,将其传递给 compiler-core 和 compiler-dom 进行编译。

  • 生成代码:将处理后的模板代码、脚本和样式组合成一个完整的组件对象,最终生成 JavaScript 代码。

  1. compiler-core

compiler-core 是 Vue 3 的编译核心模块,主要负责将模板转换为渲染函数。其主要步骤包括:

  • 解析(Parser):使用 baseParse 函数将模板字符串解析成抽象语法树(AST)。

  • 转换(Transform):使用一系列转换插件(transforms)对 AST 进行转换,例如处理指令、插值和事件等。

  • 代码生成(Codegen):将转换后的 AST 转换成 JavaScript 渲染函数。使用 generate 函数生成最终的渲染代码。

  1. 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 部分。

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);
  1. 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);
  1. 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);
  1. 整体流程示意图
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)

执行流程如下:

  1. 总结
  • 解析 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 进行打包。

javascript 复制代码
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      // 其他规则
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}
  1. 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());
  }
}
  1. 整体流程
  • 创建响应式对象:通过 reactive 函数将普通对象转换为响应式对象。

  • 依赖收集:在读取对象属性时,收集依赖于该属性的副作用函数。

  • 触发更新:在设置对象属性时,通知所有依赖于该属性的副作用函数执行。

  1. 响应式处理

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;
}
  1. 示例代码

通过以下示例代码,展示了 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

  1. 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 也需要移动。

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

新增后结果如下:

  1. 删除

节点 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 只需要一次,如下图所示:

  1. 双端 diff 的非理想情况处理

如果一轮对比发现根本就没有可以复用的内容,该怎么处理?

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

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

以此,完成后续对比。

  1. 添加新元素

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

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

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

  1. 删除元素

删除元素其实很简单,就是在 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

  1. 预处理

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。

  1. 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,为什么呢?因为我们需要得到的是索引,因为索引才是后续我们该如何移动的关键。

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

对比思路如下:

  • 首先看 sourcei 是否为 -1,如果是则表示需要新增 dom,否则走下一步判断逻辑。

  • 再看 seqs 是否等于 i,如果是,则不需要处理,否则需要移动 dom。

  • 如果 seqs 等于 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. 补充资料

相关推荐
医疗信息化王工2 小时前
医院自律端系统——预警处置模块全栈实战(ASP.NET Core + Vue3 + Quartz 定时调度)
mysql·postgresql·vue·asp.net core·quartz
大大杰哥4 小时前
Vue2学习(1)--了解基本方法与概念
javascript·学习·vue
Agatha方艺璇1 天前
前端开发技术复习笔记
vue·bootstrap·css3·html5·web
小葛要努力1 天前
创建vue2项目
程序人生·vue
七仔啊1 天前
基于海康门禁的人员计数系统
vue
步十人2 天前
【Vue3】前置知识简单概述(包括ES6核心语法,模块化ESM以及npm基础)
arcgis·npm·vue·es6
有梦想的程序星空3 天前
【环境配置】Vue3项目离线化本地部署echarts全攻略
前端·javascript·vue·echarts
向日的葵0063 天前
vue路由(二)
前端·javascript·vue.js·vue
MageGojo4 天前
随机文案模块怎么做?从接口封装到前端展示的完整实现思路
javascript·前端开发·api接口·后端开发·随机文案
aiguangyuan4 天前
微信小程序服务商
微信小程序·前端开发