手写Vue3源码

Vue3核心源码

本文包含如下内容

  • vue3响应式原理实现
  • 依赖收集与更新
  • 手动实现ref、reactive
  • 手写computed计算属性
  • 组件创建流程实现
  • 组件更新流程实现,手写diff过程
  • 最长递增子序列
  • vue3中模板编译过程做了哪些优化
  • 手写组件生命周期

代码地址:https://gitee.com/szxio/handwriting-vue

Monorepo介绍

Monorepo 是管理项目代码的一种方式,只在一个仓库中管理多个模块/包

  • 一个仓库可以维护多个模块,不用到处找仓库
  • 方便版本管理和依赖管理,模块之间的引用和调用都非常方便

缺点:仓库的体积变大,打包和安装依赖都会变慢

例如Vue3源码、element-plus 等都已经采用这种方式来管理项目

了解更多可以查看这个文档:https://www.modb.pro/db/626876

Vue3项目结构

                            +---------------------+
                            |                     |
                            |  @vue/compiler-sfc  |
                            |                     |
                            +-----+--------+------+
                                  |        |
                                  v        v
               +---------------------+    +----------------------+
               |                     |    |                      |
     +-------->|  @vue/compiler-dom  +--->|  @vue/compiler-core  |
     |         |                     |    |                      |
+----+----+    +---------------------+    +----------------------+
|         |
|   vue   |
|         |
+----+----+   +---------------------+    +----------------------+    +-------------------+
    |         |                     |    |                      |    |                   |
    +-------->|  @vue/runtime-dom   +--->|  @vue/runtime-core   +--->|  @vue/reactivity  |
              |                     |    |                      |    |                   |
              +---------------------+    +----------------------+    +-------------------+
  • reactivity: 响应式系统
  • runtime-core:与平台无关的运行时核心 (可以创建针对特定平台的运行时 - 自定义渲染器)
  • runtime-dom: 针对浏览器的运行时。包括DOM API,属性,事件处理等
  • runtime-test:用于测试
  • server-renderer:用于服务器端渲染
  • compiler-core:与平台无关的编译器核心
  • compiler-dom: 针对浏览器的编译模块
  • compiler-ssr: 针对服务端渲染的编译模块
  • compiler-sfc: 针对单文件解析
  • size-check:用来测试代码体积
  • template-explorer:用于调试编译器输出的开发工具
  • shared:多个包之间共享的内容
  • vue:完整版本,包括运行时和编译器

Vue3构建流程搭建

vue3采用 monorepo 的方式,目前只有 yarn 支持,所以我们需要使用 yarn 来构建项目

初始化

找一个空文件夹执行命令

yarn init -y

然后修改生成的 package.json 文件

{
  "private":"true",
  "workspaces":[
    "packages/*"
  ],
  "name": "zf-vue3",
  "version": "1.0.0",
  "main": "index.ts",
  "license": "MIT",
  "devDependencies": {
    "@rollup/plugin-json": "^4.1.0",
    "@rollup/plugin-node-resolve": "^13.0.0",
    "execa": "^5.1.1",
    "rollup": "^2.52.3",
    "rollup-plugin-typescript2": "^0.30.0",
    "typescript": "^4.3.4"
  }
}
  • private 表示这是一个私有的

  • workspaces 声明工作区间,将来我们的包都在 packages 这个文件夹下面

  • 修改了一下 name

然后执行安装命令

yarn install
依赖
typescript支持typescript
rollup打包工具
rollup-plugin-typescript2rollup 和 ts的 桥梁
@rollup/plugin-node-resolve解析node第三方模块
@rollup/plugin-json支持引入json
execa开启子进程方便执行命令

声明子文件

reactivity

新建 packages\reactivity\src\index.ts

const Reactivity = {

}
export {
    Reactivity
}

然后在 packages\reactivity 位置执行 yarn init -y 初始化 package.json 文件

接着修改 package.json 文件

packages/reactivity/package.json

{
  "name": "@vue/reactivity", // 设置包的名称
  "version": "1.0.0",
  "main": "index.ts", // 在node环境中使用时会找 main 属性对应的地址
  "module": "dist/reactivity.esm-bundler.js", // es6模式下 import @vue/reactivity --> 使用的是这个地址
  "buildOptions":{ // 自定义打包配置
    "name": "VueReactivity", // 全局名称配置,通过 script 标签引入时,全局就会有 VueReactivity 这个名字
    "formats": ["esm-bundler","cjs","global"] // 打包模式,支持三种模式,node、es6、全局模块
  },
  "license": "MIT"
}
shared

新建 packages\shared\src\index.ts

const Shared = {

}
export {
    Shared
}

然后在 packages\shared 位置执行 yarn init -y 初始化 package.json 文件

接着修改 package.json 文件

packages/shared/package.json

{
  "name": "@vue/shared",
  "version": "1.0.0",
  "main": "index.ts",
  "module": "dist/shared.esm-bundler.js",
  "buildOptions":{
    "name": "VueShared",
    "formats": ["esm-bundler","cjs"]
  },
  "license": "MIT"
}

编译脚本、rollup、ts配置

package.json 添加脚本配置

"scripts": {
    "dev":"node scripts/dev.js",
    "build":"node scripts/build.js"
},

scripts/dev.js

这个文件只做某个包的打包,给 rollup 传入TARGET环境变量,生成 rollup 配置

const execa = require('execa');
const target = "reactivity";

build(target)

async function build(target) {
    // 执行rollup命令,并传入参数,其中TARGET:${target}表示目标环境
    // stdio: 'inherit' 将子进程的日志输出到主进程
    await execa('rollup', ['-cw', `--environment`, `TARGET:${target}`], { stdio: 'inherit' });
}

scripts/build.js

这个文件用来打包 packages 文件夹下所有的包

const fs = require('fs');
const execa = require('execa'); // 开启子进程打包

// 过滤packages目录下的文件,只要保留文件夹
const targets = fs.readdirSync("packages").filter(f=>{
    // fs.statSync(`packages/reactivity`).isDirectory() 如果是文件则返回true
    if(!fs.statSync(`packages/${f}`).isDirectory()){
        return false
    }else{
        return true
    }
})

// 对不同的包进行依次打包
async function build(target){
    await execa("rollup",["-c",`--environment`,`TARGET:${target}`],{
        stdio: "inherit"
    })
}

// 遍历所有的包,调用build方法
function runParallel(targets,buildFn){
    const result = []
    targets.forEach(item=>{
        result.push(buildFn(item))
    })
    return Promise.all(result)
}

runParallel(targets,build).then(()=>{
    console.log("打包完成")
})

rollup.config.js

import path from "path";
import json from "@rollup/plugin-json";
import resolvePlugin from "@rollup/plugin-node-resolve";
import ts from "rollup-plugin-typescript2";

// 找到packages
const packagesDir = path.resolve(__dirname, "packages");

// 根据环境变量中的TARGET,找到模块中对应的package.json
const packageDir = path.resolve(packagesDir, process.env.TARGET);

// 取到每个模块下面的package.json
const resolve = (p) => path.resolve(packageDir, p);
const pkg = require(resolve("package.json"));

// 对打包类型做一个映射表,根据每个包的package.json中配置formats来格式化需要打包的内容
const outputConfig = {
  "esm-bundler": {
    file: resolve(`dist/${process.env.TARGET}.esm-bundler.js`),
    format: "es",
  },
  cjs: {
    file: resolve(`dist/${process.env.TARGET}.cjs.js`),
    format: "cjs",
  },
  global: {
    file: resolve(`dist/${process.env.TARGET}.global.js`),
    format: "iife",
  },
};

const options = pkg.buildOptions;

// 生成rollup配置
function createConfig(format, output) {
  // 声明全局名称
  output.name = options.name;
  // 生成sourcemap
  output.sourcemap = true;

  return {
    input: resolve(`src/index.ts`),
    output,
    plugins: [
        json(), 
        ts({
            tsconfig:path.resolve(__dirname, "tsconfig.json"),
        }),
        resolvePlugin(),
    ],
  };
}

// 遍历formats生成不同的打包文件
export default options.formats.map((format) => createConfig(format, outputConfig[format]));

tsconfig.json

在根目录执行 npx tsc --init 先初始化一个 tsconfig.json 文件,然后添加如下内容

{
  "compilerOptions": {
    "target": "ESNEXT",
    "module": "ESNEXT",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@vue/*": ["packages/*/src"]
    }
  }
}

至此我们就可以来尝试打包一下,输入命令

yarn build

image-20240108172437702

对比打包后的文件内容可以发现两个包下面生成的打包文件是不一样的

image-20240108172539256

包之间的互相引用

当我们执行 yarn install 后会自动的再 node_modules 中生成软连接

image-20240108172840053

后面的箭头表示这是一个软连接。然后我们在代码里使用如下方式引入时

import { Shared } from "@vue/shared"

const Reactivity = {

}
export {
    Reactivity
}

@vue/shared 指向的就是这个包所在的真实文件地址

reactive Api实现

四个核心的API

import { reactive,shallowReactive,readonly,shallowReadonly } from "vue"
  • reactive:深层次的响应对象
  • shallowReactive:浅层响应对象,只会吧对象的第一层变成响应式的
  • readonly:对象是只读的
  • shallowReadonly:浅层对象只读

开启sourceMap

tsconfig.json 文件中的 sourceMap 打开并且设置成true,方便我们调试源码

{
  "compilerOptions": {
    "target": "ESNEXT",
    "module": "ESNEXT",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "baseUrl": ".",
    "paths": {
      "@vue/*": ["packages/*/src"]
    },
    "sourceMap": true
  }
}

目录结构

├── example
│   └── 1.reactivity-api.html  // 测试文件
├── package.json
├── packages
│   ├── reactivity
│   │   ├── package.json
│   │   └── src
│   │       ├── baseHandlers.ts
│   │       ├── index.ts  // reactive核心方法文件,只做导出操作
│   │       └── reactive.ts
│   └── shared
│       ├── package.json
│       └── src
│           └── index.ts // 公共功能
├── rollup.config.js
├── scripts
│   ├── build.js
│   └── dev.js
└── tsconfig.json

shared/src/index.ts

这个文件专门放置一些公共的方法

// 判断一个数据是否是一个对象
export function isObject (value){
    return typeof value === 'object' && value !== null;
}

// 合并两个对象
export function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
    return Object.assign(obj1, obj2);
}

reactivity/src/index.ts

// 导出四个核心方法
export {
  reactive,
  shallowReactive,
  readonly,
  shallowReadonly,
} from "./reactive";

reactivity/src/reactive.ts

import { isObject } from "@vue/shared";
import {
  mutableHandlers,
  readonlyHandlers,
  shallowReactiveHandlers,
  shallowReadonlyHandlers,
} from "./baseHandlers";

export function reactive(target) {
  return createReactiveObject(target, false, mutableHandlers);
}

export function shallowReactive(target) {
  return createReactiveObject(target, false, shallowReactiveHandlers);
}

export function readonly(target) {
  return createReactiveObject(target, true, readonlyHandlers);
}

export function shallowReadonly(target) {
  return createReactiveObject(target, true, shallowReadonlyHandlers);
}

// 创建两个映射map,存放已经代理过的数据
// WeakMap 可以自动垃圾回收,不用担心内存泄漏问题。并且key只能是对象类型
const reactiveMap = new WeakMap();
const readlonlyMap = new WeakMap();

/**
 * 创建代理方法
 * @param target 需要被代理的对象 
 * @param isReadonly 是否是只读的
 * @param baseHandlers get set 方法
 * @returns
 */
export function createReactiveObject(target, isReadonly, baseHandlers) {
  // 判断这个数据是否是一个对象
  if (!isObject(target)) return target;

  // 判断这个对象是否被代理过,如果已经被代理过,则从map中获取数据
  const proxyMap = isReadonly ? readlonlyMap : reactiveMap;
  const existProxy = proxyMap.get(target);
  if (existProxy) return existProxy;

  // 创建代理对象
  const proxy = new Proxy(target, baseHandlers);
  proxyMap.set(proxy, target);

  // 最后返回被代理的对象
  return proxy;
}

reactivity/src/baseHandlers.ts

import { isObject, mergeObjects } from "@vue/shared";
import { reactive, readonly } from "./reactive";

// 抽离出共用的set方法
const readonlyObj = {
  set: (target, key) => {
    console.warn(`key:${key} set 失败,因为这个对象是只读的`);
  },
}; 

// 拦截get的方法,柯里化
function createGetter(isReadonly = false, isShallow = false) {
  /**
   * 这个内部的方法参数来自于读取某个对象是,proxy传递进来的
   * target: 当前对象本身
   * key: 当前读取的key
   * receiver: 当前的代理对象
   */
  return function (target, key, receiver) {
    // 这里使用Reflect.get来获取对象,相当于 target[key]
    // 后续Object上的方法,会被迁移到Reflect上去,Reflect.getProptotypeof()
    // target[key] = value 的方式可能会设置失败,但是并不会返回报错,也没有返回标识来表示是否成功
    // Reflect 方法具备返回值,所以这里要使用 Reflect 来取值和set值
    const res = Reflect.get(target, key, receiver);

    if (!isReadonly) {
      // 进行依赖收集,只有不是只读的对象才会进行依赖收集
    }

    // 如果是shallow则只返回拿到的值,不进行深层次代理
    if (isShallow) return res;

    // 如果拿到的返回值仍然是一个对象,则进行递归响应式处理
    // 这里和vue2不同,vue2是上来就进行对象的递归响应式处理
    // vue3则是懒代理,只有到读取到这个对象的某个属性时,才会对这个对象进行响应式处理
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res);
    }

    return res;
  };
}

// 拦截set的方法,柯里化
function createSetter(isShallow = false) {
  return function (target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver);
    return res
  };
}

// 生成不同的get方法
const get = createGetter();
const shallowGet = createGetter(false, true);
const readonlyGet = createGetter(true);
const shallowReadonlyGet = createGetter(true, true);

// 生成不同的set方法
const set = createSetter();
const shallowSet = createSetter(true);

export const mutableHandlers = {
  get,
  set,
};

export const shallowReactiveHandlers = {
  get: shallowGet,
  set: shallowSet,
};

// 合并两个对象,共用一个set方法
export const readonlyHandlers = mergeObjects(
  {
    get: readonlyGet,
  },
  readonlyObj
);
// 合并两个对象,共用一个set方法
export const shallowReadonlyHandlers = mergeObjects(
  {
    get: shallowReadonlyGet,
  },
  readonlyObj
);

测试reactive Api

example/1.reactive.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head> 
  <body>
    <script src="../node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
    <script>
      let { reactive, shallowReactive, readonly, shallowReadonly } =
        VueReactivity;

      let state = shallowReadonly({ 
        name:"李四",
        age:{
            n:18
        }
       });
      state.name = 20
      console.log(state.age);
    </script>
  </body>
</html>

Effect依赖收集

effect.ts

packages/reactivity/src/effect.ts

export function effect(fn: Function, options: any = {}) {
  const effect = createReactiveEffect(fn, options);
  if (!options.lazy) {
    effect();
  }
  return effect;
}

let uid = 0;
let activeEffect; // 当前正在操作的effect
const effectStack = []; // 使用一个栈来存储effect
// 创建一个响应式的effect,根据不同的属性创建不同的effect方法
function createReactiveEffect(fn, options) {
  const effect = function reactiveEffect() {
    try {
      activeEffect = effect; // 当前正在操作的effect
      fn();
      effectStack.push(effect);
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  effect.id = uid++; // 制作一个effect标识,用于区分effect
  effect._isEffect = true; // 用于标识这个是响应式effect
  effect.row = fn; // 保留effect对应的原函数
  effect.options = options; // 保留effect的属性
  return effect;
}

// 依赖收集方法,在 effect 中读取属性时就会触发 get 方法
// 在get方法中将对象本身,以及当前的key传递过来
// 然后和effect进行对应关联
const targetMap = new WeakMap()
export function track(target, type, key) {
    if(!activeEffect) return

    // 使用如下一个结构来进行属性和effect关联
    // new WeakMap( target, new Map( key, [effect1,effect2] ))
    let depTarget = targetMap.get(target)
    if(!depTarget){
        targetMap.set(target, depTarget = new Map())
    }

    // 拿到的是一个set,存放的是属性对应的多个effect
    let depMap = depTarget.get(key)
    if(!depMap){
        depTarget.set(key, depMap = new Set())
    }

    depMap.add(activeEffect)
}

baseHandlers.ts

packages/reactivity/src/baseHandlers.ts

修改 createGetter 方法,在get时调用 effect.js 中的 track 方法,传入 target,type,key 进行响应式依赖收集

+ import { track } from "./effect";
+ import { TrackOpTypes } from "./operators";

// 拦截get的方法,柯里化
function createGetter(isReadonly = false, isShallow = false) {
  /**
   * 这个内部的方法参数来自于读取某个对象是,proxy传递进来的
   * target: 当前对象本身
   * key: 当前读取的key
   * receiver: 当前的代理对象
   */
  return function (target, key, receiver) {
    // 这里使用Reflect.get来获取对象,相当于 target[key]
    // 后续Object上的方法,会被迁移到Reflect上去,Reflect.getProptotypeof()
    // target[key] = value 的方式可能会设置失败,但是并不会返回报错,也没有返回标识来表示是否成功
    // Reflect 方法具备返回值,所以这里要使用 Reflect 来取值和set值
    const res = Reflect.get(target, key, receiver);

    if (!isReadonly) {
      // 进行依赖收集,只有不是只读的对象才会进行依赖收集
+      track(target,TrackOpTypes.GET,key)
    }

    // 如果是shallow则只返回拿到的值,不进行深层次代理
    if (isShallow) return res;

    // 如果拿到的返回值仍然是一个对象,则进行递归响应式处理
    // 这里和vue2不同,vue2是上来就进行对象的递归响应式处理
    // vue3则是懒代理,只有到读取到这个对象的某个属性时,才会对这个对象进行响应式处理
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res);
    }

    return res;
  };
}

operators.ts

packages/reactivity/src/operators.ts

定义一个枚举,用于区分场景

// 设置一个枚举
export const enum TrackOpTypes {
    GET
} 

测试effect

example/2.effect.html

<div id="app"></div>
<script src="../node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
<script>
    let { effect,reactive } = VueReactivity
    let state = reactive({name:'张三',age:18})
    effect(()=>{
        app.innerText = `${state.name} + ${state.age}`
    })
</script>

最终生成的 targetMap 结构

image-20240109171018102

触发更新

修改createSetter方法

修改 /reactivity/src/baseHandlers.ts 文件中的 createSetter 方法

import {
  hasChange,
  hasOwn,
  isArray,
  isIntegerKey,
  isObject,
  mergeObjects,
} from "@vue/shared";
import { reactive, readonly } from "./reactive";
import { track, trigger } from "./effect";
import { TrackOpTypes, TriggerOpTypes } from "./operators";

// 拦截set的方法,柯里化
function createSetter(isShallow = false) {
  return function (target, key, value, receiver) {
    // 获取旧值
    const oldValue = Reflect.get(target, key, receiver);

    // set数据时的时候更新这个key所收集的effect
    // 先判断target是否是一个数组并且key是否是数字字符串
    // 如果是则判断修改的key是否是在数组下标内
    // Number(key) < target.length 表示当前修改的下标在原有数组内,因为此时的target还没有发生修改
    // 否则的话再去判断当前修改的key是否在target身上
    let hadKey =
      isArray(target) && isIntegerKey(key)
        ? Number(key) < target.length
        : hasOwn(target, key);

    // 修改值,Reflect.set返回的是一个布尔值,true表示修改成功
    const res = Reflect.set(target, key, value, receiver);

    if (!hadKey) {
      // 新增
      trigger(target, TriggerOpTypes.ADD, key, value);
    } else {
      // 判断两个值不相等的情况
      if (hasChange(oldValue, value)) {
        // 修改
        trigger(target, TriggerOpTypes.SET, key, value, oldValue);
      }
    }

    return res;
  };
}

reactivity/src/operators.ts

operators 文件中增加了一个枚举类,用于区分时新增一个属性还是修改一个属性

// set时用于区分时新增一个属性还是修改一个属性
export const enum TriggerOpTypes {
  ADD,
  SET,
}

shared/src/index.ts

公共方法增加了一些方法

// 判断一个数据是否是一个对象
export function isObject(value) {
  return typeof value === "object" && value !== null;
}

// 合并两个对象
export function mergeObjects<T extends object, U extends object>(
  obj1: T,
  obj2: U
): T & U {
  return Object.assign(obj1, obj2);
}

// 判断数据是否是数组
export function isArray<T>(value: unknown): value is T[] {
  return Array.isArray(value);
}

// 判断数据是否是函数
export function isFunction(value: unknown): value is Function {
  return typeof value === "function";
}

// 判断数据是否是数字
export function isNumber(value: unknown): value is number {
  return typeof value === "number";
}

// 判断数据是否是字符串
export function isString(value: unknown): value is string {
  return typeof value === "string";
}

// 判断一个字符串是否是数字字符串
export function isIntegerKey(key) {
  return parseInt(key) + "" === key;
}

// 判断对象中是否有某个属性
export function hasOwn(obj, key) {
  return Object.prototype.hasOwnProperty.call(obj, key);
}

// 判断两个数据是否相等
export function hasChange(oldValue, newValue) {
  return oldValue !== newValue;
}

// 判断数据是否是Symbol类型
export function isSymbol(value) {
  return typeof value === "symbol";
}

reactivity/src/effect.ts

effect 中去增加一个 tagger 方法,此方法会根据传递进来的key和target,找到对应的 dep 进行更新,也就是会重新触发这个属性对应的 effect,从而实现页面更新

import { isArray, isIntegerKey, isSymbol } from "@vue/shared";
import { TriggerOpTypes } from "./operators";

// 更新依赖的方法
export function trigger(target, type, key?, newValue?, oldValue?) {
  // 得到当前对象对应的map
  let depMap = targetMap.get(target);

  // 判断这个target是否收集过依赖
  if (!depMap) return;

  // 将所有的effect放在一个数组中,最终一起执行
  let effects = [];

  const add = (effectsToAdd) => {
    if (effectsToAdd) {
      effectsToAdd.forEach((effect) => {
        effects.push(effect);
      });
    }
  };

  // 判断当前操作的是否是数组
  // 并且修改的key是legth,则需要更新数组所收集到的effect
  if (key === "length" && Array.isArray(target)) {
    console.log(depMap, "depMapdepMap");

    // Map类型数据进行forEach遍历是,第一个是键值对的值,第二个是键
    depMap.forEach((deps, key) => {
      // key > newValue
      // 例如我effect中使用了 state.arr[2],则收集到的依赖就会有一个key是2
      // 如果我更新时 state.arr.length = 1,则也要更新这个数组target所收集的依赖effect
      if (!isSymbol(key) && (key === "length" || key > newValue)) {
        add(deps);
      }
    });
  } else {
    // 这里可能是对象
    if (key !== undefined) {
      add(depMap.get(key));
    }
    // 如果是这种情况: state.arr[100] = 1
    // 这种情况表示更新的是数组的某个索引,此时key就是100
    // 但是100并不在原有数组的属性上,所以type是ADD
    // 去更新这个数组对应的length属性对应的effect
    switch (type) {
      case TriggerOpTypes.ADD:
        if (isArray(target) && isIntegerKey(key)) {
          add(depMap.get("length"));
        }
    }
  }

  effects.forEach((effect) => effect());
}

测试触发更新

<div id="app"></div>
<script src="../node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
<script>
    let { effect, reactive } = VueReactivity;
    let state = reactive({ name: "张三", age: 18, arr: [1, 2, 3] });
    effect(() => {
        app.innerHTML = `${state.arr} + ${state.arr.length}`;
    });

    setTimeout(() => {
        state.arr.length = 100;
    }, 1000);
</script>

image-20240109231817514

ref Api实现

四个API

ref 有关的API共有四个

  • ref:将一个普通数据类型转成响应式的,如果穿过来的是一个对象,则会调用 reactive 进行响应式处理
  • shallowRef:浅层的ref
  • toRef:用法 let refKey =toRef(object,key) 将某个对象中的某一个属性变成响应式的,此时修改 refKey.value = xxx 相当于修改的就是 object.key = xxx
  • toRefs:用法 let {key1,key2} = toRefs(object) 将某个对象中的所有属性变成响应式的,在对象解构时可以用这个方法,否则解构出来的属性会丢失响应式

reactivity/src/ref.ts

下面是源码实现

import { hasChange, isArray, isObject } from "@vue/shared";
import { track, trigger } from "./effect";
import { TrackOpTypes, TriggerOpTypes } from "./operators";
import { reactive } from "./reactive";

export function ref(value) {
  return createRef(value);
}

export function shallowRef(value) {
  return createRef(value, true);
}

function convert(value) {
  return isObject(value) ? reactive(value) : value;
}

class RefImpl {
  public _value: any;
  // 产生的实例会被添加一个__v_isRef标记,表示这是一个ref
  public __v_isRef = true;
  // 给参数前面加上一个public关键字
  // 会自动的帮我们实现:this.rowValue = rowValue; this.shallow = shallow
  constructor(public rowValue, public shallow) {
    // 如果是shallowRef 则直接返回,否则判断是否是对象,如果是对象,则递归进行深层响应式处理
    this._value = shallow ? rowValue : convert(rowValue);
  }

  // es6中的属性访问器,转成es5会转成 Object.defineProperty
  get value() {
    track(this, TrackOpTypes.GET, "value");
    return this._value;
  }

  set value(newValue) {
    if (hasChange(this._value, newValue)) {
      this.rowValue = newValue;
      this._value = newValue;
      trigger(this, TriggerOpTypes.SET, "value", newValue);
    }
  }
}

export function createRef(value, shallow = false) {
  return new RefImpl(value, shallow);
}

// toRef返回的实例不再需要收集依赖,如果传过来的target已经被响应式了,则生成的ref还是响应式的
// 在set value时还是会走响应式中的set方法进行依赖更新,所以这里不需要依赖收集
class ObjectRefImpl {
  // 产生的实例会被添加一个__v_isRef标记,表示这是一个ref
  public __v_isRef = true;
  constructor(public target, public key) {

  }
  get value(){
    return this.target[this.key];
  }
  set value(newValue){
    this.target[this.key] = newValue;
  }
}

// toRef实现
export function toRef(target, key) {
  return new ObjectRefImpl(target, key);
}

// toRefs实现
export function toRefs(object) {
  const res = isArray(object) ? new Array(object.length) : {}
  // 遍历每一个属性。让每一个属性都变成ref
  for (let key in object) {
    res[key] = toRef(object, key);
  }
  return res
}

reactivity/src/index.ts

在 index 文件导入四个ref的四个方法

export {ref,shallowRef,toRef,toRefs} from "./ref"

测试ref

<div id="app"></div>
<div id="app1" style="width: 100px;height: 100px;"></div>

<script src="../node_modules/@vue/reactivity/dist/reactivity.global.js"></script>

<script>
    let { ref,effect,reactive,toRef,toRefs } = VueReactivity;

    // ref测试
    let color = ref("red")
    effect(()=>{
        app1.style.backgroundColor = color.value
    })
    setTimeout(()=>{
        color.value = "blue"
    },1000)

    // toRefs测试
    let state = reactive({
        name:"王五",
        age:12
    })
    let {name,age} = toRefs(state)

    effect(()=>{
        app.innerHTML = `${name.value} - ${age.value}`
    })

    setTimeout(()=>{
        name.value = "张三123"
    },1000)
</script>

image-20240110140402875

computed源码实现

实现流程

  1. 当访问计算属性的value时要把当前计算属性所依赖的effect收集起来
  2. 当计算属性中所依赖的属性发生变化时,会走set方法,会遍历所依赖属性收集的effect并执行
  3. 执行effect中如果发现effect的属性中存在scheduler方法,则会执行scheduler方法
  4. 在计算属性的scheduler方法中会重置_dirty的值,并执行trigger方法_
  5. 在trigger方法中会执行当前计算属性收集的effect,从而重新读取value属性触发get方法
  6. 执行get方法时因为已经重置了_dirty的值,所以会重新执行getter方法得到最新值并返回
  7. 触发get方法后会再次收集依赖等待下次调用
// 计算属性实现原理
import {isFunction} from "@vue/shared";
import {effect, track, trigger} from "./effect";
import {TrackOpTypes, TriggerOpTypes} from "./operators";

class ComputedRef{
    public _dirty = true // 标记值是否需要更新,为true时需要重新走getter方法读取最新值
    public effect
    public _value

    constructor(getter,public setter) {
        // 2.当计算属性中所依赖的属性发生变化时,会走set方法,会遍历所依赖属性收集的effect并执行
        // 3.执行effect中如果发现effect的属性中存在scheduler方法,则会执行scheduler方法
        // 4.在计算属性的scheduler方法中会重置_dirty的值,并执行trigger方法
        // 5.在trigger方法中会执行当前计算属性收集的effect,从而重新读取value属性触发get方法
        // 6.执行get方法时因为已经重置了_dirty的值,所以会重新执行getter方法得到最新值并返回
        // 7.触发get方法后会再次收集依赖等待下次调用
        this.effect = effect(getter,{
            lazy:true,
            scheduler:()=>{
                if(!this._dirty){
                    this._dirty = true
                    trigger(this,TriggerOpTypes.SET,"value")
                }
            }
        })
    }

    get value(){
        // 实现数据缓存,多次获取计算属性的值不会使getter触发多次
        if(this._dirty){
            this._value = this.effect()
            this._dirty = false
        }
        // 1. 当访问计算属性的value时要把当前计算属性所依赖的effect收集起来
        track(this,TrackOpTypes.GET,"value")
        return this._value
    }
    set value(newValue){
        this.setter(newValue)
    }
}

export function computed(getterOrOptions){
    let getter;
    let setter;
    // 判断计算属性传递进来的值是否是是一个方法,如果是一个方法,则不能再给这个计算属性set值
    if(isFunction(getterOrOptions)){
        getter = getterOrOptions
        setter = ()=>{
            console.warn("computed value must be readonly")
        }
    }else{
        // 如果是对象,则取get和set
        getter = getterOrOptions.get
        setter = getterOrOptions.set
    }

    return new ComputedRef(getter,setter)
}

修改 packages/reactivity/src/effect.ts

import { isArray, isIntegerKey, isSymbol } from "@vue/shared";
import { TriggerOpTypes } from "./operators";

export function effect(fn: Function, options: any = {}) {
  const effect = createReactiveEffect(fn, options);
  if (!options.lazy) {
    effect();
  }
  return effect;
}

let uid = 0;
let activeEffect; // 当前正在操作的effect
const effectStack = []; // 使用一个栈来存储effect
// 创建一个响应式的effect,根据不同的属性创建不同的effect方法
function createReactiveEffect(fn, options) {
  const effect = function reactiveEffect() {
    try {
      activeEffect = effect; // 当前正在操作的effect
      effectStack.push(effect);
+      return fn();
    } finally {
      effectStack.pop();
      activeEffect = effectStack[effectStack.length - 1];
    }
  };
  effect.id = uid++; // 制作一个effect标识,用于区分effect
  effect._isEffect = true; // 用于标识这个是响应式effect
  effect.row = fn; // 保留effect对应的原函数
  effect.options = options; // 保留effect的属性
  return effect;
}

// 依赖收集方法,在 effect 中读取属性时就会触发 get 方法
// 在get方法中将对象本身,以及当前的key传递过来
// 然后和effect进行对应关联
const targetMap = new WeakMap();
export function track(target, type, key) {
  if (!activeEffect) return;

  // 使用如下一个结构来进行属性和effect关联
  // new WeakMap( target, new Map( key, [effect1,effect2] ))
  let depTarget = targetMap.get(target);
  if (!depTarget) {
    targetMap.set(target, (depTarget = new Map()));
  }

  // 拿到的是一个set,存放的是属性对应的多个effect
  let depMap = depTarget.get(key);
  if (!depMap) {
    depTarget.set(key, (depMap = new Set()));
  }

  depMap.add(activeEffect);
}

// 更新依赖的方法
export function trigger(target, type, key?, newValue?, oldValue?) {
  // 得到当前对象对应的map
  let depMap = targetMap.get(target);

  // 判断这个target是否收集过依赖
  if (!depMap) return;

  // 将所有的effect放在一个数组中,最终一起执行
  let effects = [];

  const add = (effectsToAdd) => {
    console.log(effectsToAdd,'effectsToAddeffectsToAdd')
    if (effectsToAdd) {
      effectsToAdd.forEach((effect) => {
        effects.push(effect);
      });
    }
  };

  // 判断当前操作的是否是数组
  // 并且修改的key是legth,则需要更新数组所收集到的effect
  if (key === "length" && Array.isArray(target)) {
    // console.log(depMap, "depMapdepMap");

    // Map类型数据进行forEach遍历是,第一个是键值对的值,第二个是键
    depMap.forEach((deps, key) => {
      // key > newValue
      // 例如我effect中使用了 state.arr[2],则收集到的依赖就会有一个key是2
      // 如果我更新时 state.arr.length = 1,则也要更新这个数组target所收集的依赖effect
      if (!isSymbol(key) && (key === "length" || key > newValue)) {
        add(deps);
      }
    });
  } else {
    // 这里可能是对象
    if (key !== undefined) {
      add(depMap.get(key));
    }
    // 如果是这种情况: state.arr[100] = 1
    // 这种情况表示更新的是数组的某个索引,此时key就是100
    // 但是100并不在原有数组的属性上,所以type是ADD
    // 去更新这个数组对应的length属性对应的effect
    switch (type) {
      case TriggerOpTypes.ADD:
        if (isArray(target) && isIntegerKey(key)) {
          add(depMap.get("length"));
        }
    }
  }

+  effects.forEach((effect) => {
+    if(effect.options.scheduler){
+      effect.options.scheduler()
+    }else{
+      effect()
+    }
  });
}

runtimeDom和runtimeCore

  • runtimeDom专门用来用来操作DOM,例如style属性的添加和删除,class类的添加和删除,属性、事件等等
  • runtimeCore专门用来生成虚拟节点并挂载,会调用runtimeDom中提供的方法

新建runtime-dom包

新建 packages/runtime-dom 文件夹

然后创建 src/index.ts 文件

export default {
    name:"runtime-dom"
}

新建一个package.json

{
  "name": "@vue/runtime-dom",
  "version": "1.0.0",
  "main": "index.js",
  "module": "dist/runtime-dom.esm-bundler.js",
  "buildOptions":{
    "name": "VueRuntimeDom",
    "formats": ["esm-bundler","cjs","global"]
  },
  "license": "MIT"
}

新建runtime-core包

新建 packages/runtime-core 文件夹

然后创建 src/index.ts 文件

export default {
    name:"runtime-core"
}

新建一个package.json

{
  "name": "@vue/runtime-core",
  "version": "1.0.0",
  "main": "index.js",
  "module": "dist/runtime-core.esm-bundler.js",
  "buildOptions":{
    "name": "VueRuntimeCore",
    "formats": ["esm-bundler","cjs","global"]
  },
  "license": "MIT"
}

添加依赖

执行 yarn installruntime-domruntime-core 加入到 node_modules

yarn install

yarn build

image-20240128170026833

观察是否生成 dist 文件,有说明包创建成功

patchNode

新建 runtime-dom/src/nodeOpts.ts

// 这个文件专门存放用于操作节点的各种方法
export const nodeOpts = {
    // 创建一个元素
    createElement: (target) => document.createElement(target),
    // 删除一个元素
    remove: (target) => {
        // 1.先找到这个元素的父元素
        const parent = target.parentNode
        if (parent) {
            parent.removeChild(target)
        }
    },
    // 在指定位置插入一个元素
    insert: (target, parent, anchor = null) => {
        parent.insertBefore(target, anchor)
    },
    // 查找一个元素
    querySelector: (selector) => document.querySelector(selector),
    // 设置一个标签元素的文本内容
    setElementText: (el, text) => el.textContent = text,
    // 创建一个文本元素
    createText: (text) => document.createTextNode(text),
    // 设置文本元素的内容
    setText: (node, text) => node.nodeValue = text
}

patchProps

新建 runtime-dom/src/patchProps.ts

// 这个方法用于给某个元素添加对应的属性
import {patchStyle} from "./modules/style";
import {patchClass} from "./modules/class";
import {patchEvent} from "./modules/event";
import {patchAttr} from "./modules/attr";

export const patchProps = (el, key, prevValue, newValue) => {
    switch (key) {
        case "style":
            // 调用patchStyle方法来更新样式
            patchStyle(el,prevValue,newValue)
            break;
        case "class":
            // 调用patchClass方法来更新class
            // 注意:这里的prevValue和newValue是字符串类型
            patchClass(el,newValue)
            break;
        default:
            // 这里区分是事件还是普通属性
            // 这个正则表示匹配以on开头,后面跟着任意字符但不是大写的a-z的字符
            // 例如:onClick、onChange 等
            // 注意:onclick、onchange 这样的属性这个正则表达式不匹配
            if(/on[^a-z]/.test(key)){
                // 调用patchEvent方法来更新事件
                patchEvent(el,key,newValue)
            }else{
                //  调用patchAttr方法来更新属性
                patchAttr(el,key,newValue)
            }
            break;
    }
}

runtime-dom/src/modules/style.ts

export function patchStyle(el, prev, next){
    let style = el.style

    // 如果传递过来新的样式是null,则直接删除标签上的style元素
    if(next == null){
        el.removeAttribute("style")
        return
    }

    if(prev){
        for (const key in prev) {
            if(!next[key]){
                style[key] = ''  // 删除样式
            }
        }
    }

    for (const key in next) {
        style[key] = next[key]
    }
}

runtime-dom/src/modules/class.ts

export function patchClass(el, value){
    if(value == null){
        value = ""
    }
    el.classList = value
}

runtime-dom/src/modules/event.ts

// 对事件进行缓存操作的流程
// 1.给元素添加一个用于缓存绑定事件的对象
// 2.如果这个缓存中之前没有缓存过这个事件,但是value有值,则需要绑定方法到缓存列表中
// 3.以前绑定过的需要删除掉,删除缓存
// 4.如果前后都有值,直接改变invoker中value属性指向最新的事件
export function patchEvent(el, key, value) {
    // 在Vue的源码中,事件就是存放在el的_vei身上的
    // 这里拿到之前缓存的事件对象
    const invokers = el._vei || (el._vei = {})
    // 从对象中获取本地调用的方法
    const exists = invokers[key]

    if(value && exists){
        exists.value = value
    }else{
        // 截取掉前面的on,并且将后面的字符串转成小写
        const eventName = key.slice(2).toLowerCase()
        if(value){
            // 去缓存一份新的事件名
            let invoker = invokers[key] = createInvoker(value)
            el.addEventListener(eventName, invoker)
        }else{
            el.removeEventListener(eventName, exists)
            invokers[key] = undefined
        }
    }
}

function createInvoker(value){
    const invoker = (e)=>{
        invoker.value(e)
    }
    // 这样每次都支持更新value的值,不会重复添加多个事件
    invoker.value = value
    return invoker
}

runtime-dom/src/modules/attr.ts

export function patchAttr(el, key, value){
    if(value == null){
        el.removeAttribute(key)
        return
    }
    el.setAttribute(key,value)
}

runtime-dom导出createApp方法

我们最终要使用 createApp 这个方法来挂载页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <div id="app"></div>
    <script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
    <script>
        let {createApp} = VueRuntimeDom
        let App = {
            render(){
                console.log("render")
            }
        }
        let app = createApp(App)
        app.mount("#el")
    </script>
</body>
</html>

runtime-dom/src/index.ts 创建createApp方法

import {patchProps} from "./patchProps"
import {nodeOpts} from "./nodeOpts"
import {mergeObjects} from "@vue/shared";
import {createRender} from "@vue/runtime-core";

// 将patchProps和nodeOpts合并为一个对象并导出
const renderOptions = mergeObjects({patchProps}, nodeOpts)

// 导出 createApp 方法
export function createApp(rootComponent, rootProps = null) {
    const app: any = createRender(renderOptions).createApp(rootComponent, rootProps)
    const { mount } = app
    app.mount = (container) => {
       // 先清空容器内容
        const contain = nodeOpts.querySelector(container)
        contain.innerHTML = ""
        mount(contain)
    }
    return app
}

runtime-core实现createRender方法

新建 runtime-core/src/renderer.ts

import {createAppApi} from "./apiCreateApp";

export function createRender(renderOptions){
    // vue核心方法,拿到一个虚拟节点和一个容器,将虚拟节点渲染到容器中
    const render = (vnode,container)=> {

    }
    return {
        createApp:createAppApi(render)
    }
}

新建 runtime-core/src/apiCreateApp.ts

export function createAppApi(render){
    return function createApp(rootComponent, rootProps){
        return {
            mount(container){
                // mount方法做的事情
                // 1.根据组件创建虚拟节点
                // 2.将虚拟节点和容器获取到后调用render方法进行页面渲染
                let vnode = {}
                render(vnode,container)
                
                console.log(container)
                console.log(rootComponent)
                console.log(rootProps)
            }
        }
    }
}

导出 createRender 方法

runtime-core/src/index.ts

import {createRender} from "./renderer";

export {
    createRender
}

最后测试一下看看 mount 方法的打印是否成功

image-20240128183955906

接下来我们专注实现如何创建虚拟节点

组件创建流程

runtime-core/src/apiCreateApp.ts

此方法返回mount方法,

import {createVNode} from "./vnode";

export function createAppApi(render) {
    return function createApp(rootComponent, rootProps) {
        const app = {
            _props: rootProps, // 组件的属性
            _component: rootComponent, // createApp传入的组件
            _container: null, // 需要挂载的容器
            mount(container) {
                // mount方法做的事情
                // 1.根据组件创建虚拟节点
                let vnode = createVNode(rootComponent, rootProps)

                // 2.将虚拟节点和容器获取到后调用render方法进行页面渲染
                render(vnode, container)

                app._container = container
            }
        }
        return app
    }
}

runtime-core/src/vnode.ts

// 这个方法用来创建虚拟组件,是Vue3的核心方法之一
import {isArray, isObject, isString} from "@vue/shared";
import {ShapeFlags} from "@vue/shared/src/shapeFlags";

export const createVNode = (type, props, children = null) => {
    // 根据type判断区分是组件还是普通元素
    // type是对象的就是组件,如果是字符串就是普通元素
    // 例如:createApp({ render(){ return `<h1><h1>` }},{name:"lisi",age:18}),这种的就是组件
    // 例如:createApp("div",{ style:{ color:"red" }}),这种的就是普通元素
    // 但是不管是组件还是普通元素,第二个参数都是各自的属性
    // 第三个参数是儿子节点,可能是空,如果是空的话就默认是null


    const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : (isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : 0)

    const vnode = {
        __v_isVnode: true,// 标识是否是一个虚拟节点
        type,
        props,
        children,
        el: null,// 这个虚拟节点对应的真实dom元素
        key: props && props.key,// 每个组件对应的唯一标识,在diff算法中会用到
        shapeFlag,
    }
    // 判断当前组件儿子的类型
    normalizeChildren(vnode, children)
    // 返回描述页面的对象,称为虚拟节点,具有跨平台的能力
    // 可以根据不同的平台生成不同的虚拟节点
    return vnode
}

function normalizeChildren(vnode, children) {
    let type = 0
    if (children === null) {

    } else if (isArray(children)) {
        type = ShapeFlags.ARRAY_CHILDREN
    } else {
        type = ShapeFlags.TEXT_CHILDREN
    }
    // 根据儿子的状态来判断父亲的状态
    vnode.shapeFlag |= type
}

shared/src/shapeFlags.ts

使用位运算符来判断组件的类型

export const enum ShapeFlags {
    ELEMENT = 1,
    FUNCTIONAL_COMPONENT = 1 << 1,
    STATEFUL_COMPONENT = 1 << 2,
    TEXT_CHILDREN = 1 << 3,
    ARRAY_CHILDREN = 1 << 4,
    SLOTS_CHILDREN = 1 << 5,
    TELEPORT = 1 << 6,
    SUSPENSE = 1 << 7,
    COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
    COMPONENT_KEPT_ALIVE = 1 << 9,
    COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

// | 运算符,两边有一个是1,结果就是1,否则为0
// console.log(ShapeFlags.TEXT_CHILDREN | ShapeFlags.ARRAY_CHILDREN)

然后再 shared/src/index.ts 文件中导出

import * as shapeFlags from "./shapeFlags"

runtime-core/src/renderer.ts

在mount函数中会调用下面的render方法,并传递过来vnode虚拟节点,拿到不同的虚拟节点,创建对应的真实节点,然后将真实节点渲染到容器中

然后判断是元素还是组件,进行组件挂载

判断有没有上一次的虚拟节点,如果没有,则就是初始化,否则是更新

import {createAppApi} from "./apiCreateApp";
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {createComponentInstance, setupComponent} from "./component";

export function createRender(renderOptions) {

    const setupRenderEffect = () => {

    }

    // 挂载组件
    const mountComponent = (initialVNode, container) => {
        // 组件的渲染方法,这个方法的核心功能是调用setup拿到返回值,获取render函数返回的结果来进行渲染
        // 1.拿到实例,根据initialVNode来创建实例
        const instance = (initialVNode.component = createComponentInstance(initialVNode))
        // 2.需要的数据解析到实例上
        setupComponent(instance)
        // 3.创建一个effect让render函数执行
        setupRenderEffect()
    }

    const processComponent = (n1, n2, container) => {
        // 判断有没有上一次的虚拟节点,如果没有,则就是初始化,否则是更新
        if (n1 === null) {
            // 初始化挂载组件
            mountComponent(n2, container)
        } else {
            // 更新组件
        }
    }

    const patch = (n1, n2, container) => {
        const {shapeFlag} = n2 // 拿到虚拟节点的类型
        if (shapeFlag & ShapeFlags.ELEMENT) {
            // 元素
        } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
            // 组件
            processComponent(n1, n2, container)
        }
    }

    // vue核心方法,拿到一个虚拟节点和一个容器,将虚拟节点渲染到容器中
    const render = (vnode, container) => {
        // 这里拿到不同的虚拟节点,创建对应的真实节点,然后将真实节点渲染到容器中
        // 第一次调用时,可能是初始化,没有上一个虚拟节点
        patch(null, vnode, container)
    }
    return {
        createApp: createAppApi(render)
    }
}

runtime-core/src/component.ts

在挂载组件是创建实例,并且调用组件的setup方法和render方法,都在此文件中进行

调用setup函数时,会传递两个参数:

  1. 定义的props属性数据
  2. context上下文对象,包含:attrs、slots、emit、expose

调用render函数时,会传递一个proxy代理对象,通过这个代理对象,可以同时访问setupState、data、props上的属性

// 创建实例方法
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {PublicInstanceProxyHandlers} from "./PublicInstanceProxyHandlers";

export const createComponentInstance = (vnode) => {
    const instance = {
        vnode,
        type: vnode.type,
        props: {},
        attrs: {},
        slots: {},
        ctx: {},
        data: {
            b: 2
        },
        render: null,
        setupState: {
            c: 3
        },
        isMounted: false // 标识组件是否挂载过
    }

    instance.ctx = {_: instance}

    return instance
}

export const setupComponent = (instance) => {
    // 将vnode上的props和children放在实例instance上
    let {props, children, shapeFlag} = instance.vnode
    instance.props = props
    instance.children = children

    // 判断当前组件是不是有状态的组件
    let isStateful = shapeFlag & ShapeFlags.STATEFUL_COMPONENT
    if (isStateful) {
        setupStatefulComponent(instance)
    }
}

function setupStatefulComponent(instance) {

    let Component = instance.type
    let {setup} = Component
    // 组件中定义的setup函数,同时传递参数
    // 第一个参数是定义的props属性数据
    // 第二个是定义的context上下文对象
    // 上下文对象中包含:attrs、props、slots、emit、expose
    let setupContext = createSetupContext(instance)
    setup(instance.props, setupContext)


    // 调用组件的render函数
    instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers as any)
    Component.render(instance.proxy)
}

function createSetupContext(instance) {
    return {
        attrs: instance.attrs,
        slots: instance.slots,
        emit: () => {
        },
        expose: () => {
        }
    }
}

runtime-core/src/PublicInstanceProxyHandlers.ts

对render函数的参数进行代理,访问一个属性时,可以从props,setupState,data中获取任意一个只要存在的值

import {hasOwn} from "@vue/shared";
// 对render函数的参数进行代理,访问一个属性时,可以从props,setupState,data中获取任意一个只要存在的值
export const PublicInstanceProxyHandlers = {
    get({_: instance}, key) {
        // vue源码中规定了如果访问的key是以$开头的,则不能访问
        if (key[0] === '$') {
            return
        }
        // setupState存放的是setup函数返回的数据
        // 取值的顺序为:setupState、data、props
        let {props, data, setupState} = instance
        if (hasOwn(setupState, key)) {
            return setupState[key]
        } else if (hasOwn(data, key)) {
            return data[key]
        } else if (hasOwn(props, key)) {
            return props[key]
        }
    },
    set({_: instance}, key, value) {
        let {props, data, setupState} = instance
        if (hasOwn(props, key)) {
            props[key] = value
        } else if (hasOwn(data, key)) {
            data[key] = value
        } else if (hasOwn(setupState, key)) {
            setupState[key] = value
        }
    }
}

测试setup函数和render函数

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
    let {createApp} = VueRuntimeDom
    let App = {
        setup(props, context) {
            console.log("setup")
            console.log(props, context)
        },
        render(proxy) {
            console.log(proxy)
            console.log(proxy.name)
            console.log(proxy.b)
            console.log(proxy.c)
        }
    }
    let app = createApp(App, {name: "李四", age: 18})
    app.mount("#app")
</script>
</body>
</html>

运行结果

image-20240201151222285

通过结果分析

  • 李四 是从调用createApp函数时,传递的参数中获取的
  • 2 是从data中获取的
  • 3 是从setupState中获取的

setupState是setup函数的返回值,目前我们还没有处理,只是暂时写死的,后面我们会处理

处理setup的返回值

runtime-core/src/component.ts

修改 setupStatefulComponent 方法,增加判断是否存在setup,如果存在,则获取setup的返回值,交给 handleSetupResult 方法处理,最终调用 finishComponentSetup 方法处理render

// 创建实例方法
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {PublicInstanceProxyHandlers} from "./PublicInstanceProxyHandlers";
import {isFunction, isObject} from "@vue/shared";

export const createComponentInstance = (vnode) => {
    const instance = {
        vnode,
        type: vnode.type,
        props: {},
        attrs: {},
        slots: {},
        ctx: {},
        data: {},
        render: null,
        setupState: {},
        isMounted: false // 标识组件是否挂载过
    }

    instance.ctx = {_: instance}

    return instance
}

export const setupComponent = (instance) => {
    // 将vnode上的props和children放在实例instance上
    let {props, children, shapeFlag} = instance.vnode
    instance.props = props
    instance.children = children

    // 判断当前组件是不是有状态的组件
    let isStateful = shapeFlag & ShapeFlags.STATEFUL_COMPONENT
    if (isStateful) {
        setupStatefulComponent(instance)
    }
}

function setupStatefulComponent(instance) {
    let Component = instance.type
    let {setup} = Component

    // 判断组件上是否有setup函数
    if (setup) {
        // 组件中定义的setup函数,同时传递参数
        // 第一个参数是定义的props属性数据
        // 第二个是定义的context上下文对象
        // 上下文对象中包含:attrs、props、slots、emit、expose
        let setupContext = createSetupContext(instance)
        // 拿到setup函数的返回值进行不同的处理
        let setupResult = setup(instance.props, setupContext)
        handleSetupResult(instance, setupResult)
    } else {
        // 组件中没有定义setup函数,则直接获取render函数
        finishComponentSetup(instance)
    }

}

// 处理setup函数的返回
function handleSetupResult(instance, setupResult) {
    // 如果setup函数的返回值是一个函数,则这个函数作为render放在instance实例上
    // 所以这里如果setup函数的返回值是一个函数,则不会再执行定义的render函数
    if (isFunction(setupResult)) {
        instance.render = setupResult
    } else if (isObject(setupResult)) {
        // 如果setup函数的返回值是一个对象,则这个对象作为setupState放在instance实例上
        instance.setupState = setupResult
    }
    finishComponentSetup(instance)
}

// 处理render函数
function finishComponentSetup(instance) {
    let Component = instance.type
    // 如果实例上没有render,这里表示setup返回值不是一个方法,而是一个对象
    if (!instance.render) {
        // 继续判断组件上也没有render,意思是createApp方法接收的对象没有传递render函数
        // 并且有template模板,则将模板编译成render函数
        if (!Component.render && Component.template) {
            // Component.render = 将模板编译成render函数的方法
        }
        instance.render = Component.render
        // 创建render函数的代理参数
        instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers as any)
    }
}

function createSetupContext(instance) {
    return {
        attrs: instance.attrs,
        slots: instance.slots,
        emit: () => {
        },
        expose: () => {
        }
    }
}

runtime-core/src/renderer.ts

修改 renderer 文件的 setupRenderEffect 方法。接收 instance

import {createAppApi} from "./apiCreateApp";
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {createComponentInstance, setupComponent} from "./component";

export function createRender(renderOptions) {

    // 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
    const setupRenderEffect = (instance,container) => {
        // 调用组件的render函数
        instance.render()
    }

    // 挂载组件
    const mountComponent = (initialVNode, container) => {
        // 组件的渲染方法,这个方法的核心功能是调用setup拿到返回值,获取render函数返回的结果来进行渲染
        // 1.拿到实例,根据initialVNode来创建实例
        const instance = (initialVNode.component = createComponentInstance(initialVNode))
        // 2.需要的数据解析到实例上
        setupComponent(instance)
        // 3.创建一个effect让render函数执行
        setupRenderEffect(instance,container)
    }

    const processComponent = (n1, n2, container) => {
        // 判断有没有上一次的虚拟节点,如果没有,则就是初始化,否则是更新
        if (n1 === null) {
            // 初始化挂载组件
            mountComponent(n2, container)
        } else {
            // 更新组件
        }
    }

    const patch = (n1, n2, container) => {
        const {shapeFlag} = n2 // 拿到虚拟节点的类型
        if (shapeFlag & ShapeFlags.ELEMENT) {
            // 元素
        } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
            // 组件
            processComponent(n1, n2, container)
        }
    }

    // vue核心方法,拿到一个虚拟节点和一个容器,将虚拟节点渲染到容器中
    const render = (vnode, container) => {
        // 这里拿到不同的虚拟节点,创建对应的真实节点,然后将真实节点渲染到容器中
        // 第一次调用时,可能是初始化,没有上一个虚拟节点
        patch(null, vnode, container)
    }
    return {
        createApp: createAppApi(render)
    }
}

组件渲染流程

在 setupRenderEffect 方法中创建effect函数

获取render函数的返回值

修改 runtime-core/src/renderer.ts 文件的 setupRenderEffect 方法

创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行

只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染

import {createAppApi} from "./apiCreateApp";
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {createComponentInstance, setupComponent} from "./component";
import {effect} from "@vue/reactivity";

export function createRender(renderOptions) {

    // 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
    const setupRenderEffect = (instance, container) => {
        // 创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行
        // 只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染
        effect(function componentEffect() {
            // 判断是否是初次渲染
            if (!instance.isMounted) {
                // 初次渲染组件,调用render函数拿到返回值
                let subTree = instance.render.call(instance.proxy, instance.proxy)
                console.log(subTree, container)
                // 调用patch进行页面渲染
                patch(null, subTree, container)
                // 修改是否初次渲染的状态
                instance.isMounted = true
            } else {
                // 更新组件
            }
        })
    }

    // 挂载组件
    const mountComponent = (initialVNode, container) => {
        // 组件的渲染方法,这个方法的核心功能是调用setup拿到返回值,获取render函数返回的结果来进行渲染
        // 1.拿到实例,根据initialVNode来创建实例
        const instance = (initialVNode.component = createComponentInstance(initialVNode))
        // 2.需要的数据解析到实例上
        setupComponent(instance)
        // 3.创建一个effect让render函数执行,并且吧需要挂载的节点传过去
        setupRenderEffect(instance, container)
    }

    const processComponent = (n1, n2, container) => {
        // 判断有没有上一次的虚拟节点,如果没有,则就是初始化,否则是更新
        if (n1 === null) {
            // 初始化挂载组件
            mountComponent(n2, container)
        } else {
            // 更新组件
        }
    }

    const patch = (n1, n2, container) => {
        const {shapeFlag} = n2 // 拿到虚拟节点的类型
        if (shapeFlag & ShapeFlags.ELEMENT) {
            // 元素
        } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
            // 组件
            processComponent(n1, n2, container)
        }
    }

    // vue核心方法,拿到一个虚拟节点和一个容器,将虚拟节点渲染到容器中
    const render = (vnode, container) => {
        // 这里拿到不同的虚拟节点,创建对应的真实节点,然后将真实节点渲染到容器中
        // 第一次调用时,可能是初始化,没有上一个虚拟节点
        patch(null, vnode, container)
    }
    return {
        createApp: createAppApi(render)
    }
}

编写h函数

render的返回值是一个h函数,h函数的返回值是虚拟节点,所以下面来实现h函数

新建 packages/runtime-core/src/h.ts

/**
 实现h函数,h函数可能存在的几种方式

 两个参数的情况
 h("div", {style: {color: "red"}})  类型 + 属性
 h("div", h("span", "hello world"))  类型 + 套h函数,这里h函数返回的是一个虚拟节点,我们可以判断 __v_isVnode
 h("div", "hello world")  类型 + 孩子
 h("div", [h("span", "hello world")])  类型 + 数组


 三个参数的情况
 h("div", {style: {color: "red"}}, "hello world")  类型 + 属性 + 孩子
 h("div", {style: {color: "red"}}, h("span", "hello world")))  类型 + 属性 + 虚拟节点

 大于三个参数的情况
 h("div", {style: {color: "red"}}, "hello world", "hello world")  类型 + 属性 + 多个孩子
 h("div", {style: null, h("span", "hello world"), h("span", "hello world"))  类型 + 属性 + 多个虚拟节点
 */
import {isArray, isObject} from "@vue/shared";
import {createVNode, isVnode} from "./vnode";

export function h(type, propsOrChildren, children) {
    let l = arguments.length
    if (l === 2) {
        if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
            // h("div", h("span", "hello world"))
            if (isVnode(propsOrChildren)) {
                return createVNode(type, null, [propsOrChildren])
            }
            // h("div", {style: {color: "red"}})
            return createVNode(type, propsOrChildren)
        } else {
            // h("div", "hello world")  类型 + 孩子
            // h("div", [h("span", "hello world")])  类型 + 数组
            return createVNode(type, null, propsOrChildren)
        }
    } else {
        if (l > 3) {
            // h("div", {style: {color: "red"}}, "hello world", "hello world")  类型 + 属性 + 多个孩子
            children = Array.prototype.slice.call(arguments, 2)
        } else if (l === 3 && isVnode(children)) {
            // h("div", {style: {color: "red"}}, h("span", "hello world")))  类型 + 属性 + 虚拟节点
            children = [children]
        }
        return createVNode(type, propsOrChildren, children)
    }
}

然后导出

packages/runtime-core/src/index.ts文件导出h函数

import {createRender} from "./renderer";
import {h} from "./h"

export {
    createRender,
    h
}

然后再 runtime-dom 中吧 runtime-core 的内容导出

packages/runtime-dom/src/index.ts

import {patchProps} from "./patchProps"
import {nodeOpts} from "./nodeOpts"
import {mergeObjects} from "@vue/shared";
import {createRender} from "@vue/runtime-core";

+ // runtime-dom中导出runtime-core中所有的内容
+ export * from "@vue/runtime-core"

// 将patchProps和nodeOpts合并为一个对象并导出
const renderOptions = mergeObjects({patchProps}, nodeOpts)

// 导出 createApp 方法
export function createApp(rootComponent, rootProps = null) {
    const app: any = createRender(renderOptions).createApp(rootComponent, rootProps)
    const {mount} = app
    app.mount = (container) => {
        // 先清空容器内容
        const contain = nodeOpts.querySelector(container)
        contain.innerHTML = ""
        mount(contain)
    }
    return app
}

测试查看打印结果

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
    let {createApp, h} = VueRuntimeDom
    let App = {
        setup(props, context) {
            console.log("setup")
            console.log(props, context)
            return {
                b: 111,
                c: 333
            }
        },
        render(proxy) {
            console.log(proxy)
            console.log(proxy.name)
            console.log(proxy.b)
            console.log(proxy.c)
            return h("div", {style: {color: "red"}}, "hello world")
        }
    }
    let app = createApp(App, {name: "李四", age: 18})
    app.mount("#app")
</script>
</body>
</html>

image-20240202112524657

下面我们来实现这个过程

实现组件渲染

修改 packages/runtime-core/src/renderer.ts 文件中的 patch 方法,添加针对元素的操作

  1. 增加 processElement 方法处理元素,并且将创建出来的真实DOM赋值给vnode的el属性
  2. 然后再 processElement 方法判断是否是初次挂载,如果是初次挂载则调用 mountElement 方法
  3. mountElement 方法中开始创建真实DOM,并且添加属性和子元素等
  4. 如果判断子元素是一个数组,则调用 mountChildren 方法遍历子元素
  5. mountChildren 方法中使用 normalizeVnode 来创建每一个子元素对应的虚拟节点
  6. normalizeVnode 方法会判断当前元素是否是对象,如果是对象则直接返回,否则创建一个类型是 Symbol("Text") 的虚拟节点
  7. 然后重新调用 patch 方法进行子组件渲染,此时子组件挂载目标就是上面的el
  8. 递归调用 path 最终完成组件渲染
import {createAppApi} from "./apiCreateApp";
import {ShapeFlags} from "@vue/shared/src/shapeFlags";
import {createComponentInstance, setupComponent} from "./component";
import {effect} from "@vue/reactivity";
import {normalizeVnode, Text} from "./vnode";

export function createRender(renderOptions) {
    // 结构renderOptions获取操作DOM的各种方法
    const {
        patchProps: hostPatchProps,
        insert: hostInsert,
        remove: hostRemove,
        createElement: hostCreateElement,
        createText: hostCreateText,
        setText: hostSetText,
        setElementText: hostSetElementText,
    } = renderOptions

    // 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
    const setupRenderEffect = (instance, container) => {
        // 创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行
        // 只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染
        effect(function componentEffect() {
            // 判断是否是初次渲染
            if (!instance.isMounted) {
                // 初次渲染组件,调用render函数拿到返回值
                let subTree = instance.render.call(instance.proxy, instance.proxy)
                // 调用patch进行页面渲染
                patch(null, subTree, container)
                // 修改是否初次渲染的状态
                instance.isMounted = true
            } else {
                // 更新组件
            }
        })
    }

    // 挂载组件
    const mountComponent = (initialVNode, container) => {
        // 组件的渲染方法,这个方法的核心功能是调用setup拿到返回值,获取render函数返回的结果来进行渲染
        // 1.拿到实例,根据initialVNode来创建实例
        const instance = (initialVNode.component = createComponentInstance(initialVNode))
        // 2.需要的数据解析到实例上
        setupComponent(instance)
        // 3.创建一个effect让render函数执行
        setupRenderEffect(instance, container)
    }

    // 处理组件
    const processComponent = (n1, n2, container) => {
        // 判断有没有上一次的虚拟节点,如果没有,则就是初始化,否则是更新
        if (n1 === null) {
            // 初始化挂载组件
            mountComponent(n2, container)
        } else {
            // 更新组件
        }
    }

    // 挂载元素
    const mountElement = (vnode, container) => {
        const {type, props, shapeFlag, children} = vnode
        // 创建元素,并且把真实DOM赋值到虚拟节点的el属性上
        let el = (vnode.el = hostCreateElement(type))
        // 处理属性
        if (props) {
            for (const key in props) {
                hostPatchProps(el, key, null, props[key])
            }
        }
        // 判断元素类型
        if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
            // 普通文本比较简单,直接吧内容放在元素中即可
            hostSetElementText(el, children)
        } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            // 子元素是一个数组,需要遍历每一个子元素
            mountChildren(children, el)
        }
        // 插入元素到指定位置
        hostInsert(el, container)
    }

    // 处理子元素是一个数组的情况
    const mountChildren = (children, container) => {
        for (let i = 0; i < children.length; i++) {
            let child = (children[i] = normalizeVnode(children[i]))
            patch(null, child, container)
        }
    }

    // 处理元素
    const processElement = (n1, n2, container) => {
        if (n1 === null) {
            // 挂载元素
            mountElement(n2, container)
        } else {
            // 更新元素
        }
    }

    const processText = (n1, n2, container) => {
        if (n1 === null) {
            hostInsert(hostCreateText(n2.children), container)
        }
    }

    const patch = (n1, n2, container) => {
        const {shapeFlag, type} = n2 // 拿到虚拟节点的类型

        switch (type) {
            case Text:
                processText(n1, n2, container)
                break
            default:
                if (shapeFlag & ShapeFlags.ELEMENT) {
                    // 元素
                    processElement(n1, n2, container)
                } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
                    // 组件
                    processComponent(n1, n2, container)
                }
        }
    }

    // vue核心方法,拿到一个虚拟节点和一个容器,将虚拟节点渲染到容器中
    const render = (vnode, container) => {
        // 这里拿到不同的虚拟节点,创建对应的真实节点,然后将真实节点渲染到容器中
        // 第一次调用时,可能是初始化,没有上一个虚拟节点
        patch(null, vnode, container)
    }
    return {
        createApp: createAppApi(render)
    }
}

packages/runtime-core/src/vnode.ts 新增 normalizeVnode 方法

export const Text = Symbol("Text")

export function normalizeVnode(vnode) {
    if (isObject(vnode)) return vnode
    // 创建一个类型是 Symbol("Text") 的元素,标识这是一个虚拟的文本节点
    return createVNode(Text, null, String(vnode))
}

测试查看渲染效果

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
    let {createApp, h} = VueRuntimeDom
    let App = {
        render(proxy) {
            return h(
                "div",
                {style: {color: "red"}},
                [
                    h(
                        "h2",
                        {style: {color: "blue"}},
                        [
                            "hello world",
                            h(
                                "b",
                                {style: {color: "pink"}},
                                "123"
                            )
                        ]
                    ),
                    "world"
                ])
        }
    }
    let app = createApp(App, {name: "李四", age: 18})
    app.mount("#app")
</script>
</body>
</html>

image-20240202151239249

接下来实现数据更新,自动更新组件

组件更新流程

只触发一次更新

packages/runtime-core/src/index.ts 导入 reactive 内容

export {
    createRender,
} from "./renderer";

export {
    h
} from "./h"

export * from "@vue/reactivity"

然后修改测试的 html 文件,使用 reactive

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
    let {createApp, h, reactive} = VueRuntimeDom
    let App = {
        setup() {
            let state = reactive({
                name: "szx"
            })

            function changeName() {
                state.name = "songzx"
                state.name = "szx"
                state.name = "songzx"
            }

            return () => {
                return h(
                    "div",
                    {style: {color: "red"}, onClick: changeName},
                    [
                        h(
                            "h2",
                            {style: {color: "blue"}},
                            [
                                "hello world",
                                h(
                                    "b",
                                    {style: {color: "pink"}},
                                    "123"
                                )
                            ]
                        ),
                        state.name
                    ])
            }
        },
    }
    let app = createApp(App, {name: "李四", age: 18})
    app.mount("#app")
</script>
</body>
</html>

现在我们在 h 函数中用到了 state.name ,并且点击后出改变数据,此时会触发三次更新

image-20240202160023971

image-20240202160040360

此时,可以使用之前定义过的effect的 scheduler 属性,自定义更新事件

如果 effect 函数的参数中有 scheduler 属性,则会调用 scheduler(),并传入当前要执行的effect

image-20240202160316551

新建 packages/runtime-core/src/scheduler.ts

// 用一个队列来处理多次调用render函数更新组件的问题
let queue = []

export function queueJob(job) {
    if (!queue.includes(job)) {
        queue.push(job)
        queueFlush()
    }
}

let isFlushPending = false

function queueFlush() {
    if (!isFlushPending) {
        isFlushPending = true
        Promise.resolve().then(flushJobs)
    }
}

function flushJobs() {
    isFlushPending = false
    // 进行排序,先调用父组件的更新方法
    queue.sort((a, b) => a.id - b.id)
    for (let i = 0; i < queue.length; i++) {
        const job = queue[i]
        job()
    }
    queue.length = 0
}

然后修改 packages/runtime-core/src/renderer.ts 文件的 setupRenderEffect 方法

// 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
const setupRenderEffect = (instance, container) => {
    // 创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行
    // 只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染
    effect(function componentEffect() {
        // 判断是否是初次渲染
        if (!instance.isMounted) {
            // 初次渲染组件,调用render函数拿到返回值
            let subTree = instance.render.call(instance.proxy, instance.proxy)
            // 调用patch进行页面渲染
            patch(null, subTree, container)
            // 修改是否初次渲染的状态
            instance.isMounted = true
        } else {
            // 更新组件
            console.log("更新")
        }
    }, {
        scheduler: (effect) => {
            queueJob(effect)
        }
    })
}

此时再去点击就会触发一次更新

image-20240202160400217

默认两个元素的比较

packages/runtime-dom/src/nodeOpts.ts

添加 nextSibling 方法

// 这个文件专门存放用于操作节点的各种方法
export const nodeOpts = {
    // 创建一个元素
    createElement: (target) => document.createElement(target),
    // 删除一个元素
    remove: (target) => {
        // 1.先找到这个元素的父元素
        const parent = target.parentNode
        if (parent) {
            parent.removeChild(target)
        }
    },
    // 在指定位置插入一个元素
    insert: (target, parent, anchor = null) => {
        parent.insertBefore(target, anchor)
    },
    // 查找一个元素
    querySelector: (selector) => document.querySelector(selector),
    // 设置一个标签元素的文本内容
    setElementText: (el, text) => el.textContent = text,
    // 创建一个文本元素
    createText: (text) => document.createTextNode(text),
    // 设置文本元素的内容
    setText: (node, text) => node.nodeValue = text,
    // 获取指定元素的下一个兄弟元素
    nextSibling: (el) => el.nextSibling
}

有如下代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let {createApp, h, reactive,ref} = VueRuntimeDom
  let App = {
    setup(){
      let flag = ref(true)

      setTimeout(()=>{
        flag.value = false
      },1000)

      return ()=>{
        return flag.value ?
                h("div",{style:{color:"red"}},h("div","hello word")) :
                h("div",{style:{color:"blue"}},h("div","你好 世界"))
      }
    }
  }
  const app = createApp(App)
  app.mount("#app")
</script>
</body>
</html>

在倒计时一秒后,页面发生变化

flag.value 变化后,会重新触发 effect 方法,此时判断是否初次渲染

packages/runtime-core/src/renderer.ts

修改setupRenderEffect方法中的effect方法,从当前实例中获取上次的VNode,然后调用render函数拿到新的VNode

// 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
const setupRenderEffect = (instance, container) => {
    // 创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行
    // 只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染
    effect(function componentEffect() {
        // 判断是否是初次渲染
        if (!instance.isMounted) {
            // 初次渲染组件,调用render函数拿到返回值
            let subTree = instance.subTree = instance.render.call(instance.proxy, instance.proxy)
            // 调用patch进行页面渲染
            patch(null, subTree, container)
            // 修改是否初次渲染的状态
            instance.isMounted = true
        } else {
            // 更新组件,调用render函数拿到最新的Node节点
            let prevTree = instance.subTree
            let proxyToUse = instance.proxy
            let nextTree = instance.render.call(proxyToUse, proxyToUse)
            patch(prevTree, nextTree, container)
        }
    }, {
        scheduler: (effect) => {
            queueJob(effect)
        }
    })
}

然后调用patch方法进行页面页面更新

packages/runtime-core/src/renderer.ts

patch方法中添加一个判断,判断n1和n2是否相同,如果不相同,表示本次更新的元素完全和上次不一样,所以直接吧上一次的删除掉,重新渲染新的元素,同时获取上一个元素的下一个兄弟元素

const isSameVNodeType = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key
}

const unmount = (n1) => {
    hostRemove(n1.el)
}

const patch = (n1, n2, container, anchor = null) => {
    const {shapeFlag, type} = n2 // 拿到虚拟节点的类型

    // 如果存在n1的情况下,先判断一下n1和n2的类型是否相同,如果不相同,则直接吧n1删除掉
    if (n1 && !isSameVNodeType(n1, n2)) {
        // 获取当前元素的下一个元素,在重新插入的时候根据anchor正确的插入新元素,否则会插入到当前元素的后面
        anchor = hostNextSibling(n1.el)
        unmount(n1)
        n1 = null
    }

    switch (type) {
        case Text:
            // 单独处理文本
            processText(n1, n2, container)
            break
        default:
            if (shapeFlag & ShapeFlags.ELEMENT) {
                // 元素
                processElement(n1, n2, container, anchor)
            } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
                // 组件
                processComponent(n1, n2, container)
            }
    }
}

判断完成之后如果可以进入到processElement方法中,表示两个元素的类型相同,则要开始比较他们的属性和孩子

// 处理元素
const processElement = (n1, n2, container, anchor) => {
    if (n1 === null) {
        // 挂载元素
        mountElement(n2, container)
    } else {
        // 更新元素
        patchElement(n1, n2, container)
    }
}

// 更新元素的属性
const patchProps = (el,oleProps,newProps) => {
    for (const key in newProps) {
        let prev = oleProps[key]
        let next = newProps[key]
        if(prev !== next){
            hostPatchProps(el,key,oleProps[key],newProps[key])
        }
    }

    for (const key in oleProps) {
        if(!(key in newProps)){
            hostPatchProps(el,key,oleProps[key],null)
        }
    }
}

/**
* 比较两个元素的儿子
* 比较儿子有如下四种情况
* 1.老的有儿子,新的没儿子
* 2.老的有儿子,新的有儿子
* 3.老的没儿子,新的没儿子
* 4.老的没儿子,新的有儿子
* 5.新老都是文本
*/
const patchChildren = (n1,n2,el) => {
	console.log(n1,n2,el)
}

// 更新元素
const patchElement = (n1, n2, container) => {
    // 直接复用旧的元素节点
    let el = (n2.el = n1.el)

    // 对比属性
    let oldProps = n1.props
    let newProps = n2.props
    patchProps(el, oldProps, newProps)

    // 对比儿子
    patchChildren(n1,n2,el)
}

测试比较两个不同的元素

let App = {
    setup(){
        let flag = ref(true)

        setTimeout(()=>{
            flag.value = false
        },1000)

        return ()=>{
            return flag.value ?
                h("div",{style:{color:"red"}},h("div","hello word")) :
                h("span",{style:{color:"blue"}},h("div","你好 世界"))
        }
    }
}

此时页面会直接替换

image-20240228205858937

如果元素类型相同,则会比较属性和比较孩子

let App = {
    setup(){
        let flag = ref(true)

        setTimeout(()=>{
            flag.value = false
        },1000)

        return ()=>{
            return flag.value ?
                h("div",{style:{color:"red"}},h("div","hello word")) :
                h("div",{style:{color:"blue"}},h("div","你好 世界"))
        }
    }
}

此时页面只是吧属性更新了一下,内容没有替换

image-20240228210048996

接下来开始实现比较孩子的操作

特殊比较和优化

packages/runtime-core/src/renderer.ts

实现 patchChildren 方法
const unmountChildren = (children) => {
    for (let child of children) {
        unmount(child)
    }
}

const patchKeyedChildren = (c1, c2, el) => {
    let i = 0
    let e1 = c1.length - 1
    let e2 = c2.length - 1

    // 从头开始一个一个比较,遇到不同的就停止比较
    while (i <= e1 && i <= e2) {
        const n1 = c1[i]
        const n2 = c2[i]
        if (isSameVNodeType(n1, n2)) {
            patch(n1, n2, el)
        } else {
            break
        }
        i++
    }

    // 从尾部开始比较,特殊优化,尽可能缩小需要diff的范围
    while (i <= e1 && i <= e2){
        const n1 = c1[e1]
        const n2 = c2[e2]
        if(isSameVNodeType(n1,n2)){
            patch(n1, n2, el)
        }else{
            break
        }
        e1--
        e2--
    }

    // 比较完成后,如果i大于e1表示有新增的部分,老的少,新的多
    if(i > e1){
        if(i <= e2){
            const nextPosition = e2 + 1
            // 确认在那个元素之前插入
            // 判断如果e2 + 1大于新的孩子长度,表示在结尾
            // 否则就在当前e2的下一个元素之前插入
            const anchor = nextPosition < c2.length ? c2[nextPosition].el : null
            while (i <= e2){
                patch(null,c2[i],el,anchor)
                i++
            }
        }
    }else if(i > e2){ // 表示老的多,新的少
       while (i <= e1){
           unmount(c1[i])
           i++
       }
    }else{
        // 乱序比较,采用一个映射表,尽可能复用元素
        let s1 = i
        let s2 = i

        // 这里存放的是新中间的元素的每一个下标
        const keyToNewIndexMap = new Map()
        for (let j = s2; j <= e2; j++) {
            const  childVNode = c2[j]
            keyToNewIndexMap.set(childVNode.key,j)
        }

        // 得到中间的需要对比的元素个数
        const toBePatched = e2 - s2 + 1
        // 创建一个相同长度的数据,存放的是老的孩子的下标,默认都是0
        // 可以通过判断是不是0来确认这个节点是否需要被patch
        const newIndexToOldIndexMap = new Array(toBePatched).fill(0)

        // 去老的里面找,有没有可以复用的
        for (let j = s1; j <= e1; j++) {
            const oldVNode = c1[j]
            // 用老的key去看新的里面有没有这个key
            const newIndex = keyToNewIndexMap.get(oldVNode.key)
            // 如果这个key在新的孩子中没有,则删除掉老的
            if(newIndex === undefined){
                unmount(oldVNode)
            }else{
                // 如果新的孩子在老的孩子里面存在,则拿到老的孩子所对应的下标 + 1
                newIndexToOldIndexMap[newIndex - s2] = j + 1
                // 如果有,则对比新老节点
                patch(oldVNode,c2[newIndex],el)
            }
        }

        // 倒序遍历新的中间节点。从后往前一次插入到页面中
        for (let i = toBePatched - 1; i >= 0 ; i--) {
            console.log(i,'565656')
            // 当前元素的的在c2中的下标
            const currentIndex = i + s2
            // 当前元素在页面中的真实DOM节点
            const child = c2[currentIndex]
            // 当前元素在页面中的真实DOM节点的下一个兄弟节点
            const anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null
            console.log(child,'555')
            // 判断这个元素是否在老的里面存在
            if(newIndexToOldIndexMap[i] === 0){
                patch(null,child,el,anchor)
            }else{
                hostInsert(child.el,el,anchor)
            }
        }
    }
}

/**
 * 比较两个元素的儿子
 * 比较儿子有如下四种情况
 * 1.老的有儿子,新的没儿子
 * 2.老的有儿子,新的有儿子
 * 3.老的没儿子,新的没儿子
 * 4.老的没儿子,新的有儿子
 * 5.新老都是文本
 */
const patchChildren = (n1, n2, el) => {
    let c1 = n1.children
    let c2 = n2.children
    const prevShapeFlag = n1.shapeFlag
    const shapeFlag = n2.shapeFlag

    // 如果新元素是一个文本类型
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 如果老的孩子是一个元素数组,则吧老的孩子全部删除
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            unmountChildren(c1)
        }
        // 如果老的孩子是一个文本类型,则直接更新文本
        if (c1 !== c2) {
            // 直接给当前的el设置文本内容为新的内容
            hostSetElementText(el, c2)
        }
    } else {
        // 新的不是文本,老的是一个数组
        if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                // 两个数组元素比较
                // diff算法的核心
                patchKeyedChildren(c1, c2, el)
            } else {
                // 新的没有儿子,老的有儿子,则把老的儿子全部删除
                unmountChildren(c1)
            }
        } else {
            // 新的不是文本,老的是一个文本
            if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
                hostSetElementText(el, "")
            }
            // 新的是一个元素
            if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
                mountChildren(c2, el)
            }
        }
    }
}
前面相同,后面不同

前面相同,后面不同,从头开始比较

image-20240228220300777
// 从头开始一个一个比较,遇到不同的就停止比较
while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]
    if (isSameVNodeType(n1, n2)) {
        patch(n1, n2, el)
    } else {
        break
    }
    i++
}
后面相同,前面不同
image-20240228222747031
// 从尾部开始比较,特殊优化,尽可能缩小需要diff的范围
while (i <= e1 && i <= e2){
    const n1 = c1[e1]
    const n2 = c2[e2]
    if(isSameVNodeType(n1,n2)){
        patch(n1, n2, el)
    }else{
        break
    }
    e1--
    e2--
}
尾部追加
image-20240302143419175

前后对比完成后,如果发现新的多,则往后面追加

// 比较完成后,如果i大于e1表示有新增的部分,老的少,新的多
if(i > e1){
    if(i <= e2){
        const nextPosition = e2 + 1
        // 确认在那个元素之前插入
        // 判断如果e2 + 1大于新的孩子长度,表示在结尾
        // 否则就在当前e2的下一个元素之前插入
        const anchor = nextPosition < c2.length ? c2[nextPosition].el : null
        while (i <= e2){
            patch(null,c2[i],el,anchor)
            i++
        }
    }
}
尾部删除
image-20240302143611218

前后对比完成后,如果发现新的少,老的多,则删除老的多余节点

else if(i > e2){ // 表示老的多,新的少
   while (i <= e1){
       unmount(c1[i])
       i++
   }
}
最终进入乱序比较
image-20240302143739404
else{
    // 乱序比较,采用一个映射表,尽可能复用元素
    let s1 = i
    let s2 = i

    // 这里存放的是新中间的元素的每一个下标
    const keyToNewIndexMap = new Map()
    for (let j = s2; j <= e2; j++) {
        const  childVNode = c2[j]
        keyToNewIndexMap.set(childVNode.key,j)
    }

    // 得到中间的需要对比的元素个数
    const toBePatched = e2 - s2 + 1
    // 创建一个相同长度的数据,存放的是老的孩子的下标,默认都是0
    // 可以通过判断是不是0来确认这个节点是否需要被patch
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0)

    // 去老的里面找,有没有可以复用的
    for (let j = s1; j <= e1; j++) {
        const oldVNode = c1[j]
        // 用老的key去看新的里面有没有这个key
        const newIndex = keyToNewIndexMap.get(oldVNode.key)
        // 如果这个key在新的孩子中没有,则删除掉老的
        if(newIndex === undefined){
            unmount(oldVNode)
        }else{
            // 如果新的孩子在老的孩子里面存在,则拿到老的孩子所对应的下标 + 1
            newIndexToOldIndexMap[newIndex - s2] = j + 1
            // 如果有,则对比新老节点
            patch(oldVNode,c2[newIndex],el)
        }
    }

    // 倒序遍历新的中间节点。从后往前一次插入到页面中
    for (let i = toBePatched - 1; i >= 0 ; i--) {
        // 当前元素的的在c2中的下标
        const currentIndex = i + s2
        // 当前元素在页面中的真实DOM节点
        const child = c2[currentIndex]
        // 当前元素在页面中的真实DOM节点的下一个兄弟节点
        const anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null
        console.log(child,'555')
        // 判断这个元素是否在老的里面存在
        if(newIndexToOldIndexMap[i] === 0){
            patch(null,child,el,anchor)
        }else{
            hostInsert(child.el,el,anchor)
        }
    }
}
测试比较结果
let App = {
    setup() {
        let flag = ref(true)

        setTimeout(() => {
            flag.value = false
        }, 1000)

        return () => {
            return flag.value ?
                h("div", {style: {color: "red", "font-size": "25px"}}, [
                    h("li", {key: "a"}, "a"),
                    h("li", {key: "b"}, "b"),
                    h("li", {key: "e"}, "e"),
                    h("li", {key: "f"}, "f"),
                    h("li", {key: "g"}, "g"),
                    h("li", {key: "l"}, "l"),
                    h("li", {key: "k"}, "k"),
                    h("li", {key: "c"}, "c"),
                    h("li", {key: "d"}, "d"),
                ]) :
                h("div", {style: {color: "blue"}}, [
                    h("li", {key: "a"}, "a"),
                    h("li", {key: "b"}, "b"),
                    h("li", {key: "g"}, "g"),
                    h("li", {key: "e"}, "e"),
                    h("li", {key: "f"}, "f"),
                    h("li", {key: "h"}, "h"),
                    h("li", {key: "c"}, "c"),
                    h("li", {key: "d"}, "d"),
                ])
        }
    }
}
const app = createApp(App)
app.mount("#app")

diffimg1

观察动画发现虽然结果正确了,但是 g、e、f、h 四个节点都会发生一次变化。在老节点中,e、f 两个节点是连续在一起的,新的节点中 e、f 也是在一起的,我们只需要吧 g 直接插入到 e 的前面即可。

这个逻辑优化就是查找最长递增子序列。下面我们来实现这个优化

最长递增子序列

function getSequence(arr) {
  const p = arr.slice()                 //  保存原始数据
  const result = [0]                    //  存储最长增长子序列的索引数组
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]     //  j是子序列索引最后一项
      if (arr[j] < arrI) {              //  如果arr[i] > arr[j], 当前值比最后一项还大,可以直接push到索引数组(result)中去
        p[i] = j                        //  p记录第i个位置的索引变为j
        result.push(i)
        continue
      }
      u = 0                             //  数组的第一项
      v = result.length - 1             //  数组的最后一项
      while (u < v) {                   //  如果arrI <= arr[j] 通过二分查找,将i插入到result对应位置;u和v相等时循环停止
        c = ((u + v) / 2) | 0           //  二分查找 
        if (arr[result[c]] < arrI) {
          u = c + 1                     //  移动u
        } else {
          v = c                         //  中间的位置大于等于i,v=c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]          //  记录修改的索引
        }
        result[u] = i                   //  更新索引数组(result)
      }
    }
  }
  u = result.length
  v = result[u - 1]
  //把u值赋给result  
  while (u-- > 0) {                     //  最后通过p数组对result数组进行进行修订,取得正确的索引
    result[u] = v
    v = p[v];                        
  }
  return result
}

以数组[2, 11, 6, 8, 1]为例:最终输出的结果为[0, 2, 3],表示最强增长序列的索引分别是0, 2 ,3;对应的值是2,6,8。换句话说,在这个数组中最长连续增长的值就是数组中的2,6,8三个元素。

费了这么大的力气,使用这个方法的目的是什么呢?

Vue2在DOM-Diff过程中,优先处理特殊场景的情况,即头头比对,头尾比对,尾头比对等。

而Vue3在DOM-Diff过程中,根据 newIndexToOldIndexMap 新老节点索引列表找到最长稳定序列,通过最长增长子序列的算法比对,找出新旧节点中不需要移动的节点,原地复用,仅对需要移动或已经patch的节点进行操作,最大限度地提升替换效率,相比于Vue2版本是质的提升!

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

有了这个方法后我们就可以利用这个算法得出那些节点是可以直接跳过的

修改 else 方法

else{
    // 乱序比较,采用一个映射表,尽可能复用元素
    let s1 = i
    let s2 = i

    // 这里存放的是新中间的元素的每一个下标
    const keyToNewIndexMap = new Map()
    for (let j = s2; j <= e2; j++) {
        const  childVNode = c2[j]
        keyToNewIndexMap.set(childVNode.key,j)
    }

    // 得到中间的需要对比的元素个数
    const toBePatched = e2 - s2 + 1
    // 创建一个相同长度的数据,存放的是老的孩子的下标,默认都是0
    // 可以通过判断是不是0来确认这个节点是否需要被patch
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0)

    // 去老的里面找,有没有可以复用的
    for (let j = s1; j <= e1; j++) {
        const oldVNode = c1[j]
        // 用老的key去看新的里面有没有这个key
        const newIndex = keyToNewIndexMap.get(oldVNode.key)
        // 如果这个key在新的孩子中没有,则删除掉老的
        if(newIndex === undefined){
            unmount(oldVNode)
        }else{
            // 如果新的孩子在老的孩子里面存在,则拿到老的孩子所对应的下标 + 1
            newIndexToOldIndexMap[newIndex - s2] = j + 1
            // 如果有,则对比新老节点
            patch(oldVNode,c2[newIndex],el)
        }
    }

    // 求最长递增子序列
    // 比如:newIndexToOldIndexMap 的结果是 5, 3, 4, 0
    // 则对应的最长递增子序列是 3, 4 对应的下标是 1, 2。意思就是我得到了下标为1和2的节点不需要移动
    let increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
    // 用一个遍历记录最后以为的下标
    let j = increasingNewIndexSequence.length - 1

    // 倒序遍历新的中间节点。从后往前一次插入到页面中
    for (let i = toBePatched - 1; i >= 0 ; i--) {
        // 当前元素的的在c2中的下标
        const currentIndex = i + s2
        // 当前元素在页面中的真实DOM节点
        const child = c2[currentIndex]
        // 当前元素在页面中的真实DOM节点的下一个兄弟节点
        const anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null
        // 判断这个元素是否在老的里面存在
        if(newIndexToOldIndexMap[i] === 0){
            // 直接吧这个新的放进去
            patch(null,child,el,anchor)
        }else{
            // 判断一下当前需要这个节点的下标是否在最长递增子序列里面,如果不存在,则这个节点需要移动
            if(i !== increasingNewIndexSequence[j]){
                hostInsert(child.el,el,anchor)
            }else{
                // 否则不需要移动这个节点,并且让j--,继续判断前一个
                j--
            }
        }
    }
}

查看结果

getSequence

仔细观察 e、f 节点,可以发现 e、f 并没有发生移动,这一点对比vue2的 diff 算法是质的变化,页面渲染效率提高很多。

组件渲染流程图

image-20240303164900824

组件更新流程图

image-20240304192359101

Vue3的模板编译过程的优化

blockTree和patchFlag

在这个网址中可以查看vue3代码编译后的结果

https://template-explorer.vuejs.org/

image-20240304205511467

复制这段编译后的代码到vue3的项目中去运行

import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(), _createElementBlock(_Fragment, null, [
        _createElementVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */),
        _createElementVNode("span", null, [
            _createElementVNode("a", { href: _ctx.dyHref }, _toDisplayString(_ctx.age), 9 /* TEXT, PROPS */, ["href"])
        ])
    ], 64 /* STABLE_FRAGMENT */))
}


console.log(render({
    name:"李四",
    age:18,
    href:"http://baidu.com"
}))

可以看到收集到了两个动态节点

image-20240304205652323

vue3在模板更新时加入了 blockTree 和 patchFlag

  • blockTree 作用是收集了动态的节点,放在了 dynamicChildren
  • 在进行diff对比的过程中去判断如果这个节点的dynamicChildren不为空,则遍历dynamicChildren中的节点进行比对,而不再进行全量diff
  • 这样效率大大提高

编译过程

  • 先将模板进行分析,生成对应的ast数。ast数是一个大对象,来描述语法的
  • 然后做转化流程 transform -> 对动态节点做一些标记,例如:指令、插槽、事件、属性…,这些标记会被记录到 patchFlag 属性中,对应不同的值
  • 然后diff过程中会根据 patchFlag 的值,知道这个节点具体是哪个东西发生了改变,从而进行精准diff
  • 最后代码生成 codegen -> 生成最终代码

blokeTree

  • diff 算法的特点是递归遍历,每次比较同一层,然后深度遍历比较所有,这样效率并不好
  • block 的作用就是为了收集动态节点,将树的递归拍平成了一个数组,只遍历收集起来的节点
  • 在 createVnode 的时候,就会判断这个节点是不是动态的,然外层的 block 收集起来
  • 目的是为了 diff 时只 diff 动态节点

如果会影响DOM结构的,例如 v-if v-else 都会被标记成 block 节点

父亲也会收集儿子的 block

最终多个 block 构成一个 blockTree

patchFlags

  • 对不同的类型进行描述

性能优化

  • 每次重新渲染时,都要创建一个虚拟节点,调用的是 createVnode 方法
  • 当创建多个重复的静态节点时,会做静态提升,静态节点进行提取

Vue3和Vue2的对比

  • 响应式原理:由 defineProperty 替换成了 proxy
    • defineProperty 会递归对所有属性进行代理,而 proxy 只会在访问到这个属性时返回代理结果
  • vue3 diff算法(可以根据 patchFlag 做 diff)vue2 的 diff 是全量 diff。其中 vue3的diff算法中用到了一个最长递增子序列的算法,如果有相邻的重复节点,则会跳过这些节点,提高性能
  • 编码层面的变化:vue2采用 optionsAPI,vue3是 compositionAPI
  • vue3支持同时存在多个根节点,因为如果是多个根节点的情况,会自动的在最外面添加一个 Fragment
  • vue3源码全面采用ts编写,vue2是flow,类型退到不准确
  • vue3支持自定义渲染器 createRender() 传入自己的渲染方法,可以编译到不同平台

组件生命周期原理

实现原理

在组件的不同阶段会触发不同的钩子。当页面初次渲染和更新都会走 patch 方法,然而在初次渲染时,调用 patch 之前,会先获取当前组件的实例,去实例上获取声明的生命周期钩子函数,判断是否存在 onBeforeMount 钩子,如果有,就会先调用 onBeforeMount 方法,然后 patch 函数执行完毕后,调用 onMounted 钩子。然后组件更新时也会走 patch 方法,更新之前会判断是否存在 onBeforeUpdated 钩子,如果有,就会先调用 onBeforeUpdated 方法,更新完毕后,在调用 onUpdated 方法。

这里的核心是如何确保当前组件的声明周期钩子函数中获取到的实例是当前组件的。例如当父组件内包含一个子组件时,一定是先执行子组件的钩子,再执行父组件的钩子。

实现代码

下面来实现代码

找到 packages/runtime-core/src/component.ts 文件中调用 setup 的 setupStatefulComponent 函数

添加下面代码,分别导出

  • currentInstance
  • getCurrentInstance
  • setCurrentInstance
// 定义变量保存当前的实例
export let currentInstance = null
export const getCurrentInstance = () => {
    return currentInstance
}
export const setCurrentInstance = (instance) => {
    currentInstance = instance
}

function setupStatefulComponent(instance) {
    let Component = instance.type
    let {setup} = Component

    // 判断组件上是否有setup函数
    if (setup) {
        // 设置当前组件的实例
        currentInstance = instance
        // 组件中定义的setup函数,同时传递参数
        // 第一个参数是定义的props属性数据
        // 第二个是定义的context上下文对象
        // 上下文对象中包含:attrs、props、slots、emit、expose
        let setupContext = createSetupContext(instance)
        // 拿到setup函数的返回值进行不同的处理
        let setupResult = setup(instance.props, setupContext)
        // setup函数执行完毕后,将当前实例清空
        currentInstance = null
        handleSetupResult(instance, setupResult)

    } else {
        // 组件中没有定义setup函数,则直接获取render函数
        finishComponentSetup(instance)
    }

}

在执行 setup 之前将当前的实例赋给 currentInstance,然后 setup 函数执行完毕后清空 currentInstance

然后新建 packages/runtime-core/src/apiLifecycle.ts 文件

import {currentInstance, setCurrentInstance} from "./component";

const enum LifeCycleHooks {
    BEFORE_MOUNT = "bm",
    MOUNTED = "m",
    BEFORE_UPDATE = "bu",
    UPDATED = "u"
}

const createHook = (lifeCycle) => (hooke, target = currentInstance) => {
    injectHook(lifeCycle, hooke, target)
}

function injectHook(type, hooke, target) {
    if (!target) {
        console.warn("当前没有实例,无法执行钩子函数")
    } else {
        const hooks = target[type] || (target[type] = [])
        /**
         * 这里的hook就是真正的钩子函数
         * onMounted(()=>{
         *     console.log("onMounted")
         * })
         * onMounted 方法第一个参数是一个函数,这个函数会被hooke变量接收
         */

        const wrap = () => {
            setCurrentInstance(target)
            hooke.call(target)
            setCurrentInstance(null)
        }
        hooks.push(wrap)
    }
}

// 执行钩子函数
export const invokeArrayFns = (fns) => {
    for (let i = 0; i < fns.length; i++) {
        fns[i]()
    }
}


export const onBeforeMount = createHook(LifeCycleHooks.BEFORE_MOUNT)

export const onMounted = createHook(LifeCycleHooks.MOUNTED)

export const onBeforeUpdate = createHook(LifeCycleHooks.BEFORE_UPDATE)

export const onUpdated = createHook(LifeCycleHooks.UPDATED)

这个文件中的 injectHook 方法巧妙的利用闭包保存了每个组件自己的实例,并且在执行钩子函数之前会调用 setCurrentInstance 方法保存当前实例,这样组件钩子内去调用 getCurrentInstance 方法是会准确的获取到当前的实例

并且将每个钩子名称做了简化,以数组的形式保存到当前实例中

实例上了钩子函数后,下一步就是要在合适的时机去执行这些钩子

找到 packages/runtime-core/src/renderer.ts 文件的 createRender 方法中的 setupRenderEffect 方法,这个方法中包含了挂载前,挂载完成,更新前,更新完成这四个时机

// 拿到实例上的render,创建effect,当数据发生变化时,重新触发effect,并且调用render函数
const setupRenderEffect = (instance, container) => {
    // 创建一个effect,在effect中调用render方法,这样render方法会拿到数据会收集这个effect,属性更新时effect会重新执行
    // 只要effect重新执行,render方法就会重新调用,这样页面就会重新渲染
    effect(function componentEffect() {
        // 判断是否是初次渲染
        if (!instance.isMounted) {
            let {bm, m} = instance
            // 更新之前调用组件的onBeforeMount钩子
            if (bm) {
                invokeArrayFns(bm)
            }
            // 初次渲染组件,调用render函数拿到返回值
            let subTree = instance.subTree = instance.render.call(instance.proxy, instance.proxy)
            // 调用patch进行页面渲染
            patch(null, subTree, container)
            // 修改是否初次渲染的状态
            instance.isMounted = true
            // 页面渲染完毕后调用组件的onMounted钩子
            if (m) {
                invokeArrayFns(m)
            }
        } else {
            let {bu, u} = instance
            // 组件更新之前执行组件的onBeforeUpdate钩子
            if (bu) {
                invokeArrayFns(bu)
            }
            // 更新组件,调用render函数拿到最新的Node节点
            let prevTree = instance.subTree
            let proxyToUse = instance.proxy
            let nextTree = instance.render.call(proxyToUse, proxyToUse)
            patch(prevTree, nextTree, container)
            // 组件更新之后执行组件的onUpdate钩子
            if (u) {
                invokeArrayFns(u)
            }
        }
    }, {
        scheduler: (effect) => {
            queueJob(effect)
        }
    })
}

最后导出方法

packages/runtime-core/src/index.ts

export {
    createRender,
} from "./renderer";

export {
    h
} from "./h"

export {
    onBeforeMount,
    onMounted,
    onBeforeUpdate,
    onUpdated
} from "./apiLifecycle"

export {getCurrentInstance} from "./component"

export * from "@vue/reactivity"

测试生命周期

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>
</body>
<script src="../node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
<script>
    let {
        createApp,
        h,
        reactive,
        ref,
        onBeforeMount,
        onMounted,
        onBeforeUpdate,
        onUpdated,
        getCurrentInstance
    } = VueRuntimeDom

    let flag = ref(true)
    setTimeout(() => {
        flag.value = false
    }, 1000)

    let App = {
        setup() {
            // 在每个生命周期调用时,就已经拿到了当前组件的实例
            onBeforeMount(() => {
                console.log("onBeforeMount")
            })
            onMounted(() => {
                // 可以在生命周期内获取当前组件的实例
                const instance = getCurrentInstance()
                console.log(instance)
                console.log("onMounted")
            })
            onBeforeUpdate(() => {
                console.log("onBeforeUpdate")
            })
            onUpdated(() => {
                console.log("onUpdate")
            })


            return () => {
                return h("div", flag.value)
            }
        }
    }
    const app = createApp(App)
    app.mount("#app")
</script>
</html>

image-20240308091702528

image-20240308091723623

通过打印,可以发现组件的实例上已经添加了不同生命周期中的不同钩子

  • 13
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值