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

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

对比思路如下:

  • 首先看 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. 补充资料

相关推荐
IT教程资源D3 天前
[N_144]基于微信小程序在线订餐系统
mysql·vue·uniapp·前后端分离·订餐小程序·springboot订餐
是梦终空5 天前
vue下载依赖报错npm ERR node-sass@4.14.1 postinstall: `node scripts/build.js`的解决方法
javascript·npm·vue·node-sass·vue依赖
陈陈小白5 天前
npm run dev报错Error: listen EADDRINUSE: address already in use :::8090
前端·npm·node.js·vue
韩立学长5 天前
【开题答辩实录分享】以《证劵数据可视化分析项目设计与实现》为例进行答辩实录分享
python·信息可视化·vue
二当家的素材网5 天前
【无标题】
vue·uniapp
_OP_CHEN5 天前
从零开始的Qt开发指南:(三)信号与槽的概念与使用
开发语言·c++·qt·前端开发·qt creator·信号与槽·gui开发
合作小小程序员小小店5 天前
web网页开发,在线%考试管理%系统,基于Idea,vscode,html,css,vue,java,maven,springboot,mysql
java·前端·系统架构·vue·intellij-idea·springboot
.NET快速开发框架6 天前
国思RDIF低代码快速开发框架 v6.2.2版本发布
低代码·vue·rdif·国思rdif开发框架
IT教程资源D6 天前
[N_148]基于微信小程序网上书城系统
mysql·vue·uniapp·前后端分离·网上书城小程序·springboot书城