手撕 vue3

154 阅读35分钟

vue3 对比 vue2

架构优化

  • vue3 源码采用 monorepo 方式进行管理,将模块拆分到 package 目录中
  • vue3 采用 ts 开发,增强类型检测,vue2 则采用 flow
  • vue3 的性能优化,采用函数式编程(不像 vue2 把所有属性挂到原型或者 this 上),支持 tree-shaking(基于 rollup),没用到的功能直接优化不打包
  • Vue2 后期引入RFC , 使每个版本改动可控 rfcs

代码优化

  • Vue3 劫持数据采用 proxy,且 proxy 使用了懒递归劫持(内层对象取值再劫持),vue2 劫持数据采用 defineProperty,defineProperty 有性能问题和缺陷
  • Vue3 中对模板编译进行了优化,编译时生成了 Block tree,可以对子节点的动态节点进行收集,可以减少比较,并且采用了 patchFlag 标记动态节点
  • Vue3 采用了 compositionApi 进行组织功能(组合式 API),解决反复横跳,优化复用逻辑(mixin 带来的数据来源不清晰、命名冲突等),相比 vue2 的 optionApi 组织方式,类型推断更加方便
  • 增加了 Fragment(多个根节点,原理是会在外面包一层 div), Teleport,Suspense组件

Vue2 采用的是 optionApi 组织方式,声明数据是在 data 对象中,声明计算属性是在 computed 对象中,声明 watch 是在 watch 对象中,声明 method 是在 method 对象中,这种写法上的拆分给源码带来的后果是,为了实现一个点击功能,我可能需要在多个 options 配置中反复横跳取相应的数据来实现功能,而 vue3 的 compositionApi,它能让我们使用的时候,把具体功能封装为具体函数,所有的逻辑聚焦在函数中,源码中去收集、处理也就比较方便。

vue3 架构分析 & 环境搭建

Monorepo 介绍

Monorepo 是管理项目代码的一个代码管理方式,指在一个项目仓库(repo)中管理多个模块/包(package)

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

缺点:仓库体积会变大。

Monorepo 是把所有相关的 package 都放在一个仓库里进行管理,每个 package 独立发布。例如:React, Angular, Babel, Jest, Umijs, Vue3 等,拓展说下,我们常常使用 lerna 工具 + yarn workspace 来管理 monorepo 代码组织方式中不同的包,具体参阅 Lerna 管理 Monorepo 项目,不过在 Vue3 中,我们使用 rollup + yarn workspace 来管理 Monorepo

Vue3 源码目录结构

参考 (Vue3 代码仓库)[github.com/vuejs/vue-n…] 中的目录结构:

  • 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 仓库中分了这么多包统一管理,其中每个包都可以独立被引用使用。

vue 包中包含的核心模块:

依赖包分析

我们新建项目 ys-vue3/, 以下我们简称 ys-vue3 为根目录。

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

初始化 yarn

根目录下进行 yarn 初始化。

yarn init -y

我们需要修改下 package.json

{
  "name": "reactivity",
  "version": "1.0.0",
  "private": true,  // 代表该包是用来管理下面的包的,不会发布到 npm 上
  "main": "src/index.js",
  "license": "MIT" // MIT,自动添加的,标识开源
}

创建 packages & workspace 配置

根目录下创建 packages 包,用于存放所有子包,修改 package.json,写明当前的工作目录在哪里。

{
  "name": "ys-vue3",
  "version": "1.0.0",
  "private": true,
  "workspaces":[
    "packages/*"  // 当前的工作目录                         
  ],
  "main": "src/index.js",
  "license": "MIT"
}

这里,我们先来实现 packages/shared 和 packages/reactivity 两个包(其他包依赖这两个包),新建包并初始化 yarn,目录如下:

- package.json
- packages
	- reactivity
		- package.json
		- src
			- index.ts  # reactivity 包的入口文件
	- shared
		- package.json
		- src
			- index.ts  # shared 包的入口文件

修改 reactivity/package.json 和 shared/package.json 的 name 字段,我们增加命名空间,可以随便起,这里我们叫 @ys-vue3

{
  "name": "@ys-vue3/reactivity",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT"
}

{
  "name": "@ys-vue3/shared",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT"
}

配置 ts & 第三方包安装

根目录下安装依赖包。

 yarn add typescript

此时会报错,因为我们在根目录 package.json 中声明了 workspaces,此时根目录装包,它会警告我们这样装的包会被 workspack/ 下的包共享而不是安装到某个包下面。

An unexpected error occurred: "Running this command will add the dependency to the workspace root rather than workspace itself, which might not be what you want - if you really meant it, make it explicit by running this command again with the -W flag (or --ignore-workspace-root-check).".

根据提示,我们增加 —W 参数或者 --ignore-workspace-root-check

 yarn add typescript -W

根目录创建 tsconfig.json 文件

npx tsc --init // npx 表示执行 node_modules/.bin/tsc 而不去全局找

修改ts.config.js

{
  "compilerOptions": {                      
    "target": "es5",                                
    "module": "ESNEXT", // 浏览器用的,这里把 commonjs -> ESNEXT  
    "sourceMap": true, // 打开 sourceMap                         
    "strict": true,                                 
    "esModuleInterop": true,                        
    "skipLibCheck": true,                           
    "forceConsistentCasingInFileNames": true        
  }
}

安装其他包

yarn add rollup-plugin-typescript2 rollup-plugin-typescript2 @rollup/plugin-node-resolve execa -W -D

rollup 打包脚本构建

根目录下增加 scripts 文件夹,新增构建文件 scripts/build.js

- package.json
- packages
	- reactivity
		- package.json
		- src
			- index.ts  # reactivity 包的入口文件
	- shared
		- package.json
		- src
			- index.ts  # shared 包的入口文件
- scripts
	- build.js  # rollup 打包脚本

这时候会有个问题,我的子包是可以丢出去给别人用的,可能在 node 环境、浏览器环境也可能仅仅一个 script.src 标签引了一下,那我们该采用何种方式来打包它?这里就需要我们给子包增加个自定义的打包配置,就加到子包的 package.json 中好了,注意,shared 是子包之间公用的模块,不会被用户引入到 script 标签,这里不需要把 shared 打包出 iife 文件。

// reacticity/package.json

{
  "buildOptions": {
    "name": "VueReactivity", // 代表是 vue 响应式模块的打包配置
    "formats": [
      "esm-bundlers", // es 语法规范, export r = xxx;
      "cjs", // commonJS 规范,exports.r = xxx;
      "global" // iife 规范,兼容 script 标签直接引语的自执行函数
    ]
  }
}
// shared/package.json

{
  "buildOptions": {
  	"name": "shared",
    "formats": [
      "esm-bundlers",
      "cjs"
    ]
  }
}

好了,准备工作做完了,我们可以开始编写 rollup 打包文件了。

修改 scripts/build.js

scripts/build.js
// 这里是我们要针对 monorepo 进行编译的项目,需要把每个子包的 ts 文件打成 js 
const fs = require('fs');
const execa = require('execa'); // 可以理解成可以打开一个进程去做打包操作

// 筛选 packages 中的子包(是文件夹就默认是子包哦)
const dirs = fs.readdirSync('packages').filter(p => {
  if (!fs.statSync(`packages/${p}`).isDirectory()) {
    return false;
  }
  return true;
});

// 并行打包所有文件夹 
async function build(target) { // rollup -c -environment TARGET=shared
  // -c 表示使用配置文件 rollup.config.js
  // --environment 环境变量,叫 Target: xxx (比如 Target:shared)
  // 子进程的输出 需要在父进程中打印,需要配置 stdio: 'inherit' 
  await execa('rollup', ['-c', '--environment', `TARGET:${target}`], { stdio: 'inherit' }); 
}

async function runParallel(dirs, iterFn) { // 并发去打包,每次打包都调用 build 方法
  let result = [];

  for (let item of dirs) {
    // 每个 build 都会返回一个 promise
    result.push(iterFn(item)); 
  }

  return Promise.all(result); // 存储打包时的 promise,等待所有全部打包完毕后,调用成功
}

runParallel(dirs, build).then(() => {
  console.log('成功')
})


新建 rollup.config.js

rollup.config.js
// 一个包要打包多个格式 esModule  commonjs  iife 
import ts from 'rollup-plugin-typescript2'; // 解析ts插件
import resolvePlugin from '@rollup/plugin-node-resolve'; // 解析第三方模块
import path from 'path'; // 可以处理路径

// 获取packages目录
let packagesDir = path.resolve(__dirname, 'packages'); // 获取 packages 文件夹的绝对路径
let packageDir = path.resolve(packagesDir, process.env.TARGET); // 获取当前要打包的子包绝对路径

// 获取子包 package.json 绝对路径
const resolve = p => path.resolve(packageDir, p); // 根据当前需要打包的路径来解析

const pkg = require(resolve('package.json')); // 表示我要引用这个 json 文件

const packageOptions = pkg.buildOptions; // 拿到我们自定义的打包配置
const name = path.basename(packageDir); // 获取这个目录的最后一个名字

// 用户使用可能有三种方式,import/require/window.xxx
// 需要输出三种文件
const outputConfig = {
  'esm-bundlers': {
    // 输出打包文件的路径
    file: resolve(`dist/${name}.esm-bundler.js`),
    format: 'es'
  },
  'cjs': {
    file: resolve(`dist/${name}.cjs.js`),
    format: 'cjs'
  },
  'global': {
    file: resolve(`dist/${name}.global.js`),
    format: 'iife'
  }
}
function createConfig(format, output) {
  output.name = packageOptions.name; // 用于 iife 在 window 上挂载的属性
  output.sourcemap = true; // 稍后生成 sourcemap

  return {
    input: resolve(`src/index.ts`), // 打包的入口 
    output,
    plugins: [
      ts({ // ts 编译的时候用的文件是哪一个
        tsconfig: path.resolve(__dirname, 'tsconfig.json') // 当前目录下(主包路径下)
      }),
      resolvePlugin()
    ]
  }
}

// 根据用户提供的 formats 选项,去我们自己的配置里取值进行生产配置文件
export default packageOptions.formats.map(format => createConfig(format, outputConfig[format]))


为了打包不报错,修改 packages/reactivity/src/index.ts

let l = 1;

export {
  l
}

为了打包不报错,修改 packages/shared/src/index.ts

let r = 1;

export {
  r
}

根目录 package.json 添加命令

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

这样执行 npm run build,就可以并行打包啦,打包成功会每个子包中都会生成 dist 目录,里面有各个环境的代码,这里拿 shared 子包来举例。

- shared
	- shared.cjs.js
	- shared.cjs.js.map
	- shared.esm-bundler.js
	- shared.esm-bundler.js.map

开发环境打包脚本构建

但是我们总不能改一句代码 build 一下吧,所以要有个开发环境下使用的打包方案。

新建 scripts/dev.js

const execa = require('execa');

async function build(target){ 
    await execa('rollup',['-cw','--environment',`TARGET:${ target }`],{ stdio:'inherit' }); 
}

// 比如我就想观察 reactivity 包内的改变
build('reactivity');

修改根目录下 package.json

"scripts": {
	"dev": "node scripts/dev.js" // 新增 dev 命令
}

启动 npm run dev,会打包并观察 reactivity 包内文件的变化,修改文件则自动打包了,这样,环境搭建就基本完成了,接下来我们来实现 reactivity 包吧。

实现 reactivity 中的响应式原理

试用 vue3 中的 reactivity 模块

根目录下创建 examples 文件夹,创建 examples/1.index.html

- examples
	- index.html

初始化 yarn,并安装 @vue/reactivity 包

yarn init -y 

// 这样会安装 @vue 包,其内部有两个子包 reactivity 和 shared
// 而且子包内部 dist 目录下有各个环境的打包代码
yarn add @vue/reactivity 

examples/1.index.html 引入响应式包

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
  <script src="node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
  <script>
    // VueReactivity 就是 iife 结果的变量 
    // 也就是 reactivity/package.json 中 buildOptions.name 字段
    let { reactive, shallowReactive, readonly, shallowReadonly, effect } = VueReactivity;
  </script>
</body>
</html>

reactive & effect

reactive 用于深度劫持对象。

let { reactive, effect } = VueReactivity;
let school = { name: 'ys', age: 18 }

// reactive 返回一个深度劫持的 proxy 对象, 当我在 effect 中从一个被劫持对象上取值的时候会进行依赖收集,当赋值的时候,会重新执行 effect
let proxySchool = reactive(school); 

// 刚开始 effect 会默认执行,执行时会收集属性的依赖,比如这里 name 和 age 都收集了 effect
// 稍后更新 name 或 age 时,会触发 effet 方法
effect(() => {
  // 这里必须取的是 proxySchool 的属性,才能被收集,取 school 属性可不行
  app.innerHTML = proxySchool.name + ':' + proxySchool.age
});

setTimeout(() => {
  // 1s 后,页面更新
  proxySchool.name = 'ys2';
}, 1000);
  • reactive 返回一个 proxy 对象,对源对象做了劫持。
  • effect 默认执行一次,在其中如果使用了被劫持的对象的属性,则会被该属性收集,当被收集的属性更新,会再次执行该 effect 方法。

shallowReactive

shallowReactive 用于浅度劫持对象。

    let { reactive, shallowReactive, readonly, shallowReadonly, effect } = VueReactivity;
    let school = { name: 'ys', age: 18, adress: { city: 123 } }

    let proxySchool = shallowReactive(school);  // 浅劫持

    effect(() => {
      app.innerHTML = proxySchool.name + ':' + proxySchool.adress.city
    });

    setTimeout(() => {
      // 1s 后,页面无更新
      proxySchool.adress.city = 234;
    }, 1000);
  • 浅劫持只代理第一层对象,不会检测到第二层对象的属性改变哦

readonly

readonly 用来表示对象只读,不能更改。

let { readonly, effect } = VueReactivity;
let school = { name: 'ys', age: 18, adress: { city: 123 } }

let proxySchool = readonly(school);  // 浅劫持

effect(() => {
  console.log('aowu'); // 仅在初始化打印一次
  app.innerHTML = proxySchool.name + ':' + proxySchool.adress.city
});

setTimeout(() => {
  // 1s 后,控制台打印警告,页面无更新
  proxySchool.name = 'ys2';
}, 1000);
  • readonly 的对象会被代理,但是不进行依赖收集(不收集 effect),可以节约性能
  • readonly 不能被更改,修改不生效且控制台打印警告

shallowReadonly

shallowReadonly 用来表示对象第一层只读,不能更改,因为外层没有收集依赖,虽然里层能改,但是也不会触发收集到的 effect 更新,所以这里没法用代码去体现。

图示 reactivity 响应式原理

实现 reactivity 响应式原理

新建 examples/2.index.html,引入我们自己的包

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
  <script src="../node_modules/@ys-vue3/reactivity/dist/reactivity.global.js"></script>
  <script>
    let { reactive, shallowReactive, readonly, shallowReadonly, effect } = VueReactivity;
  </script>
</body>
</html>

修改 ts.config.js 这里暂且把 ts 严格检测关闭,我们调试过程中允许 any,并且完善解析包路径,使得在 reactivity 包中使用 shared 包的方法。

{
  "compilerOptions": {                      
    // ...                       
    "strict": false, // 关闭严格模式,方便调试
    "moduleResolution": "node", // 包引用的方式采用 node 方式
    "baseUrl": "./", // 解析 node 包的 base 目录
    "paths": {
      "@ys-vue3/*": ["packages/*/src"] // 引用 @ys-vue3 则去 packages/*/src 下寻找
    },                                 
	// ...     
  }
}

修改 reactivity/src/index.ts

export * from './reactive';

新建 reactivity/src/reactive.ts

  • 如果非对象,不代理直接返回该值类型
  • 对象则使用 Proxy 代理(new Proxy),然后把源对象作为 key,代理对象 proxy 作为 val,存储到 weakMap 中(为了对象置空自动释放内存,所以选择 weakMap),返回 proxy 对象。
  • 下次代理前判断 weakMap 中是否代理过该对象,代理过直接返回上次处理过的代理对象。
reactivity/src/reactive.ts
import { isObject } from "@ys-vue3/shared";
import { mutableHandler, readonlyHandlers, shallowReactiveHandler, shallowReadonlyHandlers } from "./baseHandlers";

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

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

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

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

// weakmap 记录对象是否被代理过(防止多次代理)
// 如果后续对象置为 null,则自动回收,不需要手动管理缓存表
const reactiveMap = new WeakMap(); 
/**
 * 代理函数
 * @param target 代理的目标 
 * @param isReadonly 是否只读 
 * @param baseHandler 针对不同的方式创建不同的代理对象,就是 Proxy 的第二个参数
 * @returns 代理后的 Proxy 对象
 */
function createReactiveObject(target, isReadonly, baseHandler) {
   // new Proxy(target, baseHandler)
    if (!isObject(target)) return target;

   const existProxy = reactiveMap.get(target); // 是否代理过

   // 之前代理过则直接返回上一次的结果
   if (existProxy) return existProxy; 

   // 如果是对象, 则返回代理后的 proxy 对象
   const proxy = new Proxy(target, baseHandler);

   reactiveMap.set(target, proxy);

   return proxy;
}


新增 reactivity/src/baseHandlers.ts(实现 set get 劫持方法)

  • get 拦截器中,劫持第一层对象(不管是只读还是 reactive),如果非浅劫持,在属性读取的时候把深层对象递归劫持(懒劫持),注意,Vue3 劫持的是对象本身。
  • 区分不同代理类型,提供 get set 函数,比如只读类型的 set 仅仅打印了一个警告⚠️
  • set 拦截器中,会验证设置的 key 在源对象中是否存在(新增还是修改),这里有个特殊情况,数组 push 会触发两次 set(index 赋值和 length 修改),我们判断了数组而且更改的 key 为数字时(下标),跟数组长度做对比,如果小于数组长度则是修改,否则为更新,普通对象判断是否存在该 key 即可
reactivity/src/baseHandlers.ts
import { extend, hasChanged, hasOwn, isArray, isIntegerKey, isObject } from "@ys-vue3/shared";
import { reactive, readonly } from "./reactive";

function createGetter(isReadonly = false, shallow = false) {
  // get 参数:源对象,当前访问的 key,代理对象 receiver
  return function get(target, key, receiver) {
    console.log(`${ key } 用户取值了`);
    // Reflect 就是后续慢慢替换掉 Object 的方法,一般使用 Reflect 去配合 proxy 
    // 他俩参数配套使用
    const res = Reflect.get(target, key, receiver); // 等价于 target[key]

    if (!isReadonly) {
      // 非只读,会收集依赖(属性收集 effect),只读不进行依赖收集
      console.log(`需要收集依赖,当属性改变,稍后可能需要更新视图哦`);
    }

    // 如果是浅劫持,直接返回,劫持到这一层就可以了
    if (shallow) {
      return res;
    }

    // 非浅劫持,且取值的属性也为一个对象,这里做下懒代理(取值时代理)
    if (isObject(res)) {
      // 注意,只读对象也需要代理第一层,只有代理了才能拦截为只读并在值修改抛警告
      // 调用深层代理的方法做递归(readonly 深层代理或 reactive 深层代理),传入该对象
      return isReadonly ? readonly(res) : reactive(res);
    }

    return res;
  }
}

function createSetter(isReadonly = false, shallow = false) {
  // set 参数:源对象,当前访问的 key,要设置的值,代理对象 receiver
  return function set(target, key, val, receiver) {
    console.log(`用户设置值了,可以更新视图, key: ${ key }  val: ${ val }`);
    const oldValue = target[key]; // 获取老值

    // 判断修改还是新增 
    // 针对数组而言,如果调用 push,会触发两次 set (下标赋值和 length 修改)
    // 数组且修改的属性名为字符串数字(下标),而且该下标小于 target.length 说明是修改
    // 对象如果存在 key,说明是修改
    let hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key);
    const res = Reflect.set(target, key, val, receiver); // 设置新值

    if (!hadKey) {
      // 新增
      console.log('新增');
    } else if (hasChanged(oldValue, val)){
      // 新旧值不一样,则代表修改
      // 这里就绕过了数组 push 先赋值下标,再更改 length 导致的二次更新
      // 第二次修改 length时,发现 length 已经相等了,就不走新增也不走修改了
      console.log('修改');
    }

    return res;
  }
}

// readonlySet 拦截到 set 直接警告
let readonlySet = {
  set(target, key) {
    console.warn(`cannot set ${ JSON.stringify(target) } on key ${ key } failed`);
  }
}

// ------------------ 创建 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(false, true);

// reactive 的拦截方式
export const mutableHandler = {
  get,
  set
}

// shallowReactive 的拦截方式
export const shallowReactiveHandler = {
  get: shallowGet,
  set: shallowSet
}

// readonly 的拦截方式
export const readonlyHandlers = extend({
  get: readonlyGet
}, readonlySet)

// shallowReadonly 的拦截方式
export const shallowReadonlyHandlers = extend({
  get: shallowReadonlyGet
}, readonlySet)


修改工具包 shared/src/index.ts

shared/src/index.ts
export function isObject(val) {
  return typeof val == 'object' && val !== null;
}

export let extend = Object.assign;

export function hasChanged(oldValue, newValue) {
  return oldValue !== newValue;
}

export let isArray = Array.isArray;

// 判断属性名是否为字符串数字,字符串数字转数字再加 '',必定还是原来的字符串数字
export const isIntegerKey = (key) => {
  return parseInt(key) + '' === key
}

export const hasOwn = (target, key) => Object.prototype.hasOwnProperty.call(target, key)


测试代码 examples/2.index.html

    let { reactive, shallowReactive, readonly, shallowReadonly } = VueReactivity;
    let obj = { name: 'ys', adress: { city: 123 }, hobby: [1, 2, 3] }

    // 多次调用,只劫持一次
    // let obj1 = reactive(obj);
    // let obj2 = reactive(obj);
    // console.log(obj1 == obj2);

    // 给 readonly 的对象设置值 会抛出警告
    // let obj3 = readonly(obj);
    // obj3.name = 'ys2'; // baseHandlers.ts:18 cannot set {"name":"ys"} on key name failed

    // 取值 & 设置值
    // let obj4 = reactive(obj);
    // console.log(obj4.name);
    // obj4.name = 'ys2';

    // 多层取值,深层对象需要被代理,这里应该触发两次 get,一次 adress,一次 city
    // let obj5 = reactive(obj);
    // console.log(obj5.adress.city);

    // 因为 vue3 代理的是整个对象,所以不存在的属性取值也会走 get
    // 这对新增属性无疑是很友好的
    // let obj6 = reactive(obj);
    // console.log(obj6.a); // a, undefined
    // obj.a = 1;
    // console.log(obj6.a); // a, 1

    // 数组新增元素,会触发两次 set,一次是修改下标,一次是更改 length(浏览器行为)
    // 我们不能能让它触发两次视图更新,所以需要特殊处理
    let obj7 = reactive(obj);

    obj7.hobby.push(4);

实现 effect & 依赖收集、派发更新

我们知道,effect 默认执行一次,并且会被属性收集,这很类似 Vue2 中的 watcher,当属性更新时,会执行它收集到的 effect。

新建 reactivity/src/effect.ts

  1. effect 可以接收第二个参数,通过传递的 lazy 值,来决定自己首次是否自动执行。
  2. 构建响应式 effect,利用栈结构维护当前 effct 函数,并在属性取值时,做依赖收集操作,依赖收集的构建的数据结构很有趣哦。
  3. 用户取值时,把当前 effect 和 属性做绑定,用户修改或新增属性时,把属性对应的 effct 拿出来再次顺序执行,提供依赖收集方法(track)和触发执行属性对应 effct 的方法(trigger)。
reactivity/src/effect.ts
export function effect(fn, options: any = {}) {
  const effect = createReactiveEffect(fn, options); // 响应式 effect

  if (!options.lazy) {
    effect();
  }

  return effect; // 返回响应式 effect
}

// 栈形结构,栈顶为当前要收集的 effect 方法
const effectStack = []; 
export let activeEffect; // 当前栈顶的 effect,也就是属性应该收集的 effect
let id = 0; // 构建 effect id

// 构建响应式 effect
// 当用户取值时需要将 activeEffect 属性做关联
// 当用户更改的时候,要通过属性找到收集的 effect 重新执行 
function createReactiveEffect(fn, options) {
  // 我们构建的 effect,默认第一次自动执行
  const effect = function() { 
    try {
      // 要让属性记住 effect
      effectStack.push(effect);
      activeEffect = effect;

      // 执行用户的函数,里面有取值操作,则会收集
      return fn(); 
    } finally {
      effectStack.pop(); // 移除栈顶
      activeEffect = effectStack[effectStack.length - 1]
    }
  }

  effect.id  = id++;
  effect.__isEffect = true;
  effect.options = options;
  effect.deps = []; // effect 用来收集依赖了那些属性

  return effect;
}

// 属性读取时收集 effect
// 一个属性对应多个 effct,一个 effect 还可以对应多个属性,我们需要如下的结构
// Map {
//   { name: 'ys' }: {
//     name: new Set(effct1, effect2)
//   },
//   { name: 'sy' }: {
//     name: new Set(effct1, effect2)
//   }
// }
const targetMap = new WeakMap;

export function track(target, type, key) {
  // console.log(activeEffect); // 利用 js 单线程,拿到 activeEffect
  if (activeEffect == undefined) return; // 用户只是取了下值,没有在 effect 中

  let depsMap = targetMap.get(target);

  if (!depsMap) {
    // 空的 map 结构,这里不能使用 weakMap,因为这一层 key 为 string
    // { 'name': new Set(...) }
    depsMap = new Map();

    targetMap.set(target, depsMap);
  }

  // 构建里面的 Set 结构
  let depSet = depsMap.get(key);

  if (!depSet) {
    // 利用 set 结构 O(1) 的查询,来防止收集相同 effect  
    depSet = new Set();
    depsMap.set(key, depSet);
  }

  if (!depSet.has(activeEffect)) {
    depSet.add(activeEffect);
  }
}

// 属性更改时 执行对应 effect
export function trigger(target, type, key, newValue?, oldValue?) {
  // 去映射表里找属性收集的 effct,让他重新执行
  const depsMap = targetMap.get(target);

  // 可能只是取了属性,这个属性没有在 effct 中使用
  // 所以属性没有收集到 effect,但是后续更改值,触发了 set 方法
  if (!depsMap) return; 

  const effectsSet = new Set(); // 收集要执行的 effct

  // 遍历属性收集到的 effcts
  const add = (curEffects) => {
    if (curEffects) {
      curEffects.forEach(effect => effectsSet.add(effect));
    }
  }

  add(depsMap.get(key));

  effectsSet.forEach((effect: any) => effect());
  console.log('me');
}


修改 reactivity/src/baseHandlers.ts

  1. 用户取值时,把当前 effect 和 属性做绑定,用户修改或新增属性时,把属性对应的 effct 拿出来再次顺序执行。
reactivity/src/baseHandlers.ts
import { activeEffect, track, trigger } from './effect'

function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
  	// ...
    if (!isReadonly) {
      // 非只读,会收集依赖(属性收集 effct),只读不进行依赖收集
      console.log(`需要收集依赖,当属性改变,稍后可能需要更新视图哦`);
      track(target, 'get', key); // 关联当前属性和 effct
    }
    // ...
  }
}

function createSetter(isReadonly = false, shallow = false) {
  // set 参数:源对象,当前访问的 key,要设置的值,代理对象 receiver
  return function set(target, key, val, receiver) {
    console.log(`用户设置值了,可以更新视图, key: ${ key }  val: ${ val }`);
    // ...
    if (!hadKey) {
      // 新增
      console.log('新增');
      trigger(target, 'add', key, val); // 触发执行属性对应的 effect
    } else if (hasChanged(oldValue, val)) {
      // 新旧值不一样,则代表修改
      // 这里就绕过了数组 push 先赋值下标,再更改 length 导致的二次更新
      // 第二次修改 length时,发现 length 已经相等了,就不走新增也不走修改了
      console.log('修改');
      trigger(target, 'set', key, val, oldValue);
    }

    return res;
  }
}

// ...


修改 reactivity/src/index.ts,暴露 effect 方法

export * from './reactive';
export { effect } from './effect';

测试代码 examples/2.index.html

let { reactive, shallowReactive, readonly, shallowReadonly } = VueReactivity;

// lazy: true 代表首次不自动执行 
// 如下所写,name 需要收集外层 effct,age 需要收集里层 effct,利用栈形结构
// let obj9 = reactive(obj);
// effect(() => {
//   obj9.name = 'ys2'; 

//   effect(() => {
//     obj9.age = 19;
//   })
// }, { lazy: false });

// effect 
let obj8 = reactive({ name: 'ys' });
let obj9 = reactive({ name: 'sy' });

console.log('effct 执行前');

effect(() => {
  console.log('aowu');
  let a = obj8.name; // 取值
  let b = obj8.age;
  let a1 = obj9.name; // 取值
  let b2 = obj9.age;
});

setTimeout(() => {
  obj8.name = '1'
  obj8.name = '2'
  obj8.name = '3'
  obj8.name = '4'
  obj8.name = '5'
}, 1000);

这时候,就可以看到修改五次,会触发五次更新,并且打印了五次 'aowu' 哦。

修复数组修改的 bug

测试代码 examples/2.index.html

let { reactive, shallowReactive, readonly, shallowReadonly } = VueReactivity;

let proxy = reactive({ name: 'ys', hobby: [1, 2, 3] });

effect(() => {
  // 触发取值
  let arr = proxy.hobby;
});

proxy.hobby[2] = 100;

我们发现,触发了修改下标属性(3 --> 100,hasChanged 方法会判断下标已经修改),但是 effct 没有被重新执行,原因是因为,虽然我们对 hobby 属性进行了依赖收集,但是也仅仅是对 hobby 属性进行了更改的检测,比如 hobby 更改了指向,而我们针对数组下标的修改,没有导致数组本身改变,改变的是下标属性的值,而我们没有对下标属性的值做依赖收集,因为它们没有被访问过,如果改成如下方式,则可以触发更新。

let proxy = reactive({ name: 'ys', hobby: [1, 2, 3] });

effect(() => {
  // 触发取值
  let arr = proxy.hobby[2];

  // stringify 会把所有的下标包括 length 都访问一遍,我们把数组实际渲染页面源码肯定会转成字
  // 符串,所以这个 bug 我们基本见不到,所以这种写法 length 也有自己的 effct 方法哦
  let arr = JSON.stringify(proxy.hobby);
});

proxy.hobby[2] = 100;

但是我们总不能要求用户写之前必须这么访问吧,所以在 Vue3 中,做了很多针对修复性的 bugfix 代码。

bug①: 数组修改 length

这个很明显,没有收集 length 的依赖。

let proxy = reactive({ name: 'ys', hobby: [1, 2, 3] });

effect(() => {
  // 触发取值
  console.log('aowu');
  let arr = proxy.hobby[2];
});

// 1s 后,没有打印 aowu
setTimeout(() => {
  proxy.hobby.length = 1;
}, 1000)

修改 reactivity/src/effect.ts 方法

export function trigger(target, type, key, newValue?, oldValue?) {
  // ...

  // 如果是数组,你改了length
  if (key === 'length' && isArray(target)) { 
    depsMap.forEach((dep, key) => {
      // key 为我收集到的属性,比如收集下标改 length
      // 如果 length 改小了,该下标直接整没了,需要更新
      // 或者 length 被收集过了,改 length 必须更新!
      if (key > newValue || key === 'length') {
        add(dep); 
      }
    })
  } else {
    add(depsMap.get(key));
  }

  effectsSet.forEach((effect: any) => effect());
}

bug②: 数组 push

虽然数组的 push 会触发新增,但是我们并没有让他走修改的逻辑,所以即使我们用 JSON.stringify 去收集了 length,它不会触发 length 收集的依赖执行。

let proxy = reactive({ name: 'ys', hobby: [1, 2, 3] });

effect(() => {
  // 触发取值
  console.log('aowu');
  let arr = JSON.stringify(proxy.hobby);
});

setTimeout(() => {
  // 1s 后没有打印 aowu
  proxy.hobby.push(4);
}, 1000)

修改 reactivity/src/effect.ts 方法

export function trigger(target, type, key, newValue?, oldValue?) {
  // ...

  // 如果是数组,你改了length
  if (key === 'length' && isArray(target)) { 
    depsMap.forEach((dep, key) => {
      // key 为我收集到的属性,比如收集下标改 length
      // 如果 length 改小了,该下标直接整没了,需要更新
      // 或者 length 被收集过了,改 length 必须更新!
      if (key > newValue || key === 'length') {
        add(dep); 
      }
    })
  } else {
    add(depsMap.get(key));
    switch (type) {
      case 'add':
        // 数组增加属性,需要触发 length 的依赖收集
        if (isArray(target) && isIntegerKey(key)) {
          add(depsMap.get('length')); 
        }
    }   
  }

  effectsSet.forEach((effect: any) => effect());
}

总结

我们在这里完善了两种数组更新的策略。

  1. 如果更改的数组长度小于依赖收集的长度,要触发重新渲染 2.如果调用了push方法,或者其他新增数组的方法(必须能改变长度的方法),也要触发更新

我们发现,Vue3 中,只有收集了依赖才能触发更新,如果没有收集,那么它一定做了额外的处理,来触发了更新。

ref (普通值的响应式)

在前面 reactive 中,我们并没有原始类型的值做什么处理,在 vue3 提供了 ref 来处理原始值,使得原始值也具有响应式的能力,它本质会把传入的值包裹在 RefImpl 类的实例上,取值从实例上取,并且定义了类的 get 和 set 方法(属性访问器),get 的时候收集依赖,set 的时候触发依赖更新,重新取值。

examples/1.index.html 中测试 ref 效果。

let { ref } = VueReactivity;

// 内部会把这个变量用 defineProperty 定义在一个对象上
let isShow = ref(false); 

effect(() => {
  console.log('aowu');
  console.log(isShow.value);
});

setTimeout(() => {
  // 1s 后输出 aowu
  isShow.value = true;
}, 1000);

实现 ref

新建 reactivity/src/ref.ts

reactivity/src/ref.ts
import { hasChanged, isObject } from "@ys-vue3/shared";
import { track, trigger } from "./effect";
import { reactive } from "./reactive";

// 把普通值变为一个引用类型,让一个普通值也具备响应式的能力
// 也可以传入对象
export function ref(value, shallow) {
  return createRef(value, shallow);
}

// 传入的值是对象,使用 reactive 包裹,否则返回原始值
const convert = v => isObject(v) ? reactive(v) : v;

// ts 中实现类的话,私有属性必须先声明后使用
class RefImpl {
  public _value; // 存储原值
  public __v_isRef = true // 表示他是一个ref
  
  // public 代表声明一个实例属性,比如声明 rawValue,并把传入的参数赋值
  constructor(public rawValue, public shallow) {
    // 如果非浅代理,对传入的对象使用 Proxy,普通值这一步不作操作。 
    this._value = shallow ? rawValue : convert(rawValue);
    this.rawValue = rawValue;
  }

  get value() {
    // 收集依赖
    track(this, 'get', 'value');
    return this._value;
  }

  set value(newValue) {
    if (hasChanged(newValue, this.rawValue)) {
      this.rawValue = newValue; // 更新原值,用于下次比对
      this._value = this.shallow ? newValue : convert(newValue); // 更新值

      // 触发依赖
      trigger(this, 'get', 'value', newValue);
    } 
  }
}

function createRef(value, shallow = false) {
  // 借助类的属性访问器,同时用这个类标识 Ref
  return new RefImpl(value, shallow); 
}



修改 reactivity/src/index.ts,暴露 ref 方法

export * from './reactive';
export { effect } from './effect';

新建 examples/3.ref.html,测试我们的代码

<!-- ys-vue3 子包 ractivity 测试 -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app"></div>
  <script src="../node_modules/@ys-vue3/reactivity/dist/reactivity.global.js"></script>
  <script>
    let { ref, effect } = VueReactivity;

    let isShow = ref(false); 

    effect(() => {
      console.log('aowu');
      console.log(isShow.value);
    });

    setTimeout(() => {
      // 1s 后输出 aowu
      isShow.value = true;
    }, 1000);
  </script>
</body>
</html>

实现 toRef

如果我们需要把响应式对象的某个属性拿出来使用,并且希望它也是响应式,比如 let d = obj.a.b.c.d,直接使用 d 来做响应式(不用写那么长了)。

可如果 d 不是个对象,比如我们可能拿出来的是一个单纯的不具备响应式能力的字符串,这时候我们可能想到如果把这个字符串变成响应式字符串多好。

我们知道(反正我知道),reactive 包裹的 ref 在 get 取值的时候会进行拆包(我们目前源码并没有加这个判断),所以不能在里面进行 ref 包装,**其实 ref 提供了 toRef 方法,就是干这个的,它允许我们把对象的某个属性包装成响应式。**那这里为什么不用 ref 在取完值包一下呢,其实是使用场景不同,针对目标不同。

// Ref 拆包
const obj = reactive({ name: ref('ys') });
console.log(obj.name); // 'ys'



// toRef 使用
const obj2 = reactive({ name:  'ys' });
let nameRef = toRef(obj2, 'name');

effect(() => {
  // 取代理后的字段,做依赖收集
  console.log('name 字段呀 呀', nameRef.value); 
});

setTimeout(() => {
  nameRef.value = 100;
}, 1000);

我们来实现它。

class ObjectRefImpl {
  public _v_isRef = true; // 它也是 ref
  constructor(public target, public key) {

  }

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

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

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

可以看出来,它仅仅是给我们代理了一下,包成对象来使用,并没有去做类似于 ref 的手动触发 track 或者 trigger 等,这是因为 toRef 是基于 reactive 的,也即是说,toRef 是操作一个已经深度代理的对象的属性,使得它不用写那么长,而 ref 是操作一个普通值/对象,使它具有响应式能力,二者职责不同。

实现 toRefs

toRefs 的实现就简单多了,我们只需要遍历当前对象,对每个能访问到的属性进行 toRef 就完了。

export function toRefs(target) {
  // 如果是数组返回数组
  const res = isArray(target) ? new Array(target.length) : {};

  // for in 能遍历数组 + 对象
  for (let key in target) {
    res[key] = toRef(target, key);
  }

  return res;
}

测试代码

const { reactive, ref, toRefs } = VueReactivity;
const arrProxy = reactive([1, 2, 3, 4]);
let  [a, b, c, d] = toRefs(arrProxy);

console.log(a.value, b.value, c.value, d.value);

计算属性 computed

不同于 vue2 的 computed,Vue3 中的计算属性是函数式的,这也是组合式 API 的一个好处,有以下两种写法

// 传入 options 对象
computed({
  get() {},
  set() {}
})

// 传入 getter 函数
computed(() => {
  return proxy.age * 2
});

比如我们想让年龄 * 2

// 计算属性 computed
let proxyObj = reactive({ name: 'ys', age: 12 });

// 注意 computed 返回一个计算后的新值
// 计算属性也是一个 effct,当属性变化它也需要执行,age 会收集计算属性的 effct
// age 改变触发计算属性的 effct 执行,重新生成 newAge
let newAge = computed(() => {
  return proxyObj.age * 2
});

effect(() => {
  // 计算属性也有收集依赖的功能,可以收集这个 effct
  // 当 newAge 改变,它会执行哦。
  app.innerHTML = newAge.value; // 页面显示 24, 1s 后变为 2000
})

setTimeout(() => {
  // newAge.value = 1000; // 计算属性的值是只读的
  proxyObj.age = 1000;
}, 1000)

此时的触发顺序是,1s 后 proxyObj.age 改变,首先触发 computed 自身的 effct,然后 newAge 改变,触发页面赋值的 effct,需要注意的是,如果我们没有用到计算属性的值 newAge,那么它是不会默认执行的哦,说明计算属性的 effct 是默认传入了 { lazy: true } 配置的哦

让我们来实现它。

修改 reactivity/src/effct.ts,使它支持定制化的更新函数。

// 属性更改时 执行对应 effect
export function trigger(target, type, key, newValue?, oldValue?) {
  // ...
  effectsSet.forEach((effect: any) => {
    if (effect.options.schedular) {
      // 如果用户传入的 effct 配置 options 内包含了 schedular
      // 不再默认执行属性收集的 effct, 而是执行用户自定义的更新函数
      effect.options.schedular(effect);
    } else {
      effect();
    }
  });
}

新建 reactivity/src/computed.ts

  1. effct 中 newAge.value 取值时,会触发 newAge 的 get 拦截器函数,我们执行了 this._value = this._effct() 对计算属性的值进行计算,该方法执行 effct 函数并返回用户的回调函数 fn (就是getter)结果,fn 的执行,会对 proxy.age 取值, computedEffct 作为 activeEffect 被 proxy.age 收集。
  2. 因为 newAge 不是一个响应式的数据,我们在 get 拦截器中手动去触发 track(this, 'get', 'value') 去收集此次对 newAge 取值的外层的 effct。
  3. 当 proxy.age 改变时,触发 set 方法去执行收集到的 computedEffct,由于 computedEffct 存在 options.schedular 属性,故此时执行 options.schedular(),newAge 随之改变。然后我们手动 trigger 了 newAge.value set 事件,去触发 newValue 收集到的 effct 执行,所以造就了计算属性依赖的值改变后,计算属性 effct 会重新执行来计算新的值,并且依赖计算属性的 effct 也会重新执行。
import { isObject } from "@ys-vue3/shared";
import { effect, track, trigger } from "./effect";

class ComputedRefImpl {
  public effect;
  public _value;
  public _dirty = true; // 依赖的属性改变后,再重新计算,缓存值
  constructor(public getter, public setter) {
    // 加入 lazy: true,effct 默认不执行
    // getter 作为 fn 传入 effct
    // 用户更新值后,我们在 trigger 中判断了不执行 effct,执行我们自己的 schedular
    this.effect = effect(getter, { lazy: true, schedular: () => {
      // 自己来实现更新函数
      console.log('ok');
      if (!this._dirty) {
        console.log('用户更新了依赖的属性');
        // 用于取值时,进行重新计算
        this._dirty = true; 
        // 触发真正的属性收集到的 effect 执行
        trigger(this, 'set', 'value');
      }
    }});
  }

  get value() {
    // 当用户取值的时候 才会去执行 effct
    // effct 内部会 return fn(),这里拿到 getter 函数返回的计算后的值
    if (this._dirty) {
      // 脏值重新计算, 这是 computed 代表的 effct
      this._value = this.effect();
      this._dirty = false;
    }
    
    // 取值时,计算属性也要做属性收集 比如取上栗中 effct 中 newAge.value 时
    // newAge 可不是代理后的对象,那么他就不走 get,不会自动收集依赖
    // 但是我又需要当 newAge 被改变(被重新计算)后,去触发该 effct 执行,所以取值时需要收集依赖
    // this 为 ComputedRefImpl 实例,value 为 this._value, 当 this._value 改变,会触发依赖更新
    track(this, 'get', 'value');
    return this._value;
  }

  set value(newValue) {
    this.setter();
  }
}

// 计算属性
export function computed(getterOrOptions) { // 可能是单个函数或者 options 对象
  let getter;
  let setter;

  if (isObject(getterOrOptions)) {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  } else {
    // 传入的是单个的 getter 函数
    getter = getterOrOptions;
    setter = () => { // setter 没传入的话,计算属性的值是只读的
      console.log('computed value is readonly');
    }
  }

  return new ComputedRefImpl(getter, setter);
}

至此,我们响应式模块大体功能就实现完啦。

runtime-dom 模块

在前面我们提到过,runtime-dom 是针对浏览器的运行时,它内部会调用 rutime-core,把 DOM API,属性,事件处理等方法传入进去,我们切换到 examples,为了方便后面测试,我们直接安装 vue 模块

yarn add vue@3

新建 5.render.html

<!-- vue3 子包 runtime-dom 测试 -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app"></div>
  <script src="node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script>
  <script>
    const { createdApp } = VueRuntimeDOM;

  </script> 
</body>

</html>

试用 runtime-dom 模块

createApp 创建应用

const { createApp, h } = VueRuntimeDOM;

const App = {
  // 写法一
  // render: () => {
  //   return h('h1', 'hello ys'); // 创建虚拟节点
  // }

  // 写法二
  setup() {
    return () => {
      return h('h1', 'hello ys'); 
    }
  }
}

// createApp 将虚拟节点变成真实节点,最后插入到元素中
createApp(App).mount('#app');

此时页面就渲染出来了一个 h1 标签哦。

组合式 API 在渲染模块中的体现

我们来实现一个点击计数的功能,体验下函数拆分的威力~

 // VueRuntimeDOM 依赖了 runtime-core, runtime-core 依赖了 reactivity
// 所以 reactivity 相关模块(比如 ref) 也可以导出自 VueRuntimeDOM
const { createApp, h, ref } = VueRuntimeDOM;

function useCounter() {
  const count = ref(1);
  const add = () => {
    count.value++;
  }

  return {
    add, 
    count
  }
}

const App = {
  setup() {
    let { count, add } = useCounter();

    return () => {
      return h('h1', { onClick: add }, count.value); 
    }
  }
}

// createApp 将虚拟节点变成真实节点,最后插入到元素中
// createApp 第二个参数是传入组件的属性,本例没用到这个属性哦
createApp(App, { name: '测试属性' }).mount('#app');

可以看到,我把计数器的逻辑完全拆出来了,可以在模块内使用,当然我们本章不是讨论这个的,这只是拓展。

实现 runtime-dom

新建文件夹

- packages
  # ...
  - rumtime-dom
    - src
      - index.ts
  - runtime-core
    - src
      - index.ts

runtime-dom、runtime-core 包分别初始化 yarn

yarn init -y 

修改二者 package.json

{
  "name": "@ys-vue3/runtime-dom",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT",
  "buildOptions": {
    "name": "VueRuntimeDOM",
    "formats": [
      "global"
    ]
  }
}


{
  "name": "@ys-vue3/runtime-core",
  "version": "1.0.0",
  "main": "src/index.js",
  "license": "MIT",
  "buildOptions": {
    "name": "VueRuntimeCore",
    "formats": [
      "global"
    ]
  }
}

修改 scripts/dev.js

// ...

build('runtime-dom');

修改 rumtime-dom/src/index.ts - 提供 dom 操作 api 给 rumtime-core 模块 - 提供 属性和事件处理 api 给 rumtime-core 模块 - 调用 runtime-core 提供的渲染器得到 app,重写 app.mount 方法,在其顶部获取要挂载的 dom 元素,清空元素内容,最好调用 runtime-core 提供的 mount 方法执行 dom 挂载。

rumtime-dom/src/index.ts
// 需要支持 dom 操作的 api 及属性处理的 api
import { extend } from "@ys-vue3/shared";
import { createRenderer } from "packages/runtime-core";
import { nodeOps } from "./nodeOps"; // dom 操作的 api 
import { patchProp } from "./patchProp"; // 属性处理的 api

// 合并 dom 操作的 api 及属性处理的 api
const rendererOptions = extend(nodeOps, { patchProp });

// 创建应用的 api,  createApp(App, { xx: 123 }).mount('#app');
export function createApp(rootComponent, rootProps = null) {
  // 把创建 app 系列的操作交给 runtime-core,都交给 runtime-core 提供的渲染器 createRenderer
  let app = createRenderer(rendererOptions).createApp(rootComponent, rootProps);
  let { mount } = app;

  // 重写 app.mount,便于扩展和获取参数
  app.mount = function(container) {
    container = rendererOptions.querySelector(container); // 获取要挂载到的元素
    container.innerHTML = ''; // 对容器进行清空
    // 调用 app 内部 mount
    mount(container);
  }

  return app;
};


新建 rumtime-dom/src/nodeOps.ts - dom 操作 api 定义

rumtime-dom/src/nodeOps.ts
// 节点创建相关 api
export const nodeOps = {
  // 增、删、改、查、元素中插入文本、文本的创建、文本元素的内容设置、获取父亲、获取下一个元素
  createElement: tagName => document.createElement(tagName),
  remove: child => child.parentNode && child.parentNode.removeChild(child),
  insert: (child, parent, anchor = null) => parent.insertBefore(child, anchor), // anchor 不存在的时候就是appendChild
  querySelector: selector => document.querySelector(selector),
  setElementText: (el, text) => el.textContent = text,
  createText: text => document.createTextNode(text),
  setText: (node, text) => node.nodeValue = text,
  getParent: (node) => node.parentNode,
  getNextSibling: (node) => node.nextElementSibling
}


新建 rumtime-dom/src/patchProp.ts - 属性和事件处理 api 实现

rumtime-dom/src/patchProp.ts
// 更新类名
const patchClass = (el, next) => {
  if (next == null) {
    next = ''
  }
  el.className = next;
}

// 增加一层代理函数包裹~ 省的 dom 函数更新还要解绑
const createInvoker = (fn) => {
  // 最终执行的时候,执行的是自己身上绑定的 fn,这样加了一层代理就可以实现 fn 可修改
  const invoker = (e) => { invoker.value(e) }; 
  invoker.value = fn; // invoker 上记住这个 fn
  return invoker
}

// 更新事件  key 为事件名 'onClick',next 为新函数
const patchEvents = (el, key, next) => { // react中采用的是事件代理,但是vue中直接绑定给元素的
  // 在 dom 节点上存储了个属性 el._vei = { onClick: proxyFn },dom 绑定 click 事件为 proxyFn
  // proxyFn.value = next;
  // proxyFn 就是 (e) => { proxyFn.value(e)  },
  // 当我们改变事件的时候,只需要切换 next 就行,不用再卸载事件
  const invokers = el._vei || (el._vei = {}); // 缓存事件 

  const exists = invokers[key];

  if (exists && next) {
    exists.value = next; // 替换事件,但是不用解绑,这就是增加了个代理函数的好处
  } else {
    const eventName = key.toLowerCase().slice(2); // click

    if (next) {
      // 绑定事件
      invokers[key] = createInvoker(next);
      el.addEventListener(eventName, invokers[key] ); // 绑定事件
    } else {
      // 传 null 卸载事件
      el.removeEventListener(eventName, exists);
      invokers[key] = null;
    }
  }
}

// 更新 style  { color: 'red' } ---> { background: 'blue' }
const patchStyle = (el, prev, next) => {
  if (next == null) {
    el.removeAttribute('style'); // 如果最新的没有样式 直接移除样式就可以了
  } else {
    if (prev) {
      for (let key in prev) {
        if (next[key] == null) { 
          // 如果之前有,现在没有,移除就好
          el.style[key] = ''
        }
      }
    }

    // 新的一定要生效
    for (let key in next) {
      el.style[key] = next[key];
    }
  }
}

// 更新属性
const patchAttrs = (el, key, next) => {
  if (next == null) {
      el.removeAttribute(key);
  } else {
      el.setAttribute(key, next);
  }
}

/**
 * @description 属性更新方法策略模式~
 * @param el 操作元素
 * @param key 操作属性名
 * @param prev 上次的属性值
 * @param next 本次要更新的目标属性值
 */
export const patchProp = (el, key, prev, next) => {
  switch (key) {
    case 'class': // .className
      patchClass(el, next);
      break;
    case 'style': // .style.xx
      patchStyle(el, prev, next);
      break;
    default:
      if (/^on[A-Z]/.test(key)) { // 匹配 on + 一个大小字母,比如 onClick
        // 事件 addEventListener
        patchEvents(el, key, next);
      } else {
        // 其他属性 直接使用 setAttribute
        patchAttrs(el, key, next);
      }
  }
}


修改 rumtime-core/src/index.ts - runtime-core 核心模块提供创建 APP 的渲染器方法,该方法返回一个带 mount 方法的 app 对象。

rumtime-core/src/index.ts
export { createRenderer } from './renderer';


新建 rumtime-core/src/renderer.ts - 渲染器方法的实现。

rumtime-core/src/renderer.ts
// 这里不再关心什么平台了
export function createRenderer(rendererOptions) {
  return {
    createApp(rootComponent, rootProps) {
      const app = {
        mount(container) {
          console.log(rootComponent, rootProps, container);
        }
      };

      return app;
    }
  }
}



实现 runtime-core 模块

详解创建应用的两种方式

现在逻辑都糅在 createRenderer 这个方法中了,我们需要对它进行一次整理,不过在此之前,我们先来了解下组件注册的两种方式

const { createApp } = VueRuntimeDOM;

const App = {
  props: {
    age: Number
  },
  // 方式一 返回 render 函数, 优先级高 
  setup(props, ctx) {
    return () => {
      return h('h1', '1000'); 
    }
  },

  // 方式二 返回对象,作为属性传递给外面自定义的 render 方法,优先级较低
  setup(props, ctx) {
    return { a: 1 }; 
  },
  render(proxy) {
    // 注意我们手动提供的 render 方法,和 Vue2 不同的是,Vue2 render 的
    // 参数为 h,Vue3 为 proxy,和 this 指向同一个对象。
    console.log(proxy == this); // true
    return h('h1', this.a);
  }
}

createApp(App, { name: '测试属性', age: 12 }).mount('#app');

方式一中的 setup 返回的匿名函数其实就是自定义的 render 函数(非以下源码中生成真实 dom 的 render,该 render 用于生成虚拟 dom),而 h 则仍类似于 Vue2 中的 createElement 方法。

setup 具有两个参数,props 和 ctx

const App = {
  props: {
    age: Number
  },
  // 方式一
  setup(props, ctx) {
    console.error(props, ctx)
    // props 就为当前 App.props
    // ctx 为当前上下文,有四个字段
    // 1. ctx.attrs 属性
    // 2. ctx.emit 事件发布
    // 3. ctx.expose 可以把组件的数据暴露给父级,父级可以通过 ref 来取到这些
    //    属性(xxx.$refs.abcd)
    // 4. ctx.slots 插槽 
    // age 在 props 用到了之后, ctx.attrs 里就只有 name 字段啦
    // 这样做的好处是相当于直接把 brforeCreate 生命周期给到 setup 执行了,
    // 组件的初始化都会调用 setup,所以这些其实是类似 brforeCreate 给的参数
    // 方便我们组合式 api 搭配生命周期函数执行。
    

    return () => {
      return h('h1', '1000'); // subTree 
    }
  }
}

// 这里注入了 name 和 age 两个属性,其中 age 在 props 中使用到了,
// 那么 ctx.attrs 中就没有 age 只有 nage 了
createApp(App, { name: '测试属性', age: 12 }).mount('#app');

按位或和按位与

在实现 core 模块之前,我们还需要了解下前置知识位运算

1 << 1  // 代表二进制的 1 往左移一位,也就是 1 -> 10 得到二进制的 10,也就是十进制的 2
1 << 2  // 代表二进制的 1 往左移两位,也就是 1 -> 100 得到二进制的 100,也就是十进制的 4

// 按位或 和 按位与 权限判断的最佳实现

// 按位或 "|",不足最大位前面补 0,有一个是 1,则结果是 1,常用于组合生成权限
1 << 2 | 1 << 1  // 也就是 100 | 010,得到二进制的 110,说明标识为 110 的拥有二进制 100 和 10 权限

// 按位与 "&",不足最大位前面补 0,都是 1 才转为 1,则结果是 1,常用于检测权限的包含情况
110 & 100 //  100,不为 0,就代表 110 拥有 100 的权限
110 & 10  //  10,代表 110 同时还拥有 10 的权限

实现 runtime-core(包含 diff_最长递增子序列)

ok,准备工作做完啦,由于 createApp 是 createRenderer 方法提供的,我们这里贴代码的顺序是按调用顺序来贴哦,因为整体流程过长,这里马上要准备手绘流程图来作说明了,所以单文件的文字注解就不写了爱好。

修改 rumtime-core/src/renderer.ts **- 该模块返回两个方法,createApp 和 render,createApp 被我们抽离出去了,留作下面介绍,render 方法调用会传入虚拟 dom 和 容器,内部调用 patch 方法去生成虚拟 dom,然后会对新旧虚拟 dom 作 diff 算法,根据比对结果复用或替换对应内容后生成真实 dom 插入容器中。 - 针对子元素都是数组的比对方法,先前序比对,再后续比对,最后看结果觉得是顺序插入/截取还是乱序,乱序的话,需要求解最大递增子序列(为了移动最少元素得到正确顺序)。

rumtime-core/src/renderer.ts
import { effect } from "@ys-vue3/reactivity/";
import { getSequence, ShapeFlags } from "@ys-vue3/shared";
import { createAppAPI } from "./apiCreateApi"
import { createComponentInstance, setupComponent } from "./component";
// 这里不再关心什么平台了

export function createRenderer(rendererOptions) {
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProp: hostPatchProp,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
  } = rendererOptions

  // 创建渲染用的 effct(首次执行是进行渲染)
  const setupRenderEffct = function (instance, container) {
    // 每次状态变化后,都会重新执行 effct
    effect(function componentEffct() {
      if (!instance.isMounted) {
        // effct 默认执行一次,组件未被挂载过 执行组件渲染
        // 我们使用 call 方法,保持了 setup 中 this = proxy, 返回虚拟 dom 
        // 调用的是用户提供的 render 方法,生成 subTree 的虚拟 dom
        let subTree = instance.subTree = instance.render.call(instance.proxy, instance.proxy);

        // subTree 虚拟 dom 渲染成真实节点
        patch(null, subTree, container);

        instance.isMounted = true;
      } else {
        // 数据改变,effct 重新执行
        console.log('修改了数据,需要重新生成虚拟 dom,然后进行 diff 算法');
        // 旧的 subTree 虚拟 dom
        const prevTree = instance.subTree;
        // 新的 subTree 虚拟 dom
        const nextTree = instance.render.call(instance.proxy, instance.proxy);

        // 重新渲染
        let subTree = nextTree;

        // subTree 虚拟 dom 渲染成真实节点,这里进行 diff 算法
        patch(prevTree, subTree, container);
      }
    });
  }

  const mountComponent = (n2, container) => {
    // 1. 根据组件的虚拟节点创造一个实例 instance,并挂载到虚拟dom 的 component 属性上
    let instance = n2.component = createComponentInstance(n2); 

    // 2. 给 instance 增加属性,调用组件的 setup,解析出 render 方法(生成虚拟 dom 的 render),挂载到实例上
    setupComponent(instance);

    // 3. 调用 render 生成 subTree 的虚拟 dom,为了响应式,我们使每个组件都有一个自己的 effct
    setupRenderEffct(instance, container);
  }

  const updateComponent = (n1, n2, container) => {

  }

  // 处理组件的更新或渲染的方法
  const processComponent = (n1, n2, container) => {
    if (n1 == null) {
      // 没有旧的 vnode,说明不是更新而是挂载组件(初次渲染)
      mountComponent(n2, container);
    } else {
      updateComponent(n1, n2, container);
    }
  }

  // 循环数组子节点,创建真实 dom 元素
  function mountChildren(children, container) {
    for (let i = 0; i < children.length; i++) {
      patch(null, children[i], container);
    }
  }

  // 把 subTree vnode 变为真实节点插入到 container 中,注意当前函数是个递归
  function mountElement(vnode, container, anchor) {
    const { type, props, children, shapeFlag } = vnode;

    // 1) 生成当前节点的真实 dom
    let el = vnode.el = hostCreateElement(type);

    // 2) 如果提供了 props,在元素上挂载 props 属性
    if (props) {
      // 给元素增加属性(属性挂载元素的真实 dom 节点上)
      for (let key in props) {
        // react-dom 模块提供的给元素增加属性的 api
        hostPatchProp(el, key, null, props[key]);
      }
    }

    // 3) 当前节点创建完毕后,需要创建儿子节点,挂载到当前节点
    // 注意按位与的写法
    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 如果儿子节点是数组,需要循环创建元素
      mountChildren(children, el);
    } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 如果儿子节点是文本
      hostSetElementText(el, children);
    }

    // 4) 当前节点插入容器
    hostInsert(el, container, anchor);
  }

  // 属性比对 diff 算法
  function patchProps(el, oldProps, newProps) {
    if (oldProps !== newProps) {
      for (let key in newProps) {
        const prev = oldProps[key];
        const next = newProps[key];
        if (prev !== next) {
          // 新旧属性不相等,则以新的为主
          hostPatchProp(el, key, prev, next)
        }
      }

      for (let key in oldProps) {
        if (!(key in newProps)) {
          // 旧属性中比新属性多的属性要干掉
          hostPatchProp(el, key, oldProps[key], null)
        }
      }
    }
  }

  // 新旧节点的儿子节点都为数组,需要进行数组的比对
  function patchKeyedChildren(oldChildren, newChildren, container) {
    let i = 0; // 比对的索引
    let oldLen = oldChildren.length - 1;
    let newLen = newChildren.length - 1;

    // 从前往后比 sync from start
    // 以短的为主,一方比完 或者 遇到不一样的节点则终止
    while (i <= oldLen && i <= newLen) {
      const n1 = oldChildren[i]; // 当前索引的旧儿子节点
      const n2 = newChildren[i]; // 当前索引的新儿子节点

      // 是同一个元素,要比较属性和这两个节点的儿子
      if (isSameVnode(n1, n2)) {
        // 因为我们 patch 包含了比较节点 + 更新的逻辑,这里直接用即可
        patch(n1, n2, container);
      } else {
        break;
      }

      i++;
    }

    // 从后往前比 sync from end
    while (i <= oldLen && i <= newLen) {
      const n1 = oldChildren[oldLen]; // 当前索引的旧儿子节点
      const n2 = newChildren[newLen]; // 当前索引的新儿子节点
      if (isSameVnode(n1, n2)) {
        patch(n1, n2, container);
      } else {
        break;
      }

      oldLen--;
      newLen--;
    }

    // console.log(i, oldLen, newLen);

    // 如果比对终止后 i > 老节点的 length,说明新的子节点较多
    // 无论新的子节点首部元素多还是尾部元素多,都满足 i > oldLen
    // 画图理解
    // 节点新增
    if (i > oldLen) { // 无论是头部增加 还是尾部增加 都是这个逻辑
      if (i <= newLen) { // 新增的节点的下标范围是 i ~ newLen,包括 newLen
        // 如果 newLen + 1 肯定大于 newChildren 的总长度,说明向后追加
        // 否则向前追加
        const nextPos = newLen + 1; 
        // 取到下一个元素
        // 如果没越界,新节点插入到下一个元素前面即可
        // 如果越界,新节点追到到最后面(传 null 代表 appentChild)
        const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;

        while (i <= newLen) {
          // 塞进去或者插后面。
          patch(null, newChildren[i++], container, anchor)
        }
      }
    } else if (i > newLen) { // 节点减少
      while (i <= oldLen) { // 把 i 和 oldLen 之间的干掉
        unmount(oldChildren[i++])
      }
    } else { // 乱序比对 (最长递增子序列)
      // 需要尽可能复用,用新的元素做成一个映射表去老的里去找,一样的就复用,不一样的要不插入,要不删除
      // 双指针,对新旧 children 数组进行遍历,遍历到右窗口(更新后的 oldLen,newLen)
      // 我们把需要乱序对比的区域看做一个窗口
      let oldWinLeft = i;
      let newWinLeft = i;
      let oldWinLen = oldLen;
      let newWinLen = newLen;

      console.log(oldWinLeft, newWinLeft, oldWinLen, newWinLen);

      // Vue3 用的是新 children 窗口内元素做映射表,Vue2 用的是老的~
      // 方便遍历老窗口元素去新窗口元素 map 中查找,找不到直接干掉元素
      // 缓存的是虚拟节点的 key 和 窗口内下标。
      const keyToNewIndexMap = new Map();

      for (let i = newWinLeft; i <= newWinLen; i++) {
        const childVNode = newChildren[i]; // 窗口内的新的子节点

        // { key: i }
        keyToNewIndexMap.set(childVNode.key, i);
      }

      console.log(`新节点窗口对应 map 为`, keyToNewIndexMap);   // Map {"E" => 2, "C" => 3, "D" => 4, "H" => 5}

      // 新节点窗口的元素个数,目标渲染到页面的元素个数
      const toBePatched = newWinLen - newWinLeft + 1; 
      console.log(toBePatched);
      // 生成一个这么长的数组,给对比过的元素增加标记,最后位置仍然为 0 的元素则为新增元素
      const newIndexToOldIndexMap = new Array(toBePatched).fill(0); 

      // 去老节点窗口寻找,有没有可以复用的元素
      for (let oldWinidx = oldWinLeft; oldWinidx <= oldWinLen; oldWinidx++) {
        const oldVnode = oldChildren[oldWinidx];
        let newIdx = keyToNewIndexMap.get(oldVnode.key); // 如果老节点中存在该节点

        console.log(`老节点 key 为 ${ oldVnode.key }, 🍊去新节点 map 中找老节点索引,索引为:${ newIdx }`);
        if (newIdx === undefined) { 
          // 老节点不在新节点 map 中,删掉该虚拟节点代表的真实节点
          console.log(`新节点中该 key 不存在,${ oldVnode.key } 节点应该被干掉`);
          unmount(oldVnode);
        } else { 
          // 存在 key 相同的 Vnode
          console.log(`新节点中该 key 存在,${ oldVnode.key } 节点应该跟新节点做比对更新 dom`);

          // 1)做标记,为了防止老窗口的第 0 项走到这里导致此项为 0,我们记录 oldWinidx + 1
          //    不然数组元素默认为 0,就和没标记一样啦,故做了加 1 操作
          // 2)使用下标记录,保持相对顺序,我们生成最长增长子序列要用到这个排序哦
          newIndexToOldIndexMap[newIdx - newWinLeft] = oldWinidx + 1;
          
          // 则该 vnode 和 新的 vnode 去做比较,更新,但是 map 匹配,顺序没有保证,
          // 比如 C 在老窗口第一位,更新之后应该到老窗口第二位,所以在后面还会有移动元素的操作
          patch(oldVnode, newChildren[newIdx], container);
        }
      }

      console.log(newIndexToOldIndexMap, 'sssssssssss');
      // 针对标记数组(patch过为新)求最长递增序列 
      // 比如在我们收集到的数组是 [5, 3, 4, 0],代表着 [4, 2, 3] 的元素(前面多加了 1,这里要减去)是之前存在的,本次要修改!
      // 也就是 [D, E, C] 元素要修改,该方法计算得出 [1, 2],标识 [C, D] 新旧 vnode 中顺序正常,不需要更新顺序
      let increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
      console.log('👀 ', increasingNewIndexSequence);
      let whiteLen = increasingNewIndexSequence.length - 1; // 不需要移动节点的下标数组的length

      // 上面并没有遍历取到新增的节点,因为老窗口内不存在新节点的 key
      // 最后移动节点,处理新窗口比旧窗口新增的节点
      // newIndexToOldIndexMap 中为 0 的元素所以,即为新增的节点
      // 倒序插入的好处是,我们能把最后面的元素插入到容器底部,之后的往前摞就是 
      console.log(newIndexToOldIndexMap); // [5, 3, 4, 0],h 元素为新增
      for (let i = toBePatched - 1; i >= 0; i--) {
        // 找到新增元素的真正索引,注意记录的数组的所以是 0, 1, 2, 3,需要加上窗口左边界索引
        let currentIdx = i + newWinLeft; 
        // 拿到窗口内的元素 vnode,比如 E,C,D,H
        let child = newChildren[currentIdx]; 
        // 拿到当前节点的下一个元素作为参照物,进行元素 insert 或者 append 操作
        let anchor = currentIdx + 1 < newChildren.length ? newChildren[currentIdx + 1].el : null;

        // 看 newIndexToOldIndexMap 中当前元素是否为 0,0 标识没有被 patch 过,为新增元素
        if (newIndexToOldIndexMap[i] == 0) {
          // 处理 H 
          patch(null, child, container, anchor); // 往参照物前插入或给容器尾部追加元素
        } else {
          console.log(increasingNewIndexSequence, '咕咕', i, increasingNewIndexSequence[whiteLen]);
          if (i !== increasingNewIndexSequence[whiteLen]) {
            // H 元素插入之后,移动已经渲染到页面的 D,C,E 元素,D -> H,C -> D,E -> C,整个移动插入一遍(性能有问题)
            // 所以这里我们采用了最长增长子序列算法来解决这个问题~,匹配到最优移动算法,走下面。
            hostInsert(child.el, container, anchor);
          } else {
            // 不需要重新插入了,当前顺序 ok,每次匹配成功消耗掉一个下标
            // [1, 2],第一次是 2,一旦匹配到 2 后,就变成了 1
            // 跳过不需要移动的元素,为了减少移动操作,采取了最长增长子序列算法
            whiteLen--;
          }
        }
      }
    }
  }

  // 子节点比对(虚拟节点) diff 算法
  const patchChildren = function (n1, n2, container) {
    const c1 = n1.children;
    const c2 = n2.children;

    const prevShapeFlag = n1.shapeFlag; // n1 儿子节点类型
    const shapeFlag = n2.shapeFlag; // n2 儿子节点类型

    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 新节点儿子节点是文本
      // 新的儿子节点是文本,直接新的换掉老的儿子节点(不管是不是元素)
      // console.log('新节点的儿子节点为文本节点,干掉老节点的儿子节点');
      hostSetElementText(container, c2); // 直接干掉以前的
    } else { // 新节点儿子节点不是文本,说明是数组
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 之前和现在的儿子节点都是数组,对比数组
        // console.log('新旧节点的儿子节点都为数组,需要对比数组');
        patchKeyedChildren(c1, c2, container);
      } else {
        // 之前的是文本,现在是数组,需要先清空之前节点的文本,再进行渲染
        // console.log('旧节点的儿子节点为字符串,新节点儿子节点为数组,需要先清空字符串,再循环数组依次渲染数组内的子节点们');
        hostSetElementText(container, '');
        // 循环新节点的儿子数组,创建真实 dom
        console.log(c2);
        mountChildren(c2, container);
      }
    }
  }




  // diff 算法
  // 如果元素能直接复用,属性或者孩子不同,走这里进行 diff 比对
  const patchElement = function (n1, n2, container) {
    let el = n2.el = n1.el; // 复用 dom 节点
    const oldProps = n1.props || {};
    const newProps = n2.props || {};

    // 属性比对
    patchProps(el, oldProps, newProps);
    // 子节点比对
    patchChildren(n1, n2, el);
  }

  // 处理节点(subTree)的更新或渲染的方法
  const processElement = function (n1, n2, container, anchor) {
    if (n1 == null) {
      // 没有旧的 vnode,说明不是更新而是元素初次渲染(挂载)
      // 把虚拟 dom 变为真实 dom
      mountElement(n2, container, anchor);
    } else {
      // console.log('开始 diff 算法比对');
      // 说明元素能复用,但是属性或子节点不同
      // 开始 diff 算法,核心
      patchElement(n1, n2, container);
    }
  }

  const isSameVnode = (n1, n2) => {
    return n1.type == n2.type && n1.key == n2.key; // 是同一个元素  
  }

  // 从容器中删掉提供的虚拟节点
  const unmount = vnode => {
    hostRemove(vnode.el);
  }

  const patch = (n1, n2, container, anchor = null) => {
    // 判断 n1 和 n2 是同一个元素吗?对比 type 和 key
    // 初始化时 n1 为空,不进行比对
    // 节点改变,直接删除重来
    if (n1 && !isSameVnode(n1, n2)) {
      // 如果节点本身改变,直接干掉节点重新生成
      unmount(n1);
      // 清空 n1 后,走到下面 processElement 方法会重新生成 
      n1 = null;
    }

    // n2 可能为元素也可能是组件,我需要判断它的具体类型。
    const { shapeFlag } = n2;

    if (shapeFlag & ShapeFlags.ELEMENT) {
      // 当前虚拟节点为元素
      processElement(n1, n2, container, anchor);
    } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      // 当前虚拟节点为组件
      processComponent(n1, n2, container); // 处理组件的更新或渲染的方法
    }
  }

  // render 方法虚拟节点 -> 真实节点
  const render = (vnode, container) => {
    // 第一个参数为老的虚拟节点,第二个为虚拟节点,第三个为挂载容器, 第四个为插入节点的参照物
    patch(null, vnode, container)
  }

  return {
    createApp: createAppAPI(render),
    render
  }
}


新建 rumtime-core/src/apiCreateApi.ts - 根据组件配置,生成拥有 mount 方法的 app 对象。 - mount 方法做了两件大事,一个是对组件调用 createVnode 转成虚拟 dom,然后调用 render 方法,生成真实 dom 插入到容器中(subTree 的转虚拟 dom 是在 render 方法中哦,先调用 render 中的 h 方法转虚拟 dom,然后生成真实节点插入容器)。

rumtime-core/src/apiCreateApi.ts
import { createVnode } from './vnode';

// render 为将虚拟节点变成真实节点的函数
export function createAppAPI(render) {
  return (rootComponent, rootProps) => { // 返回一个函数
    // app 即为创建的应用,其上挂载了很多全局的方法
    // 比如 app.component, app.directives, app.mixin
    const app = {
      _component: rootComponent, // 根组件 稍后组件挂载前要校验组件是否有render函数或模板
      _props: rootProps, // 传给组件的 props 
      _container: null, // 要挂载的节点
      mount(container) {
        app._container = container; 
        // console.log(rootComponent, rootProps, container);
        // 1. 根据用户传入的组件生成一个虚拟节点
        const vnode = createVnode(rootComponent, rootProps);
        // 2. 将虚拟节点变成真实节点,插入到对应的容器中
        render(vnode, container);
      }
    };
  
    return app;
  }
}


新建 rumtime-core/src/vNode.ts - vnode 提供的 createVnode 方法是用来把接收到的节点(组件的配置对象或者子节点)转化为虚拟 dom 的。

rumtime-core/src/vNode.ts
import { isArray, isObject, isString, ShapeFlags } from "@ys-vue3/shared";

// type 可能为应用的对象 { setup: fn, ...  },也就是注册的根组件,也 h('h1', {}, 'xxx') 这样调用 type 为标签名。
// props 为注册组件时,传给组件的 data
// 本方法将根组件实例转为虚拟节点,虚拟节点的好处就是跨平台
export function createVnode(type, props, children = null) {
  let shapeFlag = 0; // 默认是 0,代表不认识

  if (isString(type)) {
    shapeFlag = ShapeFlags.ELEMENT; // 为字符串,是标签渲染
  } else if (isObject(type)){
    shapeFlag = ShapeFlags.STATEFUL_COMPONENT; // 为对象,说明是组件注册
  }

  // 虚拟节点必须要有的属性: tag props children key _v_isVnode
  const vnode = {
    _v_isVnode: true,
    type, // 对于组件而言,type 就是个对象
    props: props, 
    children, // 对组件而言,它的 children 就是插槽
    key: props && props.key, // key 取自 props.key,不写就为 undefined
    el: null, // vnode 对应的真实节点,dom diff 后要更新该节点
    shapeFlag,
    component: null // 当前组件的实例
  }

  normalizeChildren(vnode, children); // 收集儿子节点的 shapeFlag

  // console.warn('虚拟节点', vnode);
  return vnode;
};

// 将儿子的类型一并记录在 vnode 中的 shapeFlag
function normalizeChildren(vnode, children) { 
  if (children == null) { // 组件的 children 默认为 null
    // 没有儿子 不用处理儿子的情况
  } else if (isArray(children)) { // h('h1', {}, [span1, span2, span3])
    // 儿子是数组,就使用 "按位或" 完成状态组合,这里简写为 "或等于"
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  } else { // h('h1', {}, '文本')
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN;
  } 
}


修改 shared/src/index.ts - 声明虚拟节点的 shapeFlag 标识的枚举,利用位运算标识组件特征。 - 最长子序列算法我们丢在了这里,源码是放在 renderer.ts 中的,太长了,接收一个在旧 children Vnode 列表相对顺序的数组,返回一个某几个节点不需要移动,就能维持旧 children Vnode 列表相对顺序的下标列表。

修改 shared/src/index.ts
// ... 略 

// 用于标识组合组件能力 
// 1 << 1 代表二进制的 1 往左移一位,也就是 10 -> 2,同理 << 2 代表移两位
// 按位与 "|",不足最大位前面补 0,有一个是 1,则结果是 1
// 100 | 10 也就是 100 | 010 也就是 110,表示它是带状态的函数组件组件(组合)
// 按位或 "&",不足最大位前面补 0,都是 1 才转为 1,则结果是 1 
// 用来判断某个二进制数是否包含另一个数,比如 110 & 100 为 100 为 true,110 & 010 也为 true,表示它是带状态的函数组件组件(组合)
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, // 是否需要 keep-alive
  COMPONENT_KEPT_ALIVE = 1 << 9, // 组件的 keep-alive
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 100 | 10 就是 110
}

// const arr = [2, 3, 1, 5, 6, 8, 7, 9, 4]

// 求一个列表最长递增的子序列(获取不移动哪些下标的元素,可以更少操作去得到升序列表)
export function getSequence(arr) { // 最终结果是索引
  const len = arr.length;
  const idxs = [0]; // 索引,拿首位的索引跟之后做对比,我们递增子序列的递增就是索引的递增
  const p = arr.slice(); // 和原数组相同,用来存放索引
  let start;
  let end;
  let middle;

  for (let i = 1; i < len; i++) {
    // 0 代表新增元素,不参与比对
    if (arr[i] == 0) continue; 

    let LastIndex = idxs[idxs.length - 1];

    if (arr[i] > arr[LastIndex]) {
      // 如果当前元素大于已收集数组的尾元素(也是下标)所代表的值,让这个元素记录前一个尾元素的下标
      // 比如图示中 3 需要收集 2 的下标。
      p[i] = LastIndex;
      // 收集下标
      idxs.push(i);
      continue;
    } else {
      // 二分查找,找到第一个比当前值大的值,替换掉 
      start = 0;
      end = idxs.length - 1;

      while(start < end) { 
        // 舍弃尾部小数 1.7 | 0 = 1
        middle = ((start + end) / 2) | 0; 

        if (arr[idxs[middle]] < arr[i]) {
          start = middle + 1;
        } else {
          end = middle;
        }
      }

      // start 或者 end 就是找到的位置,更新收集的下标列表
      // 如果二分查找找到的值大于当前值,替换成当前值的下标(贪心,该值潜力更大)
      if (arr[idxs[start]] > arr[i]) {
        if (start > 0) {
          // 比对到 1 时,虽然此时匹配到 2,start = 0,但是它们都没有前驱结点,不需要替换前驱结点
          // 比如 4 替换 5,需要找到 5 的前一个元素,start 代表目标值 5, 那么 start - 1 是前一个元素
          p[i] = idxs[start - 1];
        }
        idxs[start] = i;
      }
    }
  }

  // console.log(idxs); // [ 2, 1, 8, 4, 6, 7 ] 这个是下标哈
  // console.log(p); // [2, 0, 1, 1, 3, 4, 4, 6, 1]

  // 标记完毕后,我们得到了 idxs 数组和 p 数组,这时候需要倒序遍历去组合真正的结果
  let idxsLen = idxs.length;

  // 收集到的下标最后一项,就是 9 所处的下标了
  let lastIdx = idxs[idxsLen - 1]; 

  // 通过 9 一路找前驱结点,串起来就是正确结果
  while(idxsLen-- > 0) {
    idxs[idxsLen] = lastIdx; // 把当前下标替换为上个节点下标
    lastIdx = p[lastIdx]; // 取记录在 p 数组中的当前节点的上一个结点的下标
  }

  return idxs; // [ 0, 1, 3, 4, 6, 7 ] 换算成值也就是 [2, 3, 5, 6, 7, 9]
}


新建 rumtime-core/src/component.ts - 创建组件实例,解析 setup,拿到 render 方法挂载到实例上。

rumtime-core/src/component.ts
import { isObject, isFunction, hasOwn } from "@ys-vue3/shared/src";
import { componentPublicInstance } from "./componentPublicInstance";

let uid = 0;
export function createComponentInstance(vnode) {
  const instance = {
    uid: uid++,
    vnode: vnode, // 实例上的 vnode 就是我们处理过的 vnode
    type: vnode.type, // 用户写的组件的内容,大对象 { setup: fn, ... }  
    props: {}, // 组件里用户用到过的属性
    attrs: {}, // 用户没用到的 props,就会放到 attrs 中
    slots: {},
    setUpState: {}, // setup 返回值,可能为函数(render 函数)或者对象
    proxy: null, 
    emit: null, // 组件通信
    ctx: {}, // 上下文
    isMounted: false, // 组件是否挂载
    subTree: null, // 组件对应的渲染内容
    render: null
  }

  // 将自己放到了实例的 ctx 属性上,加了一层下划线仅仅表示这是内部属性
  instance.ctx = { _: instance } 

  return instance;
}

export function setupComponent(instance) {
  let { props, children } = instance.vnode;

  // 初始化属性 initProps,这里属性应该是响应式的,我们再此留个口子
  instance.props = props;
  // 初始化插槽 initSlots,这里插槽应该是响应式的,我们再此留个口子
  instance.slots = children;

  // 不代理的话 instace.ctx.props.a  instace.ctx.attrs.a 好长呀
  // 我们希望 instance.proxy.a 可以自动去查找当前组件的属性,而且区分优先级
  instance.proxy = new Proxy(instance.ctx, componentPublicInstance);

  // 初始化带状态的组件(组件分为函数组件和带状态的组件)
  // setup 就是带状态的组件,我们可以在里面写一些组合式API,reactive 等。
  // 函数式组件没有自己的 data,且要加上 functional: true
  setupStatefulComponent(instance);
}

// 创建 setup 函数的第二个参数 ctx
function createSetupContext(instance) {
  return {
    attrs: instance.attrs,
    slots: instance.slots,
    emit: instance.emit,
    expose: () => {}
  }
};

// 处理 setup 返回结果
function handleSetupResult(instance, setupResult) {
  // 如果 setup 返回结果是对象,放到实例的 setupState 属性中
  // 如果 setup 返回结果是函数(render 函数),放到实例的 render 属性中
  if (isObject(setupResult)) {
    instance.setupState = setupResult;
  } else if (isFunction) {
    instance.render = setupResult;
  }

  // render 相关挂载到 instance 后
  finishComponentSetup(instance);
}

// 解析完用户提供的 setup 返回结果后调用的函数(结果都挂到实例上了)
function finishComponentSetup(instance) {
  // 处理后可能 instance 上可能依旧没有 render
  // 1) 比如没写 setup,或者写了 setup 返回值没有命中函数或对象
  // 2) setup 返回一个对象,但是没额外提供 render 函数,这时候需要进行模板编译了
  let Component = instance.type;

  if (!instance.render) { // 实例上没有挂载 render 方法
    if (!Component.render && Component.template) {
      // 如果组件又没有额外提供 render 方法,则判断是否提供模板
      // 需要将 template 变成 render 函数  compileToFunctions()
    }
    
    instance.render = Component.render; // 把用户提供的 render 方法作为 render 函数
  }

  // console.log('当当', instance.render);
}


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

  if (setup) { // 说明对象提供了 setup 方法
    let setupContest = createSetupContext(instance);

    // setup 默认有两个参数,分别是 props 和 ctx
    let setupResult = setup(instance.props, setupContest);
  
    // 注意 setup 如果返回函数,作为 render 方法使用。
    // 也可能返回一个对象(需要额外提供一个 render 函数,该对象就会作为 props 传入 render 函数,
    // 因为 runtime-dom 是运行时的,代码没有 compiler 能力,不能生成 render 函数)
    // 如果二者都写,函数优先级最高,也就是优先采用函数作为 render 函数
    handleSetupResult(instance, setupResult);
  } else {
    // 用户可能没写 setup 方法,直接用外面的 render 或者根据模板生成 render
    finishComponentSetup(instance); 
  }
}


新建 rumtime-core/src/componentPublicInstance.ts - 提供代理器对象,以便通过 instance.proxy.x 去取值,先找自己的状态(setup.props),再向上下文中查找,再向属性中查找。

rumtime-core/src/componentPublicInstance.ts
import { hasOwn } from "@ys-vue3/shared/src";

export const componentPublicInstance = {
    get({ _: instance }, key) {
        const { setupState, props , ctx } = instance;

        if (hasOwn(setupState, key)) { // 先自己的状态,再向上下文中查找,再向属性中查找h('标签名', ...)
            return setupState[key];
        }else if (hasOwn(ctx, key)) {
            return ctx[key];
        } else if (hasOwn(props, key)) {
            return props[key];
        }
    },
    set({ _: instance }, key, value) {
        const { setupState, props } = instance;
        if (hasOwn(setupState, key)) {
            setupState[key] = value;
        }  else if (hasOwn(props, key)) {
            props[key] = value
        }
        return true;
    }
}


新建 rumtime-core/src/h.ts - 提供生成虚拟 dom 的 h 方法(用户配置的 render 函数中调用),类似于 Vue2 中的 _c 或者说是 createElement 方法,该方法生成虚拟 dom。 - h 方法调的还是 createVnode 方法,创建 app 时调用了一次把组件转为虚拟 dom,而用户配置的 h 方法是用来把 subTree 转为虚拟 dom 的。 - h 方法不提供深层次的递归,所以它仅仅处理当前节点和 children 的关系,并把当前节点和整理好的 children 数组传给 createVNode,如果 children 内元素也是个标签,则需要手动传入 h("div", [h("span", "巴拉巴拉")])

rumtime-core/src/h.ts
import { isArray, isObject } from "@ys-vue3/shared/src";
import { createVnode } from "./vnode";

function isVnode(vnode) {
  return vnode.__v_isVNode == true
}

// h 是创建虚拟 dom,它等价于 createVnode,最后调的就是 createVnode
// createAPP 函数内部调用 createVnode,是把组件本身生成虚拟 dom
// 用户提供的 render 内部的 h 方法是为了生成 subTree(组件内部内容) 的虚拟 dom
export function h(type, propsOrChildren, children) {
  // 第一个一定是类型,第二个参数可能是属性,可能是儿子,后面的一点是儿子
  // 一个儿子的情况可以写文本,多个儿子的情况可以写 数组 或者 多个(须标明第二个参数:属性)
  // h('h1', 'hello')
  // h('h1', ['hello', 'ys'])
  // h('h1', {}, 'hello', 'ys')
  // 虚拟节点也可以 h('h1', h('span', 111)), h('h1', {}, h('span', 111))
  const l = arguments.length;

  if (l == 2) { // h((div',h('p'))
    // 如果第二个参数为对象,可能是数组或者虚拟节点或函数..
    if (isObject(propsOrChildren) && !isArray(propsOrChildren)) { // 对象非数组
      if (isVnode(propsOrChildren)) {  // 虚拟节点
        return createVnode(type, null, [propsOrChildren]) 
      } else { // 其他对象
        return createVnode(type, propsOrChildren)
      }
    } else { // 数组
      return createVnode(type, null, propsOrChildren)
    }
  } else {
    // 如果 l > 3
    if (l > 3) {
      children = Array.from(arguments).slice(2);
    } else if (l == 3 && isVnode(children)) {
      // length 为3,children 可能是文本或数组或虚拟节点
      // 文本字符串就不需要包装成数组了,因为文本可以直接 innerHTML
      // 虚拟节点包装成数组
      children = [children];
    }

    return createVnode(type, propsOrChildren, children);
  }
}


修改 rumtime-core/src/index.ts - core 模块就要把 reactivity 模块全部导出来(其实源码内部没有导出 effct),供用户从 runtime-dom 模块引用。

rumtime-core/src/index.ts
export { createRenderer } from './renderer'; // 导出渲染器 生成 { createApp, render }
export { h } from './h'; 

// 其实源码没有导出 effct,这里为了方便都导出了
export * from '@ys-vue3/reactivity';


测试代码

code
<!-- vue3 子包 runtime-core 测试 -->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="app"></div>
  <!-- <script src="node_modules/@vue/runtime-dom/dist/runtime-dom.global.js"></script> -->
  <script src="../node_modules/@ys-vue3/runtime-dom/dist/runtime-dom.global.js"></script>
  <script>
    // VueRuntimeDOM 依赖了 runtime-core, runtime-core 依赖了 reactivity
    const { createApp, h, reactive, toRefs, ref } = VueRuntimeDOM;

    const App = {
      props: {
        age: Number
      },
      // 方式一
      // setup(props, ctx) {
      //   return () => {
      //     return h('h1', '1000'); // subTree 
      //   }
      // }

      // 方式二
      setup(props, ctx) {
        const state = reactive({ name: 'ys', age: 12 });


        const flag = ref(true);

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

        const handleclick = () => {
          state.name = 'sy';
        }

        return {
          // 为了保证用户随意解构使用,不至于得到字符串或数字,这里进行 torefs 转换
          ...toRefs(state),
          handleclick,
          flag
        };
      },
      render(proxy) {
        let { name, age, handleclick, flag } = proxy;

        // return h('h1', this.a);

        // 测试渲染真实 dom
        // return h('div', {}, [h('p', { style: { color: 'red' }}, 'hello world')]); 

        // 测试渲染动态属性,绑定事件
        // return h('div', {}, [h('p', { style: { color: 'red' }, onClick: handleclick }, `${ name.value } 今年 ${ age.value } 岁了`)]); // 测试动态属性

        // 调试 diff 算法,1s后,div 变成了 p 标签(基础对比之最顶层 subTree 改变)
        // if (flag.value) {
        //   return h('div', 'hello world');
        // } else {
        //   return h('p', 'hello world');
        // }


        // 调试 diff 算法,1s后,父节点相同,标签颜色改变(属性改变)
        // if (flag.value) {
        //   return h('div', { style: { color: 'red' }}, 'hello world');
        // } else {
        //   return h('div', { style: { color: 'blue' }}, 'hello world');
        // }

        // // 调试 diff 算法,1s后,父节点相同,文本内容(儿子节点为文本内容的改变)
        // if (flag.value) {
        //   return h('div', 'hello world123');
        // } else {
        //   return h('div', 'hello world456');
        // }

        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(文本变数组)
        // if (flag.value) {
        //   return h('div', 'hello world123');
        // } else {
        //   return h('div', [h('p', 'hello'), h('span', 'world')]);
        // }

        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(数组变数组)
        // if (flag.value) {
        //   return h('div', ['hello world123']);
        // } else {
        //   return h('div', ['hello world456']);
        // }

        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(数组变数组,前插入,少变多)
        // if (flag.value) {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //   ])
        // } else {
        //   return h('div', [
        //     h('li', { key: 'C' }, 'C'),
        //     h('li', { key: 'D' }, 'D'),
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //   ])
        // }


        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(数组变数组,后插入,少变多)
        // if (flag.value) {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //   ])
        // } else {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //     h('li', { key: 'C' }, 'C'),
        //     h('li', { key: 'D' }, 'D'),
        //   ])
        // }

        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(数组变数组,后插入,多变少)
        // if (flag.value) {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //     h('li', { key: 'C' }, 'C'),
        //     h('li', { key: 'D' }, 'D'),
        //   ])
        // } else {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //   ])
        // }

        // 调试 diff 算法,1s后,父节点相同,儿子节点替换(数组变数组,复杂乱序修改比对)
        // if (flag.value) {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //     h('li', { key: 'C' }, 'C'),
        //     h('li', { key: 'D' }, 'D'),
        //     h('li', { key: 'E' }, 'E'),
        //     h('li', { key: 'F' }, 'F'),
        //     h('li', { key: 'G' }, 'G'),
        //   ])
        // } else {
        //   return h('div', [
        //     h('li', { key: 'A' }, 'A'),
        //     h('li', { key: 'B' }, 'B'),
        //     h('li', { key: 'E' }, 'E'),
        //     h('li', { key: 'C' }, 'C'),
        //     h('li', { key: 'D' }, 'D'),
        //     h('li', { key: 'H' }, 'H'),
        //     h('li', { key: 'F' }, 'F'),
        //     h('li', { key: 'G' }, 'G'),
        //   ])
        // }
        if (flag.value) {
          return h('div', [
            h('li', { key: 'A' }, 'A'),
            h('li', { key: 'B' }, 'B'),
            h('li', { key: 'C', style: { color: 'yellow' }}, 'C'),
            h('li', { key: 'D' }, 'D'),
            h('li', { key: 'E' }, 'E'),
            h('li', { key: 'Q' }, 'Q'),
            h('li', { key: 'F' }, 'F'),
            h('li', { key: 'G' }, 'G'),
          ])
        } else {
          return h('div', [
            h('li', { key: 'A' }, 'A'),
            h('li', { key: 'B' }, 'B'),
            h('li', { key: 'E' }, 'E'),
            h('li', { key: 'C', style: { color: 'green' }}, 'C'),
            h('li', { key: 'D' }, 'D'),
            h('li', { key: 'H' }, 'H'),
            h('li', { key: 'F' }, 'F'),
            h('li', { key: 'G' }, 'G'),
          ])
        }
      }
    }

    // createApp 将虚拟节点变成真实节点,最后插入到元素中
    createApp(App, { name: '测试属性', age: 12 }).mount('#app');
  </script>
</body>

</html>

实现异步批量更新

那么问题又来了,如果我们修改测试代码

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

那么它将更新四次,这显然也不是我们想要的,我们想到了框架常用的异步批量更新~

和 Vue2 类似,我们试图在属性更改后,不直接去做更新,而是包在一个异步任务中,而我们要想能控制 effct 执行,这里就用到了 effctOptions 中的 schedular。

// 创建渲染用的 effct(首次执行是进行渲染)
const setupRenderEffct = function (instance, container) {
  // 每次状态变化后,都会重新执行 effct
  effect(function componentEffct() {
    if (!instance.isMounted) {
      let subTree = instance.subTree = instance.render.call(instance.proxy, instance.proxy);

      patch(null, subTree, container);

      instance.isMounted = true;
    } else {
      console.log('修改了数据,需要重新生成虚拟 dom,然后进行 diff 算法');
      const prevTree = instance.subTree;
      const nextTree = instance.render.call(instance.proxy, instance.proxy);

      let subTree = nextTree;

      patch(prevTree, subTree, container);
    }
  }, {
    schedular: queueJob // 更新走这里~ 去调度 effct 执行
  });
}

ok,那让我们来实现这个 queueJob 方法~

let queue = []; // 任务队列,schedular 执行时,缓存一个个的 effct
let isFlusing = false;

// 真正的更新,是包在微任务中的,当同步代码(比如用户改变属性的四次操作)执行完毕后
// 这个才开始执行哦~ 同步代码执行期间一直是锁着的,也就是 isFlusing 一直是 true~
// 用户改 100 次,其实也就触发了一次 isFlusing 的执行,同步代码执行完毕后,微任务执行,
// 此时再做更新操作,类似 Vue2 中的 watcher schedular
function flushJobs() {
  isFlusing = false; // 放开锁~
  queue.sort((a, b) => a.id - b.id); // 排序,谁先创建谁先更新(父子节点的执行顺序)

  for (let i = 0; i < queue.length; i++) {
    queue[i]();
  }

  queue.length = 0;
}

// 异步更新
function queueFlush() {
  if (!isFlusing) { // 控制执行频率,如果没锁,就锁住并执行异步更新~
    isFlusing = true; 
    Promise.resolve().then(flushJobs); // 转成微任务去更新~
  }
}

// 为了实现异步更新的 effctOptions.schedular 任务。
// 批量处理,多次更新先缓存去重,之后异步更新
// 每个 job 都是一个 effct
function queueJob(job) {
  if (!queue.includes(job)) { // 去重
    queue.push(job);

    queueFlush(); // 异步更新
  }
}

ok,这样就完成了一个异步的更新,它会合并任务中属性的多次同步更改。

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

这里只更新一次,如果想更新多次,可以这样

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

setTimeout(() => {
  flag.value = true;
}, 2000);

生命周期的使用和实现

生命周期函数的使用

我们新建文件 6.lifecycle.html

<!DOCTYPE html>
<html lang="en">
<body>
  <div id="app">{{ count }}</div>
  <script src="./node_modules/vue/dist/vue.global.js"></script>
  <script>
    const { createApp, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, reactive } = Vue;

    const App = {
      beforeCreate() { // vue3 兼容了,并存,但是不提供函数式调用
        console.log('brfore create');
      },
      created() { // vue3 兼容了,并存,但是不提供函数式调用
        console.log('created');
      },
      setup() { // setup 本身取代了 beforeCreate,并把 props 和 ctx 传入哦
        const state = reactive({ count: 0 });

        onBeforeMount(() => {
          let instance = getCurrentInstance();
          console.log('挂载前', instance);
        });

        onMounted(() => {
          console.log('挂载完成');
        });

        onBeforeUpdate(() => {
          console.log('更新前');
        });

        onUpdated(() => {
          console.log('更新后');
        });

        setTimeout(() => {
          state.count++;
        }, 1000);

        return state; // 返回对象代表供组件使用的
      }
    }

    createApp(App).mount('#app');
  </script>
</body>

</html>

可以看到,setup 本身替代了 vue2 中的 beforeCreate 和 created。

生命周期内获取实例 this

Vue3 给我们提供了 getCurrentInstance 方法用于获取组件实例。

const { getCurrentInstance } = Vue;

const App = {
  setup() {
    // ...
    onBeforeMount(() => {
      let instance = getCurrentInstance();
      console.log('挂载前', instance); // instance.bm 就是 onBeforeMount 数组
    }); 
    // ...
  }
}

那它是怎么区分不同组件间的 instance 呢,比如下面这种情况,如何保证组件拿到的是自己的实例。

const App1 = {
  setup() {
    // ...
    onBeforeMount(() => {
      let instance = getCurrentInstance();
      console.log('app1 挂载前', instance);
    }); 
    // ...
  }
}

const App2 = {
  setup() {
    // ...
    onBeforeMount(() => {
      let instance = getCurrentInstance();
      console.log('app2 挂载前', instance);
    }); 
    // ...
  }
}

其实它的做法很简单,就是在执行每个应用的 setUp 之前先将实例暴露到全局上,之后调用 setup,内部会调用生命周期,这时候会创建一个函数保存当前的实例,真正生命周期钩子调用的时候会把闭包中保存的函数交还给全局,所以 getCurrentInstance 拿到的是全局的组件实例对象,等待 setup 调用完毕后,销毁全局的 instance。

修改 rumtime-core/src/component.ts - setup 函数执行前,在其执行上下文中声明 currentInstance 变量

rumtime-core/src/component.ts
// 这就是 getCurrentInstance 方法啦
export const getCurrentInstance = ()=>{
  return currentInstance
}

// 修改全局作用域的 currentInstance
export const setCurrentInstance = (instance)=>{
   currentInstance = instance
}

// ...

export let currentInstance; // 暴露当前组件实例

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

  if (setup) { // 说明对象提供了 setup 方法
    let setupContest = createSetupContext(instance);

    currentInstance = instance;
    // 函数内部缓存了 currentInstance(闭包),可以随时用哦
    let setupResult = setup(instance.props, setupContest);
    currentInstance = null;

    handleSetupResult(instance, setupResult);
    
  } else {
    // 用户可能没写 setup 方法,直接用外面的 render 或者根据模板生成 render
    finishComponentSetup(instance); 
  }
}


新增 rumtime-core/src/aplLifecycle.ts - 生命周期函数注册 - 创建函数保存当前实例 - hooks 收集,挂载到实例上,比如 instance.bm,值是个数组

rumtime-core/src/aplLifecycle.ts
import { currentInstance, setCurrentInstance } from "./component";

// 生命周期相关
const enum LifeCycles {
  BEFORE_MOUNT = 'bm',
  MOUNTED = 'm',
  BEFORE_UPDATE = 'bu',
  UPDATED = 'u'
}

/**
 * @description 暂存 target
 * @param lifecycle 生命周期名
 * @param hook 钩子函数
 * @param target 当前生命周期对应的组件的实例
 * @returns Function
 */
function injectHook(lifecycle, hook, target) {
  // 后面可能是先渲染儿子,此时 currentInstance 已经变成儿子了,但是创建时的 target 永远都是缓存当前的 instance
  if (!target) {
    return
  }
  const hooks = target[lifecycle] || (target[lifecycle] = []); // 有就用之前的,没有改成空数组 { bm: [] }
  const wrap = () => {
    setCurrentInstance(target); // 修复 instance 为当前 injectHook 函数缓存的 instance
    hook(); // 执行生命周期前 用存储的正确的实例替换回去,保证instance正确性
    setCurrentInstance(null);
  }
  hooks.push(wrap);
}

function createHook(lifecycle) { // []  => currentInstance
  return function (hook, target = currentInstance) { 
    console.log(target, '🍇');
    // 利用函数的闭包特性,缓存当前的 target
    injectHook(lifecycle, hook, target)
  }
}

// 执行函数
export function invokeArrayFns(fns) {
  fns.forEach(fn => fn()); 
}

export const onBeforeMount = createHook(LifeCycles.BEFORE_MOUNT);
export const onMounted = createHook(LifeCycles.MOUNTED);
export const onBeforeUpdate = createHook(LifeCycles.BEFORE_UPDATE);
export const onUpdated = createHook(LifeCycles.UPDATED);


新增 rumtime-core/src/renderer.ts - 组件挂载前后执行收集到的 onBeforeMount、onMounted - 组件更新前后执行收集到的 onBeforeUpdate、onUpdated

rumtime-core/src/renderer.ts
 const setupRenderEffct = function (instance, container) {
    // 每次状态变化后,都会重新执行 effct
    effect(function componentEffct() {
      if (!instance.isMounted) {
        // effct 默认执行一次,组件未被挂载过 执行组件渲染
        // 执行 onBeforeMount 和 onMounted 生命周期
        let { bm, m } = instance; // bm 和 m 都是数组哦

        // 挂载前生命周期
        bm && invokeArrayFns(bm);

        // 我们使用 call 方法,保持了 setup 中 this = proxy, 返回虚拟 dom 
        // 调用的是用户提供的 render 方法,生成 subTree 的虚拟 dom
        let subTree = instance.subTree = instance.render.call(instance.proxy, instance.proxy);

        // subTree 虚拟 dom 渲染成真实节点
        patch(null, subTree, container);

        instance.isMounted = true;

        // 挂载后生命周期
        m && invokeArrayFns(m);
      } else {
        // 数据改变,effct 重新执行

        // 执行 onBeforeUpdate 和 onUpdated 生命周期
        let { bu, u } = instance;

        // 更新前生命周期
        bu && invokeArrayFns(bu);

        console.log('修改了数据,需要重新生成虚拟 dom,然后进行 diff 算法');
        // 旧的 subTree 虚拟 dom
        const prevTree = instance.subTree;
        // 新的 subTree 虚拟 dom
        const nextTree = instance.render.call(instance.proxy, instance.proxy);

        // 重新渲染
        let subTree = nextTree;

        // subTree 虚拟 dom 渲染成真实节点,这里进行 diff 算法
        patch(prevTree, subTree, container);


        // 更新后生命周期
        u && invokeArrayFns(u);
      }
    }, {
      schedular: queueJob // 更新走这里~ 去调度 effct 执行
    });
  }


这样,四个常用的生命周期函数就实现啦。

watch API 的使用和实现

watch 和 watchEffct 的用法

const { watch, reactive, watchEffect } = VueRuntimeDOM;

const state = reactive({ count: 0 });

// 监听一个劫持过的数据,可以传参,不传参数则输出 4 0
//  + immediate 代表立即执行一次,0 undefined
//  + flush 刷新方式 
//      + sync 同步执行,不会有异步合并的效果了
//      + pre 渲染前执行
//      + post 渲染后执行
watch(() => state.count, function(newValue, oldValue) {
  console.log(newValue, oldValue);
}, { immediate: true });

// 只要数据变了,立刻作为依赖执行,但是有异步合并更新的逻辑(先输出 0,再输出 4)
watchEffect(() => {
  // 里面取值,触发了 state.count 收集 effct
  // 当 state.count 改变,执行 watchEffect 
  console.log(state.count); 
});

// 只执行一次,输出 4 0
setTimeout(() => {
  state.count = 0
}, 1000);

watch 和 watchEffct 的实现

新增 rumtime-core/src/apiWatch.ts.ts - 调用 watch 方法会创建一个带有 schedular 选项的 lazy effct,如果传入 immediate: true 配置,会做初始化默认执行操作~ 属性更新会走 schedular 进行用户回调的触发 - effctWatch 实现基本同 watch 方法,不过该方法首个参数即为用户回调,初始化时执行 runner,该方法结尾调用用户的回调,所以初始化它默认执行一次,其后更新会走 schedular 进行用户回调的触发,它和 effct 最大的区别在于,可以合并多次同步的更新~

rumtime-core/src/apiWatch.ts.ts
// 实现 watch 和 watchEffect api

import { effect } from "@ys-vue3/reactivity/src";
import { hasChanged } from "@ys-vue3/shared/src";

// 核心属性是 flush 刷新方式和 immediate 是否立即调用
function doWatch(source, cb?, options?) { 
  let oldValue; // 缓存旧值

  // 定义 schedular 函数
  let schedular = () => {
    if (cb) {
      const newValue = runner(); // 重新取值

      if (hasChanged(newValue, oldValue)) {
        cb(newValue, oldValue);
        oldValue = newValue;
      }
    } else { // watchEffect 上来就要执行一次
      source();
    }
  }

  // effct 返回一个函数,函数调用会执行用户的 fn(这里是 getter 函数),并把结果作为返回时
  let runner = effect(() => source(), {
    lazy: true, // effct 首次不自动执行
    schedular
  });

  if (options && options.immediate) {
    schedular();
  }

  oldValue = runner();
}

// source 为监听的数据源,可以是个具有返回值 getter 函数
// 也可以是个元素为 ref 的数组,我们这里暂时不考虑数组形式
export function watch(source, cb, options) {
  return doWatch(source, cb, options);
};

export function watchEffect(source) {
  return doWatch(source, null, {} as any);
}


我们好像还差异步更新的能力哦~

修改 rumtime-core/src/apiWatch.ts.ts - 属性改变时,先进行更新任务的收集,同时加锁,并使用微任务包裹更新任务列表的执行 - 同步修改属性的操作执行完毕,开始执行微任务,更新任务的列表依次执行,因为异步更新任务最后计算 newVal 会取最新的属性值,所以能保证最后取值的正确性。

rumtime-core/src/apiWatch.ts.ts
/// 实现 watch 和 watchEffect api

import { effect } from "@ys-vue3/reactivity/src";
import { hasChanged } from "@ys-vue3/shared/src";
// ------------------------
let queue = new Set();
let lock = false;

const patchQueue = function(queue) {
  queue.forEach(fn => fn());

  lock = false;
  queue.clear();
}

const queueJob = function() {
  if (!lock) {
    const { cb, runner, oldValue, schedular, flush } = schedularData;

    lock = true; // 锁住
    // 闭包缓存当前 schedularData 中的属性们
    const fn = () => {
      return schedular();
    }
  
    queue.add(fn); 

    // 配置了 flush: sync 不进行异步更新
    if (flush == 'sync') { 
      patchQueue(queue); // 直接执行
    } else {
      // 包装为微任务
      Promise.resolve().then(() => patchQueue(queue) as any);
    }
  }
}
// --------------------------
// 闭包缓存 schedular 函数需要的参数
let schedularData = { cb: () => {}, runner: () => {}, oldValue: null, schedular: () => {}, flush: null };

// 核心属性是 flush 刷新方式和 immediate 是否立即调用
function doWatch(source, cb?, options?) { 
  let oldValue; // 缓存旧值

  // 定义 schedular 函数
  let schedular = () => {
    if (cb) {
      const newValue = runner(); // 重新取值

      if (hasChanged(newValue, oldValue)) {
        cb(newValue, oldValue);
        oldValue = newValue;
      }
    } else { // watchEffect 上来就要执行一次
      source();
    }
  }

  // effct 返回一个函数,函数调用会执行用户的 fn(这里是 getter 函数),并把结果作为返回时
  let runner = effect(() => source(), {
    lazy: true, // effct 首次不自动执行
    schedular: queueJob
  });

  if (options && options.immediate) {
    schedular();
  }

  if (options && options.flush && options.flush) {
    schedularData.flush = options.flush;
  }

  oldValue = runner();

  // 收集更新时 schedular 需要的参数
  schedularData = Object.assign(schedularData, { cb, runner, oldValue, schedular });
}

// source 为监听的数据源,可以是个具有返回值 getter 函数
// 也可以是个元素为 ref 的数组,我们这里暂时不考虑数组形式
export function watch(source, cb, options) {
  return doWatch(source, cb, options);
};

export function watchEffect(source) {
  return doWatch(source, null, {} as any);
}