Skip to content

Vue3 源码解析

看不懂来打我,vue3如何将template编译成render函数 (qq.com)

推荐 7 个 Vue2、Vue3 源码解密分析的开源项目 👍 · Issue #35 · FrontEndGitHub/FrontEndGitHub

Vue3源码解析 (diy4869.github.io)

Vue3 的响应式和以前有什么区别,Proxy 无敌?(源码级详解) (qq.com)

从零带你手把手实现Vue3响应式原理-上(非玩具) (qq.com)

从零带你手把手实现Vue3响应式原理-下(Map和Set的处理) (qq.com)

深度解析:Vue3如何巧妙的实现强大的computed - 掘金 (juejin.cn)

我从 17w star 的 Vuejs 中学到了什么? (qq.com)

精读《Vue3 DOM diff 最长上升子序列》 (qq.com)

从 Vue3 源码中那些实用的基础工具函数中,我学到了什么? (qq.com)

让 Vue.js 3.2 创建节点提升 200% 速度的秘密 (qq.com)

老外挥手百行代码,Vue.js 3.2 响应式性能暴增 (qq.com)

mp.weixin.qq.com/s?__biz=MzI3NTM5NDgzOA==&mid=2247497462&idx=2&sn=807dae4d0f4716db0fb5784be416efeb&chksm=eb07ce8fdc7047993109478c692d3ede64de269b131e75c9338c3acf389a8883cc67bdfc7ddf&token=431470234&lang=zh_CN#rd

字节一面,面试官问我Vue3源码,我说…… - 掘金 (阿崔cxr)

秋招解决方案:深入 Vue3 源码,带你彻底打通 Vue3 源码面试 - 掘金 (juejin.cn)

面试官:Vue3响应式系统都不会写,还敢说精通? - 掘金 (juejin.cn)

面试官:说一说 vue3 的快速 diff 算法(一) - 掘金 (juejin.cn)

面试官:说一说 vue3 的快速 diff 算法(二) - 掘金 (juejin.cn)

Vue3 编译原理直通车💥——parser 篇 - 掘金 (juejin.cn)

Vue3 编译原理直通车💥——transform 篇 - 掘金 (juejin.cn)

Vue3 编译原理直通车💥—— generate 篇 - 掘金 (juejin.cn)

vue3 提速小巧思🚀,值得一提的编译优化! - 掘金 (juejin.cn)

Vue3 响应式只知道 Proxy?快来学点新技巧! - 掘金 (juejin.cn)

组件库源码解析

深入解析 Vue 3 组件库 element-plus 架构源码 (qq.com)

详解三种 Diff 算法(源码+图) - 掘金 (juejin.cn)

Vue3 设计思想

Vue3.0 更注重模块上的拆分,在 2.0 中无法单独使用部分模块。需要引入完整的 Vuejs(例如只想使用使用响应式部分,但是需要引入完整的 Vuejs), Vue3 中的模块之间耦合度低,模块可以独立使用。拆分模块

  • Vue3将很多功能都设计成了单独的模块,比如可以直接import { ref, reactive } from 'vue'使用响应式的方法,模块间耦合度低,可以独立使用;而Vue2没办法单独使用部分模块,就算只用到了响应式的部分,也只能引入完整的Vuejs

Vue2 中很多方法挂载到了实例中导致没有使用也会被打包(还有很多组件也是一样)。通过构建工具 Tree-shaking 机制实现按需引入,减少用户打包后体积。重写 API

  • Vue2很多的方法,都是直接挂载到vm也就是实例上了,导致没使用的这些方法,也会被打包进最终的打包文件中;Vue3中的功能,因为进行了模块拆分,都是函数式API,所以打包的时候利用Tree-shaking机制,做到了按需引入,有效的减少打包的体积。

Vue3 允许自定义渲染器,扩展能力强。不会发生以前的事情,改写 Vue 源码改造渲染方式。扩展更方便

  • Vue3可以自定义渲染器,增强了扩展能力,暴露了很多的方法,可以进行自定义逻辑;而在一些跨平台的框架中比如小程序,如果想使用Vue2作为技术栈,则需要在Vue2的源码基础上,改动源码的逻辑,才能进行打包,这相当于破坏了源码,随着更新也会出现一些问题。

依然保留 Vue2 的特色

Vue3 性能提升

  • 打包大小减少 41%
  • 初次渲染快 55%, 更新渲染快 133%
  • 内存减少 54%
  • 使用 Proxy 代替 defineProperty 实现数据响应式
  • 重写虚拟 DOM 的实现和 Tree-Shaking

Vue2 和 Vue3 响应式原理有啥不同?

答:Vue2 用的是 Object.defineProperty,Vue3 用的是 Proxy。

vue2 的响应式

  • 核心:
    • 对象: 通过 defineProperty 对对象的已有属性值的读取和修改进行劫持(监视/拦截)
    • 数组: 通过重写数组更新数组一系列更新元素的方法来实现元素修改的劫持
js
Object.defineProperty(data, "count", {
	get() {},
	set() {},
});
  • 问题
    • 对象直接新添加的属性或删除已有属性, 界面不会自动更新
    • 直接通过下标替换元素或更新 length, 界面不会自动更新 arr[1] = {}

Vue3 的响应式

核心:
js
new Proxy(data, {
	// 拦截读取属性值
	get(target, prop) {
		return Reflect.get(target, prop);
	},
	// 拦截设置属性值或添加新属性
	set(target, prop, value) {
		return Reflect.set(target, prop, value);
	},
	// 拦截删除属性
	deleteProperty(target, prop) {
		return Reflect.deleteProperty(target, prop);
	},
});

proxy.name = "tom";
html
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Proxy 与 Reflect</title>
	</head>
	<body>
		<script>
			const user = {
				name: "John",
				age: 12,
			};

			/* 
    proxyUser是代理对象, user是被代理对象
    后面所有的操作都是通过代理对象来操作被代理对象内部属性
    */
			const proxyUser = new Proxy(user, {
				get(target, prop) {
					console.log("劫持get()", prop);
					return Reflect.get(target, prop);
				},

				set(target, prop, val) {
					console.log("劫持set()", prop, val);
					return Reflect.set(target, prop, val); // (2)
				},

				deleteProperty(target, prop) {
					console.log("劫持delete属性", prop);
					return Reflect.deleteProperty(target, prop);
				},
			});
			// 读取属性值
			console.log(proxyUser === user);
			console.log(proxyUser.name, proxyUser.age);
			// 设置属性值
			proxyUser.name = "bob";
			proxyUser.age = 13;
			console.log(user);
			// 添加属性
			proxyUser.sex = "男";
			console.log(user);
			// 删除属性
			delete proxyUser.sex;
			console.log(user);
		</script>
	</body>
</html>

搭建 Vue3 源码的开发环境

解析 vue3 源码流程

  • 1.知道Vue3的功能原理
  • 2.手写实现一个简版的Vue3
  • 3.最后我们再去调试 vue3 源码的运行

先搭建一个开发环境

我们要使用Monorepo的方式来搭建整个项目,那什么是Monorepo呢?

Monorepo是目前很多大型开源项目,管理代码的一个方式,就是在一个git项目仓库中管理多个模块或者工具包。

Vue3的源码就是采用这种方式,将模块拆分到package目录中,那么好处就是:

  • 一个仓库可维护多个模块或工具包,不用到处找各自的仓库。
  • 方便每个模块的版本管理和依赖管理,模块之间的引用和调用变的十分方便。

Vue3 源码目录

Vue3 开源地址:https://www.github.com/vuejs/core

上文说到Vue3采用的是Monorepo这种方式,所以,我们只需要大致写一下Vue3中包含的各种包,就能实现一个简单版本的Vue3了。

Vue3 源码目录:

bash
changelogs/ # 包含了项目的变更日志,记录了每个版本的更新内容和修复的问题。
script/ # 存放构建脚本和其他与开发流程相关的脚本,如linting、测试和发布脚本。

packages/ # 存放了Vue3核心的各种包和模块,例如编译器、响应性系统、运行时和服务器端渲染等。
├── compiler-core # 编译时核心包,提供与平台无关的编译功能
├── compiler-dom # 编译时针对浏览器DOM的实现
├── compiler-sfc # 编译时SFC(单文件组件)的实现
├── compiler-ssr # 编译时服务端渲染的实现
├── dts-built-test #
├── dts-test # 与TypeScript声明文件的生成和测试有关。
├── reactivity # 包含Composition API、响应式系统等的实现
├── runtime-core # 提供与平台无关的运行时核心代码
├── runtime-dom # 针对浏览器DOM的运行时实现
├── runtime-test # 运行时测试工具
├── server-renderer # 服务端渲染器
├── sfc-playground # 单文件组件(Single File Components)的实验场,用于Vue组件的开发和测试。
├── shared # Vue内部使用的共享工具代码
├── template-explorer # 模板探索器,用于学习和实验Vue的模板语法。
├── vue # Vue.js框架的核心代码。
└── vue-compat # 是一个兼容性构建,提供可配置的Vue 2兼容行为,用于帮助从Vue 2迁移到Vue 3。

    # 每个子包下一层的文件通常包括src文件夹、__tests__文件夹(包含测试代码)、以及package.json文件(定义包的依赖和配置)。例如,compiler-core文件夹下可能会包含:
    compiler-core
    ├── src # 源代码
    ├── __tests__ # 测试代码
    └── package.json # 包配置文件

参考 vue3 的源码目录,搭建手写 Vue3 项目

我们使用pnpm这个工具,来搭建Monorepo环境。

mkdir vue3-source-code来创建文件夹,使用pnpm init -y命令,初始化package.json文件。之后我们创建如下所示的目录结构:

bash
vue3-source-code
|—— packages 文件夹 // 存放 Vue3 相关的所有包
|—— reactivity 文件夹 // 响应式原理的包
|—— src
|—— index.ts // 入口文件代码
|—— shared 文件夹 // 存放一些公共方法的包
|—— src
|—— index.ts // 入口文件代码
|—— scripts 文件夹 // 存放我们自定义的一些脚本
|—— .npmrc // npm 配置文件
|—— package.json

接下来,我们开始完善配置项和一些调试代码,让项目能够跑的通: 在项目根目录,使用tsc --init命令,来初始化tsconfig.json文件,如果没有安装过tsc,需要先全局执行npm install typescript -g命令。

我们直接把以下配置填写在tsconfig.json文件中。

javascript
// ts.config.json文件
{
  "compilerOptions": {
    "outDir": "dist", // 输出的目录
    "sourceMap": true, // 启用sourcemap
    "target": "es2016", // 目标语法
    "module": "esnext", // 模块格式为esm
    "moduleResolution": "node", // 模块解析方式
    "strict": false, // 严格模式,可以使用any
    "resolveJsonModule": true, // 解析json模块
    "esModuleInterop": true, // 允许通过es6语法引入commonjs模块
    "jsx": "preserve", // jsx 不转义
    "lib": ["esnext", "dom"], // 支持的类库 esnext及dom
    "baseUrl": "./",
    "paths": {
      "@vue/*": ["packages/*/src"]
    }
  }
}

心细的朋友可能发现,最后两个配置项没有注释,我们一会再来解释这两个配置项的作用。

javascript
// .npmrc文件
shamefully-hoist = true

这个配置项非常有意思,我们来解释一下这个配置项是啥意思?

npm在安装依赖时候的特征:会将依拍平在 node_modules 文件夹中,而pnpm在安装依赖之后,则不会将依赖拍平在node_modules文件夹中。

举个栗子 🌰,在一个空白项目中,如果我们使用了npm install webpack命令,那么当你打开node_modules文件夹的时候,会发现安装了一大堆依赖,此时我们在项目中使用require('express'),发现依旧不会报错,因为在安装webpack的时候,也用到了express这个依赖,而且都拍平在node_modules文件夹下了,所以在项目中require('express')是能够找到,而且不会报错的;

但果我们使用pnpm install webpack的话,此时再打开node_modules文件,会发现少了很多东西,观察目录结构会发现,其实依赖都被放在了.pnpm这个文件夹下,此时如果我们直接require('express'),则就会报错,因为node_modules目录下,根本不存在express模块。那么,在.npmrc文件中加入了shamefully-hoist = true这个配置项,就能够将.pnpm中的依赖,拍平在node_modules文件夹中,达到的效果就和npm很类似了。

接下来cd进入shared目录,使用pnpm init -y命令初始化,并将package.json文件中配置项改为"name": "@vue/shared" ......

javascript
// shared/src/index.ts 文件中,我们先写一个判断是否为数组的方法,并将其导出
export const isArray = (value) => {
	return Array.isArray(value);
};

同样的方法,cd进入reactivity目录下,使用pnpm init -y命令初始化,并将package.json文件中配置项改为"name": "@vue/reactivity" ......

接下来,如果我们想在reactivity/src/index.ts文件中,使用shared包中暴露出来的那个isArray方法,那么应该如何引入呢?

首先想到的就是我直接import { isArray } from '../../shared/src/index.ts'不就完了么,相对路径一把梭,乍看一眼没啥问题,但是稍微一想,像shared,reactivity这种包,最后发布可是要打包完后,单独发布到npm上边的,这时候使用相对路径,那肯定就不太合适了吧。有朋友又会说了,那直接用import { isArray } from '@vue/shared'来导入不就好了么?

没错,但是如果不进行任何配置,这种写法是去哪里找@vue/shared的这个包呢?node_modules目录中,那node_modules目录中没有这个shared包啊,该怎么办呢?聪明的朋友已经还记得,上文我们在配置tsconfig.json文件的时候,埋下了一个伏笔。没错,就是最后两个配置项。

json
"baseUrl": "./",
"paths": {
  "@vue/*": ["packages/*/src"]
 }

首先,baseUrl可以将根路径定位在当项目的根目录。

其次paths可以自定义寻找包的路径,比如上边配置的意思就是,只要import了以@vue/*开头的包,那么就会去packages文件夹下的*/src目录下寻找。所以加上了这个配置项,我们在reactiviey/src/index.ts文件中,就可以正常的导入shared模块了。

javascript
// reactiviey/src/index.ts
import { isArray } from "@vue/shared";

console.log(isArray([1, 2, 3]));

export { isArray };

pnpm-workspace.yaml文件中,我们先填写如下内容,代表packages文件夹下所有的目录,都当做包来管理。

bash
packages:
- 'packages/*'

至此,一个简单的Monorepo环境就已经搭建好了。

编写脚本进行开发环境打包

对于开发环境,我们使用esbuild包进行打包,对于生产环境,我们使用rollup进行打包。

首先在项目根目录先安装包pnpm intall esbuild -D -w,之所以加上-w是为了能够让依赖成功安装在项目根目录,不然就会报错。

我们先把入口写成固定为reactivity/src/index.ts。具体的配置,可以查看esbuild的官方文档,下边就直接写上需要的配置项,并简单做下注释解释。

javascript
// scripts/dev.js 文件
const { context } = require("esbuild");
const path = require("path");

const target = "reactivity";

context({
	// 打包入口
	entryPoints: [path.resolve(__dirname, `../packages/${target}/src/index.ts`)],
	outfile: path.resolve(__dirname, `../packages/${target}/dist/${target}.js`),
	bundle: true, // 把里文件中的依赖也同时打包进来
	sourcemap: true, // 生成sourcemap,可以调试
	format: "esm", // 打包出来的是esm模块
	platform: "browser",
}).then((ctx) => {
	// 监听文件变化,只要发生了改动,就重新打包编译结果
	ctx.watch().then(() => {
		console.log("watching~~~");
	});
});

package.json文件中添加一个命令,进行打包。

javascript
// package.json
"scripts": {
  "dev": "node scripts/dev.js"
}

之后,执行npm run dev命令,可以看到,在reactivity文件夹下,生成了reactivity.jsreactivity.js.map两个文件,我们打开reactivity.js文件,可以看到,打包结果为:

javascript
// packages/shared/src/index.ts
var isArray = (value) => {
	return Array.isArray(value);
};

// packages/reactivity/src/index.ts
console.log(isArray([1, 2, 3]));
export { isArray };
//# sourceMappingURL=reactivity.js.map

那么此时js文件就已经被成功的打包了,在dist目录下,我们新建个index.html文件,看看刚才打包的结果,能不能在页面上用:

html
<!-- reactivity/dist/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>
		<script type="module">
			import { isArray } from "./reactivity.js";
			console.log("index.html文件中测试代码:", isArray([2, 3, 4]));
		</script>
	</body>
</html>

特别要注意的是,<script>标签要加上type="module",因为我们是通过esModule的方式进行导入导出的。

此时我们要启动一个本地服务器,来查看index.html文件,这里方式很多,我推荐使用一条命令,能够直接启动一个本地服务器,并且不需要安装任何东西。

我们cd进入到reactivity目录下,执行npx serve dist命令(要保证 npm 版本 ≥5.2 才能够使用 npx),这套命令就是将 dist 文件夹作为服务器根目录,然后将index.html文件默认作为主文件入口进行展示,执行完毕后,可以看到默认的端口是 3000,我们直接在浏览器中打开localhost:3000,打开控制台,可以发现输出了 2 行代码,一个是reactivity.js文件中输出的,一个是index.html文件中,导入进来输出的。

手写 Vue3 组合 API

1) shallowReactive 与 reactive

js
const reactiveHandler = {
	get(target, key) {
		if (key === "_is_reactive") return true;

		return Reflect.get(target, key);
	},

	set(target, key, value) {
		const result = Reflect.set(target, key, value);
		console.log("数据已更新, 去更新界面");
		return result;
	},

	deleteProperty(target, key) {
		const result = Reflect.deleteProperty(target, key);
		console.log("数据已删除, 去更新界面");
		return result;
	},
};

/* 
自定义shallowReactive
*/
function shallowReactive(obj) {
	return new Proxy(obj, reactiveHandler);
}

/* 
自定义reactive
*/
function reactive(target) {
	if (target && typeof target === "object") {
		if (target instanceof Array) {
			// 数组
			target.forEach((item, index) => {
				target[index] = reactive(item);
			});
		} else {
			// 对象
			Object.keys(target).forEach((key) => {
				target[key] = reactive(target[key]);
			});
		}

		const proxy = new Proxy(target, reactiveHandler);
		return proxy;
	}

	return target;
}

/* 测试自定义shallowReactive */
const proxy = shallowReactive({
	a: {
		b: 3,
	},
});

proxy.a = { b: 4 }; // 劫持到了
proxy.a.b = 5; // 没有劫持到

/* 测试自定义reactive */
const obj = {
	a: "abc",
	b: [{ x: 1 }],
	c: { x: [11] },
};

const proxy = reactive(obj);
console.log(proxy);
proxy.b[0].x += 1;
proxy.c.x[0] += 1;

2)shallowRef 与 ref

js
/*
自定义shallowRef
*/
function shallowRef(target) {
	const result = {
		_value: target, // 用来保存数据的内部属性
		_is_ref: true, // 用来标识是ref对象
		get value() {
			return this._value;
		},
		set value(val) {
			this._value = val;
			console.log("set value 数据已更新, 去更新界面");
		},
	};

	return result;
}

/* 
自定义ref
*/
function ref(target) {
	if (target && typeof target === "object") {
		target = reactive(target);
	}

	const result = {
		_value: target, // 用来保存数据的内部属性
		_is_ref: true, // 用来标识是ref对象
		get value() {
			return this._value;
		},
		set value(val) {
			this._value = val;
			console.log("set value 数据已更新, 去更新界面");
		},
	};

	return result;
}

/* 测试自定义shallowRef */
const ref3 = shallowRef({
	a: "abc",
});
ref3.value = "xxx";
ref3.value.a = "yyy";

/* 测试自定义ref */
const ref1 = ref(0);
const ref2 = ref({
	a: "abc",
	b: [{ x: 1 }],
	c: { x: [11] },
});
ref1.value++;
ref2.value.b[0].x++;
console.log(ref1, ref2);

3) shallowReadonly 与 readonly

js
const readonlyHandler = {
	get(target, key) {
		if (key === "_is_readonly") return true;

		return Reflect.get(target, key);
	},

	set() {
		console.warn("只读的, 不能修改");
		return true;
	},

	deleteProperty() {
		console.warn("只读的, 不能删除");
		return true;
	},
};

/* 
自定义shallowReadonly
*/
function shallowReadonly(obj) {
	return new Proxy(obj, readonlyHandler);
}

/* 
自定义readonly
*/
function readonly(target) {
	if (target && typeof target === "object") {
		if (target instanceof Array) {
			// 数组
			target.forEach((item, index) => {
				target[index] = readonly(item);
			});
		} else {
			// 对象
			Object.keys(target).forEach((key) => {
				target[key] = readonly(target[key]);
			});
		}
		const proxy = new Proxy(target, readonlyHandler);

		return proxy;
	}

	return target;
}

/* 测试自定义readonly */
/* 测试自定义shallowReadonly */
const objReadOnly = readonly({
	a: {
		b: 1,
	},
});
const objReadOnly2 = shallowReadonly({
	a: {
		b: 1,
	},
});

objReadOnly.a = 1;
objReadOnly.a.b = 2;
objReadOnly2.a = 1;
objReadOnly2.a.b = 2;

4) isRef, isReactive 与 isReadonly

js
/* 
判断是否是ref对象
*/
function isRef(obj) {
	return obj && obj._is_ref;
}

/* 
判断是否是reactive对象
*/
function isReactive(obj) {
	return obj && obj._is_reactive;
}

/* 
判断是否是readonly对象
*/
function isReadonly(obj) {
	return obj && obj._is_readonly;
}

/* 
是否是reactive或readonly产生的代理对象
*/
function isProxy(obj) {
	return isReactive(obj) || isReadonly(obj);
}

/* 测试判断函数 */
console.log(isReactive(reactive({})));
console.log(isRef(ref({})));
console.log(isReadonly(readonly({})));
console.log(isProxy(reactive({})));
console.log(isProxy(readonly({})));

Vue3 响应式原理

Vue3的响应式原理

Vue2 和 Vue3 的对比

这里我们不得不先提及一下Vue2的响应式原理,说句现实的话,面试的时候,肯定会一起问的,那么如果能够将两者结合在一起,进行有条理的对比分析回答,那么绝对是一个亮眼的加分项。

响应式原理对比

Vue2不足:

  • 在使用Vue2的时候,进行数据劫持使用的是Object.defineproperty,需要对我们data中定义的所有属性进行重写,从而添加gettersetter,正是因为了这一步,所以导致,如果data中定义的属性过多,性能就会变差。
  • 在写项目的时候,有的时候会碰到需要新增或删除属性的操作,那么直接新增/删除,就无法监控变化,所以需要通过一些api比如$set$delete进行实现,其实原理上还是使用了Object.defineproperty进行了数据劫持。
  • 针对数组的处理,没有使用Object.defineproperty进行数据劫持,因为如果给一个很长的数组的每一项,都添加gettersetter,那多来几个数组,就崩掉了,而且日常开发中我们通过数组索引进行修改数组的操作比较少。所以Vue2的方式就是采用重写了一些常用的数组方法比如unshift,shift,push,pop,splice,sort,reverse这七个方法,来解决数组数据响应式的问题。

Vue3改进:

  • Vue3使用了Proxy来实现了响应式数据变化,从而从根本上解决了上述问题,逻辑也简化了好多。

写法区别对比

  • Vue2中使用的是OptionsAPI,我们在写代码的时候,如果页面比较复杂,那么可能就会在data中定义很多属性,methods中定义很多方法,那么相关的逻辑就不在同一块地方,我们在找代码的时候,就可能比较累,鼠标滚轮或者触摸板来回上下翻找。Vue3使用了CompositionAPI,可以把某一块逻辑,单独写在一起,解决了这种反复横跳的问题。
  • Vue2中所有的属性都是通过this来进行访问的,this的指向一直是JS中很恶心的问题,一不小心就搞不清this的指向,代码就会出问题。Vue3直接干掉了this
  • Vue2中,很多没有使用的方法或者属性,都会被打包,并且全局的API都可以在Vue对象上访问到。比如我们在Computed中,定义了 3 个值,但是页面中只用到了 1 个,那么依旧会把这 3 个Computed值全部都打包。Vue3使用的CompositionAPI,对tree-shaking非常友好,代码压缩后的体积也就更小。
  • Vue2中的mixins可以实现相同逻辑复用,抽离到一个mixin文件中,但是会有数据来源不明确的问题,命名上也会产生冲突。而Vue3使用CompositionAPI,提取公共逻辑可以抽成单独的hooks,非常方便,避免了之前的问题。

当然,在简单的页面中,我们依旧可以使用OptionsAPI,就是Vue2的写法。CompositionAPI在开发比较复杂的页面中,书写起来显得非常方便。我们本篇文章要学习的就是Vue3中的reactivity模块,那么这个模块中包含了很多我们使用的API,比如computed,reactive,ref,effect等。

reactiveeffect方法的实现

reactivity模块的基本使用

我们先简单的看下,这个模块的使用方法,然后再来一步一步,简单实现里边的方法。打开上篇文章创建好的项目,在项目根目录,我们执行pnpm install vue -w,先用一下Vue3官方提供的方法,看看是啥效果。

安装依赖好后,我们通过node_modules文件夹找到@vue/reactivity/dist/reactivity.esm-browser.js这个文件,通过文件名字我们就能看出来,这个是esModule可以放在浏览器中运行的。

把这个文件复制一份,直接放在我们自己reactivity/dist目录下,然后修改reactivity/dist/index.html的代码如下:

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 type="module">
			import { effect, reactive } from "./reactivity.esm-browser.js";
			const state = reactive({ name: "张三", age: 18 });
			effect(() => {
				app.innerHTML = state.name + ": " + state.age;
			});
			setTimeout(() => {
				state.name = "李四";
			}, 2000);
		</script>
	</body>
</html>

我们这里介绍上述代码中的两个API,第一个就是我们熟知的reactive,没错,在项目中如果想定义一个响应式对象的话,就把对象传进reactive中就好了。

那么effect又是啥呢?如果我们只是写业务,其实很难用到这个方法,但effect确是一个非常重要的方法(又叫副作用函数),执行effect就会渲染页面,所以渲染页面的核心离不开effect方法。

一句话,reactive方法会将对象变成proxy对象,effect中使用reactive对象的时候,会进行依赖收集,等之后reactive对象中的属性发生变化的时候,会重新执行effect函数。

我们在浏览器中执行上边的代码,会发现过了 2 秒后,我们只是将state.name赋值成了李四,但是页面也重新被渲染了,名字从张三变成了李四。等看完本篇文章的代码后,可以回过头来再来理解上边的那句话。

有人可能有些疑问了,reactive我在项目中确实有用到过,但是这个effect方法,在项目中根本没用到过啊,甚至听都没听说过,没错,effect方法是底层方法,项目中用不到非常正常,但是watchwatchEffect总该用过吧?嘿嘿,没错,都是基于effect进行了封装从而实现的,别急,我们在下边的文章中会娓娓道来。

开始实现reactivity模块中的方法

编写reactive方法

首先我们在shared中添加一个新方法:

javascript
// 用来判断是不是一个对象
export const isObject = (value) => {
	return value != null && typeof value === "object";
};

之后,我们在reactivity/src目录下,新建reactive.ts文件,用来写reactive的主逻辑:

javascript
import { isObject } from "@vue/shared";
const mutableHandlers = {
	get(target, key, receiver) {
		return Reflect.get(target, key, receiver);
	},
	set(target, key, value, receiver) {
		Reflect.set(target, key, value, receiver);
		// 严格模式下如果不返回true就会报错
		return true;
	},
};
export function reactive(target) {
	// 先判断target是不是个对象,reactive只能处理对象类型的数据
	if (!isObject(target)) return;
	const proxy = new Proxy(target, mutableHandlers);
	return proxy;
}

我们用最简单的代码,写了reactive的核心逻辑,从代码中也看到,reactive中只能处理对象类型的数据。还有一点,细心的朋友可能会发现,在getset中,使用了Reflectgetset方法,那为什么不直接用target[key]呢,效果不是一样的么?看起来是这样,但是在一些情况下,就能看到明显的问题。我们先举个例子:

javascript
let obj = {
  name: 'zhangsan',
  get nickName{
    return 'nickName:' + this.name
  }
}

let proxyObj = new Proxy(obj, {
  get(target, key, receiver) {
    console.log('收集依赖:', key)
    return target[key]
  }
})

// 进行取值操作
console.log(proxyObj.nickName)

上述代码中,是一个很简单的代理,如果我们在页面中,使用了proxyObj.nickName这个取值代码,那么根据相应逻辑,执行代码打印的结果就是:

bash
收集依赖: nickName
nickName:zhangsan

那么很明显的问题就是,obj中的name属性,没有被依赖收集,那么如果在后续操作中,我们对proxyObj.name = 'xxxxxx'进行赋值了,因为没有被依赖收集到,所以虽然数据变化了,但是页面视图却并没有同步发生变化。

说到底还是因为this指向的原因,当前this指向了obj,而我们希望这个this指向被代理后的proxyObj,这样才能够将name属性也收集到,那么所以,我们此时应该使用Reflect,来使this正确的指向被代理后的proxyObj属性。

javascript
let obj = {
	name: "zhangsan",
	get nickName() {
		return "nickName:" + this.name;
	},
};

let proxyObj = new Proxy(obj, {
	get(target, key, receiver) {
		console.log("收集依赖:", key);
		return Reflect.get(target, key, receiver);
	},
});

// 进行取值操作
console.log(proxyObj.nickName);

经过此番修改,我们再执行代码,会发现,诶name属性也被成功的进行依赖收集了,达到了我们的预期.这就是为什么这里要使用Reflect的原因啦。

bash
收集依赖: nickName
收集依赖: name
nickName:zhangsan

经过这个小插曲,我们回到reactive代码中。虽然核心逻辑写好了,但是我们要考虑一些小问题,比如在下方代码中,如果用Vue3官方源码来执行,那么如果对于同一个对象进行多次代理,都应该返回同一个代理,结果为true,但是在我们目前的代码中,没有过这个判断,只要在reactive中传入一个对象,就进行new Proxy()生成一个新的代理,所以结果为false,这样肯定是不合理的。

javascript
import { reactive } from "vue";
const obj = { name: "zhangsan" };
let proxy1 = reactive(obj);
let proxy2 = reactive(obj);
console.log(proxy1 === proxy2);

那么应该如何做到如果传入同一个对象,就返回相同的代理结果呢?其实想一想大致的思路就有了,没错,需要有个缓存表,来记录每次传入的对象是不是重复了,如果重复,就返回已经存在的代理对象。

那应该用什么缓存呢?没错,就是用WeekMap,好处就是它的key能存放object类型的数据,而且不存在垃圾回收的问题,我们来补充完整逻辑吧!

javascript
import { isObject } from "@vue/shared";
// 1.我们利用WeakMap,来定义一个缓存表
const reactiveMap = new WeakMap();
const mutableHandlers = {
	get(target, key, receiver) {
		return Reflect.get(target, key, receiver);
	},
	set(target, key, value, receiver) {
		Reflect.set(target, key, value, receiver);
		// 严格模式下如果不返回true就会报错
		return true;
	},
};

export function reactive(target) {
	// 先判断target是不是个对象,reactive只能处理对象类型的数据
	if (!isObject(target)) return;
	// 2.先从缓存表中读取代理结果,如果能找到,就直接返回
	const existingProxy = reactiveMap.get(target);
	if (existingProxy) return existingProxy;
	// 没有缓存过就正常new Proxy()
	const proxy = new Proxy(target, mutableHandlers);
	// 代理后,在缓存表中缓存结果
	reactiveMap.set(target, proxy);
	return proxy;
}

这时候,我们再引入自己的reactive,执行刚才那段测试代码,发现console.log(proxy1 === proxy2)返回的就是true。这个问题解决了,但是新的问题又来了,还是回到刚才那个测试代码,这次将代理后的对象,再次传入到reactive中。

在源码中返回的结果依旧是true,但是在我们的代码中,因为传入被代理后的对象,又是一个新的对象,所以会再次被代理。那么,我们怎么才能够判断这种情况呢?

javascript
import { reactive } from "vue";
const obj = { name: "zhangsan" };
let proxy1 = reactive(obj);
let proxy2 = reactive(proxy1);
console.log(proxy1 === proxy2);

很多人第一反应就是我判断传入的值是不是proxy不就完事了,首先,并没有什么好的办法,判断传入的值是一个proxy代理后的对象,其次,如果用户自己new Proxy()生成了一个代理的对象,那么凭啥不让人家传入reactive中呢?之所以要做上文和现在这两点优化,是因为同一个对象,或同一个对象经过代理后的结果,多次传入reactive中后不会被再次进行代理,提高了效率。

这里,新版本的Vue3采用了一个比较巧妙的方法来解决这个问题,第一次看可能会有些绕,所以最好多看几遍代码,或在浏览器中进行断点调试。

javascript
import { isObject } from '@vue/shared'

const reactiveMap = new WeakMap();
const enum ReactiveFlags {
  IS_REACTIVE = '__v_isReactive'
}

const mutableHandlers =  {
  get(target, key, receiver) {
    // 2.在经过get劫持后,如果访问到的key就是ReactiveFlags.IS_REACTIVE,就说明被代理的对象,又被传进来了,所以直接返回true
    if (key === ReactiveFlags.IS_REACTIVE) return true
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    Reflect.set(target, key ,value, receiver)
    // 严格模式下如果不返回true就会报错
    return true
  }
}

export function reactive(target) {
  // 先判断target是不是个对象,reactive只能处理对象类型的数据
  if (!isObject(target)) return
  // 如果能够从从缓存中读取,则直接返回
  const existingProxy = reactiveMap.get(target)
  if(existingProxy) return existingProxy
  // 1.如果被代理后的对象,又被传入进来了,那么应该将这个被代理的对象直接返回,而不是再代理一次
  if (target[ReactiveFlags.IS_REACTIVE]) return target
  // 没有缓存过,就使用proxy进行代理
  const proxy = new Proxy(target, mutableHandlers)
  // 缓存proxy结果
  reactiveMap.set(target, proxy)
  return proxy
}

其实就是增加了一个常量枚举值,那么在Vue3内部,这些常量都是以__v开头的,IS_REACTIVE这个常量就代表着是否是一个已经被代理的reactive对象。新增的代码非常简洁,我们简单过一遍整体的流程。

首先,当一个普通对象第一次被传入进reactive中的时候,target[ReactiveFlags.IS_REACTIVE]肯定是undefined,这个毫无疑问,返回的值我们称为proxy1

注意重点来了,当我们再次将proxy1传入到reactive中的时候,因为proxy1已经是一个被代理的对象了,所以在经过if(target[ReactiveFlags.IS_REACTIVE]) return target这行代码的时候,因为target[ReactiveFlags.IS_REACTIVE]是一个取值操作,所以就会命中get中的逻辑,也就是命中这行代码if (key === ReactiveFlags.IS_REACTIVE) return true,返回了true,因为返回了true,所以根据后边的逻辑,就直接return target,将proxy1自己直接返回了。

好好品味一下这段逻辑,非常的巧妙。到这里,reactive的核心内容我们已经完成了,那么还有一些其他的方法,和细节,我们这里就不再多说,之后分析源码的时候,如果遇到再去讲解分析。

编写effect方法

reactivity/src/effect.ts 文件:

javascript
// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    // 执行传入的函数
    return this.fn()
  }
}

// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

那么,effect的最基本架子,就搭起来了。接下来是一个很关键的步骤,effect是怎么和reactive建立起联系,产生关联的呢?

换句话讲,当我们定义的reactive变量中的值发生变化了,是怎么执行相应effect的函数呢?

有些朋友自然而然就想到了依赖收集、触发更新这两个词,别急,我们一步一步来分析,其实建立联系用到了一个很巧妙的方法,那就是导出一个变量,那么这个变量就代表着effect的实例,从reactive模块中再导入这个变量,那么就相当于建立起了联系,我们看具体代码:

reactivity/src/effect.ts 文件

javascript
// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    // 4.设置正在运行的是当前effect
    activeEffect = this
    // 执行传入的函数
    return this.fn()
  }
}

// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

没错,就是这两行简单的代码,其实就解释了依赖收集,是怎么收集的。我们可以先在reactive模块中导入这个变量,简单的调试看下结果:

reactivity/src/reactive.ts

javascript
import { activeEffect } from './effect'
// 前面的代码省略

get() {
  // 前面的代码省略

  console.log(activeEffect)

  // 后面的代码省略
}
// 后面的代码省略

多余的代码不写了,为了清晰,我们只写调试代码。刷新页面,我们可以看到,在执行effect方法中传入的函数时,因为我们在函数中使用到了reactive定义的变量,所以可以清楚地看到activeEffect被成功的打印了出来,至此,effectreactive之间成功建立了联系。后续所有的代码都是建立在这条之上的。

有聪明的小伙伴可能有疑问了,那如果我们在index.html中,调用了 2 次或多次effect函数,按现在的代码不就有问题了么,因为run了多次之后,或者在effect外部又改变了reactive定义变量的值,那activeEffect不就乱套了么?没错,所以我们要保证,每次执行effect方法的时候,activeEffect都为当前的effect,解决方法也很简单,我们再添加几行代码:

reactivity/src/effect.ts 文件

javascript
// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5.在执行完传入的函数后,将activeEffect置空,这样做还有个好处就是,如果在effect方法外部使用
      // 了reactive定义的变量,那么就不会被监听到,因为此时activeEffect已经被置为null了
      activeEffect = null
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

我们继续,那么问题又来了,如果按照现在我们的effect中的代码,如果在使用effect方法的时候,进行了嵌套调用,那activeEffect就会出bug了,什么意思呢?我们改变一下index.html中的代码,然后稍加分析。

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 type="module">
			import { effect, reactive } from "./reactivity.esm-browser.js";
			const state = reactive({ name: "张三", age: 18 });
			effect(() => {
				app.innerHTML = state.name + ": " + state.age;
				effect(() => {
					app.innerHTML = state.name;
				});
				app.innerHTML = state.age;
			});
		</script>
	</body>
</html>

我们仔细分析下嵌套部分的代码:

当调用外部的effect方法时,activeEffect为外部的effect,我们这里简称outer effect,紧接着,又调用了内部的effect方法,那么按照我们现有的effect逻辑,此时activeEffect又会变为内部的effect,我们简称inner effect,注意,此时我们内部的effect执行完毕后,按照现有逻辑,activeEffect会清空变为null,但是此时外部的effect并没有执行完毕,还剩一句app.innerHTML = state.age代码没有执行,没错,这就有问题了,当前的activeEffect因为被清空重置为null了,所以当对state.age进行取值的时候,effectreactive之间的联系就断了(没有被依赖收集),而想正确建立联系,那么此时的activeEffect就应该是outer effect,怎么去做呢?这种嵌套的关系,是不是很像树形结构?树型结构的特点就是有父节点和子节点,所以,我们只需要标记父子关系即可:

reactivity/src/effect.ts 文件

javascript
// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

这下,按照上边的逻辑,我们再分析下嵌套逻辑,就能跑的通了,所以属性发生变化的时候,都可以在reactive中的get中被监听到。

那么接下来,我们便可以写之前常提到的依赖收集和触发更新了。

我们发现,reactiveeffect方法,其实是多对多的关系,即一个reactive中的属性,可以在多个effect方法中使用,而一个effect方法中,又可以使用多个reactive中的属性。

所以,我们之前常说的依赖收集,其实可以理解为,使用我们自己定义的一个名叫track的方法,在get中收集每个响应式属性对应的effect方法,让这个属性和effect产生关联;而触发更新,则是使用我们自己定义的trigger方法,在set中触发更新的逻辑,执行每个响应式属性所对应的effect方法。

那么我们首先在reactive文件中,导入并且调用这两个方法,之后,我们再去effect文件中实现这两个方法:

reactivity/src/reactive.ts 文件

javascript
import { track, trigger } from "./effect";
// 前面的代码省略
const mutableHandlers = {
	get(target, key, receiver) {
		if (key === ReactiveFlags.IS_REACTIVE) return true;
		const res = Reflect.get(target, key, receiver);
		// 1. 进行依赖收集逻辑
		track(target, key);
		return res;
	},
	set(target, key, value, receiver) {
		let oldValue = target[key];
		Reflect.set(target, key, value, receiver);
		// 2.新旧值不一样的时候,触发更新逻辑
		if (oldValue !== value) {
			trigger(target, key, value, oldValue);
		}
		return true;
	},
};
// 后面的代码省略

接下来,我们在effect中再实现这两个方法:

reactivity/src/effect.ts 文件

javascript
// 3、当前正在执行的effect
export let activeEffect = undefined
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  dep && dep.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

那么注意,此时的数据结构,很可能会让人很晕乎,我们稍作解释:

bash
# 这里不知道为什么打包会报错,所以放进代码块里
此时的 `targetMap` 大致上应该是长这个样子的(注意,key 是对象):`{{name: 'xxx', age: xxx}: {'name': [dep]}}`,也就是 `weakMap : map : set` 这种结构。

`targetMap``key`是整个对象,`value`是一个`map`结构,`map` 结构的 `key` 是属性,`value` `set` 结构,存储和属性对应的一个个 `effect`,如果还是不清楚,那么可以将 `targetMap` 打印在控制台中。

关于第 8 步骤 `trigger` 中,在循环调用 `effect.run` 方法前,会有一个防止死循环的判断,这是啥意思呢?

如果在 index.html 中,这样调用effect方法的话:

javascript
effect(() => {
	// 每次修改state.name都是新的随机数
	state.name = Math.random();
	app.innerHTML = state.name + ":" + state.age;
});

很明显,上述代码就变成了死循环,因为当state.name的值发生变化后,就会触发更新,又执行了effect方法,而在执行effect方法的时候,又因为重新改变了state.name的值,所以就又会触发effect方法,就成了无线递归的死循环代码。

所以,我们这边要加一个判断,表明如果当前正在执行的effect如果和activeEffect不相同的时候,才去执行,这样,就不会造成自己调用自己,死循环的结果。

到这里,我们的代码依旧有些小问题可以优化,我们来看一个比较有意思的场景,改变index.html中的代码:

html
<script>
	// 前面的代码省略

	const state = reactive({ name: "张三", age: 18, flag: true });
	effect(() => {
		console.log("页面刷新");
		app.innerHTML = state.flag ? state.name : state.age;
	});
	setTimeout(() => {
		state.flag = false;
		setTimeout(() => {
			console.log("name被修改了");
			state.name = "李四";
		});
	}, 1000);
</script>

我们在浏览器中执行这个代码,会发现页面过了 1 秒,变为了 18,控制台的结果却打印了 4 行,顺序是:

bash
页面刷新
// 1秒后
页面刷新
// 又过了1秒后
name被修改了
页面刷新

那么问题来了,name被修改后,不应该又触发一次页面刷新的逻辑,因为此时flag已经变为了false,按理来说依赖收集应该只收集flagage,所以当改变name的时候,不会触发更新。

我们再梳理下当前代码,依赖收集和触发更新的流程:一开始effect会直接执行,所以会直接输出页面刷新,此时依赖收集的属性有flagname,过了 1 秒钟,flag改为了false,所以又会触发页面更新,此时依赖收集的是flagage(注意,name的依赖收集依旧存在,没有被清理掉,问题就出在这),又过了 1 秒钟,打印了name被修改了,但是因为此时name的依赖收集依旧存在,在改了name的值后,依旧触发了effect函数,所以紧接着就打印了页面刷新

看到这,是不是就知道问题所在和怎么去解决呢?没错,就是在进行下次依赖收集之前,要把之前的依赖收集先进行清空,这样,就不会存在上边这种,明明没有收集name的依赖,但是当改变name的值后,页面依旧触发更新的情况了。

javascript
// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  constructor(public fn) { }
  run() {
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

我们看9-1步骤,那么这步就是用到了我们之前定义的deps = []这个存放当前activeEffect对应了哪些依赖(set结构)。

找到后清理掉所有的effect,再进行下一次的依赖收集,这样就不会造成类似于"缓存"的问题。

那么在9-3步骤,为什么要进行一次拷贝呢?其实很简单,在一个循环中,同时对effect进行了添加和删除操作,刚删完元素,就又添加了新元素,那岂不是循环就成了死循环,一直跳不出来了么,所以,解决的方法就是进行一次拷贝,删除和运行分开进行,就不会有死循环的问题了。

经过我们一步步的完善,那么effect的代码就逐渐接近尾声了。我们加把劲,继续来!

那么有一种很常见的场景,当我们代理的对象,内部又有很多对象,那这些对象就不会被代理,比如:

javascript
const obj = reactive({
	name: "张三",
	info: {
		age: 18,
		sex: "男",
	},
});

那么这时候,我们就需要进行递归代理,方法也很简单,在reactive.ts文件中get最后添加几行代码即可:

javascript
get(target, key, receiver) {
  // 前面的代码省略

  if (key === ReactiveFlags.IS_REACTIVE) return true
  const res = Reflect.get(target, key, receiver)
  track(target, key)
  // 判断如果res是一个对象,则进行递归代理
  if(isObject(res)){
      return reactive(res);
  }
  return res
}

接下来我们增加实例的 2 个方法。对于effect方法,其实是有一个返回值的,那么我们拿到这返回值,通过调用里边的方法,可以手动进行执行effect中的run方法,和停止依赖收集的stop方法,我们首先来实现拿到返回值进行手动调用(类似于Vue中的forceUpdate,可以强制刷新组件),其实原理非常简单,就把new ReactiveEffect(fn)这个结果,当成返回值不就好了么,没错,不过有些细节,我们通过完善effect.ts文件来继续看:

javascript
// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  // 11-1. 表示当前处于激活态,要进行依赖收集
  active = true
  constructor(public fn) { }
  run() {
    // 11-3. 失活态默认调用run的时候,只是重新执行传入的函数,并不会发生依赖收集
    if (!this.active) {
      return this.fn()
    }
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
  // 11-2. 声明stop方法
  stop() {
    if (this.active) {
      // 失活就停止依赖收集
      this.active = false
      cleanupEffect(this)
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
  // 10. 给effect添加一个返回值,通过这个返回值可以调用_effect实例中的stop和run等方法
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) effect.run()
  })
}

我们直接看步骤10,这样写的好处就是const runner = effect(() => { console.log('页面刷新') app.innerHTML = state.name }),在通过上述方式拿到了返回值runner后,我们可以手动执行runner()方法,或runner.effect.run()方法,进行手动刷新页面,我们通过修改index.html文件,来尝试用下这个功能,不然只说概念,没有场景,很难理解。

html
<script>
	import { effect, reactive } from "./reactivity.js";
	const state = reactive({ name: "张三", age: 18, flag: true });
	let a = "李四";
	const runner = effect(() => {
		app.innerHTML = state.name + a;
	});
	setTimeout(() => {
		a = "王五";
		runner();
	}, 1000);
</script>

通过上边的代码,我们执行后发现,页面在 1 秒钟后,还是发生了改变,虽然我们只是在定时器里边改了变量a的值,但是因为我们进行了手动触发effect.run()方法,所以页面还是会更新的。那么我们继续看什么叫做停止依赖收集。

看步骤11-1~11-3,非常明确,如果调用了stop方法,那么就会停止所有的依赖收集,并且就算之后进行了手动调用runner.run()方法,因为步骤11-3,所以也只是会再次调用effect中传入的函数,并不会进行依赖收集和触发更新。

到这里,effect就接近尾声了,那么为了和下篇文章进行接轨,我们再讲最后的一个优化点。上文提到了,我们可以手动执行runner()或runner.effect.run()方法进行页面的强制更新,但是这个runner方法,我们现在是写在effect方法之外的地方,能不能想个办法,将这个逻辑放在effect方法中呢?我们对index.html稍加改造,然后根据我们想要的数据结构,来反向推断代码应该如何写,我们想要的结果是这样:

html
<script>
	import { effect, reactive } from "./reactivity.js";
	const state = reactive({ name: "张三", age: 18 });
	const runner = effect(
		() => {
			app.innerHTML = state.name;
			console.log("我执行啦");
		},
		{
			scheduler: () => {
				setTimeout(() => {
					console.log("页面重新刷新了");
					runner();
				}, 1000);
			},
		}
	);
	setTimeout(() => {
		state.name = "王五";
		console.log("名字改变了");
	}, 1000);
</script>

我们给effect方法,提供第二个参数,参数中有一个scheduler属性,这个属性就对应着我们刚才定时器中的逻辑。

我们期望的结果是,过了 1 秒钟,state.name = '王五'发生改变后,触发的是我们effect方法中第二个参数中的scheduler对应的逻辑,而不是effect方法中的第一个回调逻辑,这样就达到了当依赖发生变化的时候,我们可以执行自己的逻辑。想要的效果很明确了,那我们来完善下逻辑吧!

javascript
// reactivity/src/effect.ts 文件

// 3. 当前正在执行的effect
export let activeEffect = undefined
// 9-1. 声明清理effect的一个方法,在每次依赖收集前进行调用
function cleanupEffect(effect) {
  const { deps } = effect; // 清理effect
  for (let i = 0; i < deps.length; i++) {
    deps[i].delete(effect);
  }
  effect.deps.length = 0;
}
// 2.编写ReactiveEffect类
class ReactiveEffect {
  // 6.设置一个父节点的标识
  parent = undefined
  // 定义一个依赖数组,保存着一个effect对应了哪些依赖
  deps = []
  // 11-1. 表示当前处于激活态,要进行依赖收集
  active = true
  // 12-2. 将scheduler挂载effect实例上
  constructor(public fn, public scheduler) { }
  run() {
    // 11-3. 失活态默认调用run的时候,只是重新执行传入的函数,并不会发生依赖收集
    if (!this.active) {
      return this.fn()
    }
    try {
      // 4.设置正在运行的是当前effect
      activeEffect = this
      // 9-2. 清理上一次依赖收集
      cleanupEffect(this)
      // 执行传入的函数
      return this.fn()
    } finally {
      // 5. 6.合并成下方代码
      activeEffect = this.parent // 执行完当前effect之后,还原activeEffect为当前effect的父节点
      this.parent = undefined // 重置父节点标记
    }
  }
  // 11-2. 声明stop方法
  stop() {
    if (this.active) {
      // 失活就停止依赖收集
      this.active = false
      cleanupEffect(this)
    }
  }
}
// 1. 首先我们创建一个响应式effect导出,并且让effect首先默认执行
export function effect(fn, options: any = {}) {
  // 12-1. 添加options.scheduler的传参
  const _effect = new ReactiveEffect(fn, options.scheduler)
  _effect.run()
  // 10. 给effect添加一个返回值,通过这个返回值可以调用_effect实例中的stop和run等方法
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

// 7. 实现依赖收集的逻辑
// 记录依赖关系
const targetMap = new WeakMap()
export function track(target, key) {
  // 只有在effect方法中改变了reactive对象,才会被进行依赖收集,因为此时activeEffect不是undefined
  if (activeEffect) {
    // 首先在targetMap中获取target
    let depsMap = targetMap.get(target)
    // 如果没有,就新建一个映射表,这里使用Map是因为之后的key可能是字符串
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 如果有映射表,就查找有没有当前的属性
    let dep = depsMap.get(key)
    // 如果没有这个属性,就使用Set添加一个集合
    if (!dep) {
      depsMap.set(key, (dep = new Set()))
    }
    // 判断如果没有的话,再去添加
    let shouldTrack = !dep.has(activeEffect)
    if (shouldTrack) {
      // 在同一个effect中,如果多次使用同一属性,那么就不需要多次进行依赖收集
      dep.add(activeEffect)
      activeEffect.deps.push(dep) // 在effect中记录所有依赖,后续便于清理(多对多联系建立)
    }
  }
}

// 8. 实现触发更新
export function trigger(target, key, newValue, oldValue) {
  // 通过对象找到对应属性,让这个属性对应的effect重新执行
  const depsMap = targetMap.get(target) // 获取对应的映射表
  if (!depsMap) return
  const dep = depsMap.get(key) // 属性对应的所有effect集合,是个set
  // 9-3 进行一次拷贝,防止自己删除元素的同时,自己添加,造成死循环
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 执行每个effect中的run方法;正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) {
      // 12-3. 如果用户传入了scheduler,那么就执行用户自定义逻辑,否则还是执行run逻辑
      if(effect.scheduler) {
        effect.scheduler()
      }else {
        effect.run()
      }
    }
  })
}

通过12-1~12-3的这三个步骤,我们不难理解,只需要在trigger方法中,也就是触发的时候通过判断是否传入了options.scheduler属性,来执行我们自己定义的scheduler函数逻辑或者是执行默认的effect.run方法。到此,我们的effect.ts文件可以说是暂时写完了。

以上,我们基本了解了响应式的核心reactiveeffect

computedwatchref方法的实现

watch的实现

我们先在reactive.ts/shared/src/index.ts中完善两个工具方法,方便我们在实现watch时进行导入调用。

javascript
// reactive.ts文件
// 判断传入的值是不是一个响应式的值
export function isReactive(value) {
	return !!(value && value[ReactiveFlags.IS_REACTIVE]);
}
// shared/src/index.ts 文件
// 判断传入的值,是不是一个函数
export const isFunction = (value) => {
	return value && typeof value === "function";
};

然后在/reactivity/src目录下新建apiWatch.ts文件,来写watch的主逻辑。首先我们简单回顾下Vue3watch的常见用法:

javascript
const state = reactive({ name: "张三", age: 18 });
// 用法1:
watch(
	() => state.name,
	(newV, oldV) => {
		console.log(newV, oldV);
	}
);
// 用法2:
watch(state, (newV, oldV) => {
	console.log(newV, oldV);
});

那么在用到watch的时候,第一个参数我们可以传入一个函数(如用法 1)来监听某个属性的变化,有朋友可能会问,为啥要写成一个函数,我直接把第一个参数传入state.name不行么?醒醒,快醒醒!在这个案例中state.name就是个定死的值张三,监听常量,肯定是不会发生变化的啊;

同样,第一个参数还可以传入一个对象(如方法 2)但是这种有几个问题,一般不推荐,比如当第一个参数传入的是对象,实际上watch监听的是这个对象的引用地址,所以,无法区分newVoldV,引用的地址是一直不变的,所以打印的结果会发现,这俩值是一样的,都是最新的值。还有个小问题就是,虽然你传入参数的是一个对象,但是在watch方法的内部,依旧是遍历了这个对象所有的key,并且进行取值操作(为的是触发依赖收集)。所以会对性能有所损耗,不过有时候为了方便,还是可以这么去干的(反正内部针对这种情况做了处理,代码写的爽就行了,管他呢)。

我们接下来实现watch的逻辑:

typescript
// /reactivity/src/apiWatch.ts 文件
import { isReactive } from "./reactive";
import { isFunction, isObject } from "@vue/shared";
import { ReactiveEffect } from "./effect";

function traverse(value, seen = new Set()) {
	if (!isObject(value)) {
		return value;
	}
	if (seen.has(value)) {
		return value;
	}
	seen.add(value);
	for (const key in value) {
		if (value.hasOwnProperty(key)) {
			// 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
			traverse(value[key], seen);
		}
	}
	return value;
}
// 1. 首先导出watch方法
export function watch(source, cb, { immediate } = {} as any) {
	// 2. 分两种情况判断,source是一个响应式对象和source是一个函数
	// 这个getter就相当于是effect中的回调函数
	let getter;
	if (isReactive(source)) {
		// 3. 如果source是一个响应式对象,应该对source递归进行取值
		getter = () => traverse(source);
	} else if (isFunction(source)) {
		getter = source;
	}
	let oldValue;
	// 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
	const job = () => {
		const newValue = effect.run();
		cb(newValue, oldValue);
		oldValue = newValue;
	};
	// 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
	const effect = new ReactiveEffect(getter, job);
	// 需要立即执行,那么就先执行一次任务
	if (immediate) {
		job();
	}
	// 5. 将立即执行的effect.run()的结果作为oldValue
	oldValue = effect.run();
}

首版代码就算是完成了,代码虽然不多,但是为了方便理解,我们还是需要拆分每个步骤,来进行一一讲解。

  • 步骤 1:很好理解,导出的watch,放入传参,这里第三个参数options我们只实现immediate的功能;
  • 步骤 2:就是上文提到的,对于传入的source,需要进行类型判断,如果是一个函数的话,那就让getter赋值为这个函数;如果是对象的话,那就用函数包一层。
  • 步骤 3:但是单独包一层,并不会触发依赖收集,所以就需要对这个响应式对象source进行遍历,然后对每个key进行取值,从而触发依赖收集;代码看上去的效果就是,只是取了下值,实际没有进行其他任何操作。为什么要包装成一个函数呢?别急,看到第 4 步就明白了。
  • 步骤 4:这步是不是非常熟?没错,在上篇写effect原理的时候,我们就是通过 new ReactiveEffect(fn, options.scheduler)进行生成的,所以,此步骤中,我们把getter当成第一个参数进行传参,把job当成第二个参数,也就是当响应式对象的属性发生变化时候,就会主动来调用job方法,如果忘了,可以再去复习下上篇文章。
  • 步骤 5:new完后,得到的effect,我们先执行一次effect.run方法,就能拿到最开始的返回值,记为oldValue
  • 步骤 6:就是步骤 4 中需要传入的job方法,当响应式对象的属性,发生变化,才会执行这个方法,我们在其中调用cb,并且传入oldValuenewValue,大功告成。

是不是发现,当我们理解了effect方法原理之后,再去写watch的实现,就变得非常简单了呢?所以说嘛effect是底层方法,很多方法都是基于它进行封装的。

接下来,我们再介绍一个Vue3watch提供的一个功能,所谓新功能,不是无缘无故就出来的,一定是为了解决相关的场景,所以才会提出的新功能,我们改动下index.html中的示例代码,先看看如下场景,该用什么方法来解决:

html
<script>
	const state = reactive({ name: "张三", age: 18 });
	let timmer = 4000;
	function getData(data) {
		return new Promise((resolve, reject) => {
			setTimeout(() => {
				resolve(data);
			}, (timmer -= 1000));
		});
	}
	watch(
		() => state.age,
		async (newV, oldV) => {
			const result = await getData(newV);
			console.log(result);
			app.innerHTML = result;
		}
	);
	// 我们这里直接改变响应式对象属性,模拟用户输入
	state.age = 20;
	state.age = 30;
	state.age = 40;
</script>

我们来简单说一下上边代码的含义,设想一下页面里有个输入框,每次输入内容,都会发送一个请求,我们这边模拟用户改变了 3 次值,所以一共发送了 3 次请求;第一个请求历时 4 秒钟能拿到返回结果,第二个请求历时 3 秒能拿到结果,第三个请求历时 2 秒能拿到结果,那么我们期望的页面显示内容,是以最后一次输入的结果为准,即页面上显示的是state.age = 40的结果。但是根据我们现在的逻辑,会发现,页面上过 2 秒后确实显示的是state.age = 40的结果,但是又过了 1 秒钟,state.age = 30这个请求的结果又被显示到页面上,又过了 1 秒state.age = 20的结果最终显示在了页面上,那显然不合理,我们的输入框中,最后明明是40,但是页面显示的结果却是20的请求结果。

所以我们此时需要来解决这个问题,我们第一反应就是,能不能在每次触发新请求的时候,屏蔽上次请求的结果呢?(注意,请求已经发送了,不能取消),这样,就能保证就算之前的请求,过了很久才拿到返回值,也不会覆盖最新的结果。那我们来在当前代码中,修改下吧!

html
<script>
	const state = reactive({ name: "张三", age: 18 });
	let timmer = 4000;
	// 1. 新建数组,用于存放上一次请求需要的方法
	let arr = [];
	function getData(data) {
		return new Promise((resolve, reject) => {
			setTimeout(() => {
				resolve(data);
			}, (timmer -= 1000));
		});
	}
	watch(
		() => state.age,
		async (newV, oldV) => {
			let fn;
			// 2. 每次发送请求前,利用闭包,将上次的结果flag改为false,从而屏蔽结果
			while (arr.length) {
				fn = arr.shift();
				fn();
			}
			// 3. 新建一个标识,为true才改变app.innerHTML的内容
			let flag = true;
			// 4. 将flag = false的函数,存在arr数组中,方便下次请求前进行调用
			arr.push(function () {
				flag = false;
			});
			const result = await getData(newV);
			console.log(result);
			flag && (app.innerHTML = result);
		}
	);
	// 我们这里直接改变响应式对象属性,模拟用户输入
	state.age = 20;
	state.age = 30;
	state.age = 40;
</script>

之后,我们在页面上再次打印结果,发现,页面上始终显示的是 40,也就是最后state.age = 40对应的结果。那么,我们通过在业务逻辑中,的一些代码改良,成功的解决了请求结果顺序错乱的问题。那么在Vue3watch中提供了新的参数,可以把一些逻辑放在watch的内部,从而达到和上述代码相同的效果,同样,我们先看用法,进而推导下在watch源码中是如何实现的。

html
<script>
	const state = reactive({ name: "张三", age: 18 });
	let timmer = 4000;
	function getData(data) {
		return new Promise((resolve, reject) => {
			setTimeout(() => {
				resolve(data);
			}, (timmer -= 1000));
		});
	}
	// 第三个参数提供了onCleanup,用户可以传入回调
	watch(
		() => state.age,
		async (newV, oldV, onCleanup) => {
			let flag = true;
			onCleanup(() => {
				flag = false;
			});
			const result = await getData(newV);
			console.log(result);
			flag && (app.innerHTML = result);
		}
	);
	// 我们这里直接改变响应式对象属性,模拟用户输入
	state.age = 20;
	state.age = 30;
	state.age = 40;
</script>

是不是发现,代码精简了很多?我们接下来实现一下吧!

typescript
// /reactivity/src/apiWatch.ts 文件
import { isReactive } from "./reactive";
import { isFunction, isObject } from "@vue/shared";
import { ReactiveEffect } from "./effect";

function traverse(value, seen = new Set()) {
	if (!isObject(value)) {
		return value;
	}
	if (seen.has(value)) {
		return value;
	}
	seen.add(value);
	for (const key in value) {
		if (value.hasOwnProperty(key)) {
			// 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
			traverse(value[key], seen);
		}
	}
	return value;
}
// 1. 首先导出watch方法
export function watch(source, cb, { immediate } = {} as any) {
	// 2. 分两种情况判断,source是一个响应式对象和source是一个函数
	// 这个getter就相当于是effect中的回调函数
	let getter;
	if (isReactive(source)) {
		// 3. 如果source是一个响应式对象,应该对source递归进行取值
		getter = () => traverse(source);
	} else if (isFunction(source)) {
		getter = source;
	}
	let oldValue;
	// 8. 创建cleanup变量,和onCleanup方法
	let cleanup;
	const onCleanup = (fn) => {
		cleanup = fn;
	};
	// 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
	const job = () => {
		// 9. 当cleanup存在,就调用我们onCleanup中传入的回调方法
		if (cleanup) cleanup();
		const newValue = effect.run();
		// 7. 首先在cb中添加这个onCleanup参数
		cb(newValue, oldValue, onCleanup);
		oldValue = newValue;
	};
	// 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
	const effect = new ReactiveEffect(getter, job);
	// 需要立即执行,那么就先执行一次任务
	if (immediate) {
		job();
	}
	// 5. 将立即执行的effect.run()的结果作为oldValue
	oldValue = effect.run();
}

看 7、8、9 三个步骤,其实就是类似于刚才我们写在外边的逻辑,只不过我们现在把这些逻辑写在了watch内部,多读几遍,非常巧妙。

至此为止,关于watch的核心逻辑,我们就已经写完了,是不是看起来,没有想象中的那么难呢?接下来我们还要实现下watchEffect,莫慌,只需要改动几行代码,便可轻松实现。首先,我们将刚才导出的watch改个名字换为doWatch,变成一个通用函数,因为前文说过,watchwatchEffect都是基于effect方法进行封装的,所以二者的逻辑可以说是非常相似的,所以我们没必要再写一遍,那么只要调用通用函数,根据传参不同,即可快速实现:

watchEffect的实现

typescript
// /reactivity/src/apiWatch.ts 文件
import { isReactive } from "./reactive";
import { isFunction, isObject } from "@vue/shared";
import { ReactiveEffect } from "./effect";

function traverse(value, seen = new Set()) {
	if (!isObject(value)) {
		return value;
	}
	if (seen.has(value)) {
		return value;
	}
	seen.add(value);
	for (const key in value) {
		if (value.hasOwnProperty(key)) {
			// 就是单纯的取了下值,比如state.name,为了触发reactive对象中属性的getter
			traverse(value[key], seen);
		}
	}
	return value;
}
// 1. 首先导出doWatch方法
export function doWatch(source, cb, { immediate } = {} as any) {
	// 2. 分两种情况判断,source是一个响应式对象和source是一个函数
	// 这个getter就相当于是effect中的回调函数
	let getter;
	if (isReactive(source)) {
		// 3. 如果source是一个响应式对象,应该对source递归进行取值
		getter = () => traverse(source);
	} else if (isFunction(source)) {
		getter = source;
	}
	let oldValue;
	// 8. 创建cleanup变量,和onCleanup方法
	let cleanup;
	const onCleanup = (fn) => {
		cleanup = fn;
	};
	// 6. 当触发更新的时候,也就是属性改变的时候,才会执行这个job方法
	const job = () => {
		// 10. 根据传参不同,判断如果有回调函数的话,那么就是watch,如果没有cb那就是watchEffect
		if (cb) {
			// 9. 当cleanup存在,就调用我们onCleanup中传入的回调方法
			if (cleanup) cleanup();
			const newValue = effect.run();
			// 7. 首先在cb中添加这个onCleanup参数
			cb(newValue, oldValue, onCleanup);
			oldValue = newValue;
		} else {
			effect.run();
		}
	};
	// 4. 当new的时候,就会执行getter方法,开始进行依赖收集,数据变化后,会执行cb的回调方法
	const effect = new ReactiveEffect(getter, job);
	// 需要立即执行,那么就先执行一次任务
	if (immediate) {
		job();
	}
	// 5. 将立即执行的effect.run()的结果作为oldValue
	oldValue = effect.run();
}
// 导出watch和watchEffect方法
export function watch(source, cb, options) {
	return doWatch(source, cb, options);
}
export function watchEffect(source, options) {
	return doWatch(source, null, options);
}

改动点仅仅是第 10 步骤,加了一个判断,那么这样doWatch就是一个通用函数,只需要根据传参不同,在外边再包一层,就是我们平时中项目常用的watchwatchEffect了!怎样,是不是很容易?那我们继续往下看吧

computed的实现

我们还是简单用一下computed,看看有哪几种用法:

html
<script>
	const state = reactive({ name: '张三', age: 18 })
	// 1. 可以传入对象,里边自定义get和set的逻辑
	const info = computed({
	  get() {
	    console.log('我触发啦!')
	    return state.name + state.age
	  }
	  set(val){
	    console.log(val)
	  }
	})
	// 虽然取了2次值,但是只会打印一次'我触发了',因为computed有缓存的效果,依赖的值不变化,就不会多次触发get,要通过.value来取值
	console.log(info.value)
	console.log(info.value)
	// 2. 传入函数,默认就相当于返回了一个get,取值要通过.value来取
	const info = computed(() => {
	  return state.name + state.age
	})
</script>

回顾了下基本用法后,我们还是在reactivity/src目录下,新建computed.ts文件,然后在reactivity/src/index.tsexport * from '.computed',进行导出。接下来,我们便可以在computed.ts中来实现computed的逻辑了。

javascript
// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect } from './effect'

class ComputedRefImpl {
  public effect
  public _value
  public __v_isRef = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {

    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    this._value = this.effect.run()
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}

export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}

我们来按步骤一一讲解下

  • 步骤 1:没错,非常熟悉的套路,和watch处理参数的方式几乎是一模一样;
  • 步骤 2:返回一个响应式对象,获取computed的值,需要通过.value的方法;
  • 步骤 3:依旧是通过new ReactiveEffect,传入getter进行依赖收集,生成effect实例对象;
  • 步骤 4:因为computed返回的对象,是通过.value来访问的,所以要创建get set,执行相应逻辑;

至此,我们的computed就可以简单的用起来了,我们先运行一下,其他的问题,我们后边再来解决,我们改变下index.html的代码,查看打印结果:

html
<script>
	const state = reactive({ name: "张三", age: 18 });
	const info = computed({
		get() {
			console.log("我调用啦!");
			return state.name + state.age;
		},
		set(val) {
			console.log(val);
		},
	});
	// 对info.value取两次值,查看结果
	console.log(info.value);
	console.log(info.value);
</script>

此时,我们会发现,控制台中打印的结果是:

bash
我调用啦!
张三18
我调用啦!
张三18

这和我们平时用的computed好像哪里有些不同?没错,info中依赖的响应式对象state中的属性,并没有变化,但是却触发了两次computed,并没有实现缓存的效果,那么我们接下来就来实现一下吧!

javascript
// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect } from './effect'

class ComputedRefImpl {
  public effect
  public _value
  // 5. 创建一个_dirty变量,为true的时候就代表可以重新执行取值操作
  public _dirty = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {
      // 7. 依赖的值变了,判断_dirty是否为false,为false的话,就把_dirty改为true
      this_dirty = true
    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    // 6. 如果_dirty是true的话,才会重新执行`run`方法,重新取值,否则,直接返回原值
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}

export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}

我们看捕捉 5~7,是不是通过一个_dirty属性,就实现了如果依赖不发生变化,那么就不会多次触发computed对象中的get了呢?还是那句话,computed的实现,依旧是依赖于effect,所以理解effect才是重中之重。

看起来是没啥问题了,但是在有一种场景下,存在着问题,我们改一下index.html代码,来看一下:

html
<script>
	const state = reactive({ name: "张三", age: 18 });
	const info = computed({
		get() {
			console.log("我调用啦!");
			return state.name + state.age;
		},
		set(val) {
			console.log(val);
		},
	});
	effect(() => {
		app.innerHTML = info.value;
	});
	console.log(info.value);
	setTimeout(() => {
		state.age = 22;
		console.log(info.value);
	}, 2000);
</script>

没错,就是当我们在effect方法中,使用了computed计算属性,那么页面就不会更新,因为effect中并没有对计算属性进行依赖收集,而computed计算属性中也没有对应的effect方法。那怎么实现呢?我们想一想,是不是很类似于之前写的依赖收集track和触发更新trigger方法呢?没错,我们只需要在computed中增加进行依赖收集和触发更新的逻辑就好了,而这两个逻辑,我们之前也写过,所以可以把通用的代码直接copy过来:

javascript
// reactivity/src/computed.ts 文件
import { isFunction } from '@vue/shared'
import { ReactiveEffect, activeEffect, trackEffects, triggerEffects } from './effect'

class ComputedRefImpl {
  public effect
  public _value
  public dep = new Set()
  // 5. 创建一个_dirty变量,为true的时候就代表可以重新执行取值操作
  public _dirty = true
  constructor(getter, public setter) {
    // 3. 还是通过new ReactiveEffect方法
    this.effect = new ReactiveEffect(getter, () => {
      // 7. 依赖的值变了,判断_dirty是否为false,为false的话,就把_dirty改为true
      this_dirty = true
      // 9. 触发更新
      triggerEffects(this.dep)
    })
  }
  // 4. 给value属性,创建get和set,再取值和赋值的时候,触发相应逻辑
  get value() {
    // 8. 如果计算属性在effect中使用的话,那也要做依赖收集
    if (activeEffect) {
      trackEffects(this.dep)
    }
    // 6. 如果_dirty是true的话,才会重新执行`run`方法,重新取值,否则,直接返回原值
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  set value(newV){
    this.setter(newValue)
  }
}

export function computed(getterOrOptions) {
  // 1. 对传入的参数进行分类处理,对函数和对象进行不同的处理。
  const isGetter = isFunction(getterOrOptions)
  let getter, setter
  if (isGetter) {
    getter = isGetter
    setter = () => ({})
  } else {
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 2. 创建计算属性,返回一个响应式对象,访问的时候,通过.value的方式来访问
  return new ComputedRefImpl(getter, setter)
}
// reactivity/src/effect.ts 文件
export function triggerEffects(dep) {
  const effects = [...dep]
  effects && effects.forEach(effect => {
    // 正在执行的effect,不要多次执行,防止死循环
    if (effect !== activeEffect) {
      // 如果用户传入了scheduler,那么就执行用户自定义逻辑,否则还是执行run逻辑
      if(effect.scheduler) {
        effect.scheduler()
      }else {
        effect.run()
      }
    }
  })
}
// computed中收集effect的依赖
export function trackEffects(dep) {
  let shouldTrack = !dep.has(activeEffect)
  if(shouldTrack) {
    // 依赖和effect多对多关系保存
    dep.add(activeEffect)
    activeEffect.deps.push(dep)
  }
}

我们看步骤 8,9,依赖收集和触发更新的方法,我们依旧写在effect.ts文件中(可以对比下triggertrack方法,逻辑几乎一模一样)。我们再运行刚才index.html中的代码,发现页面成功的更新了,那么至此,computed的核心逻辑我们就写完啦!

ref的实现

我们在reactivity/src目录下创建ref.ts文件

typescript
import { isObject } from "@vue/shared";
import { activeEffect, trackEffects, triggerEffects } from "./effect";
import { reactive } from "./reactive";

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

class RefImpl {
	public _value;
	public dep = new Set();
	public __v_isRef = true;
	constructor(public rawValue) {
		// 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
		this._value = toReactive(rawValue);
	}
	get value() {
		if (activeEffect) {
			// 2. 进行依赖收集
			trackEffects(this.dep);
		}
		return this._value;
	}
	set value(newVal) {
		if (newVal !== this.rawValue) {
			this.rawValue = newVal;
			this._value = toReactive(newVal);
			// 3. 进行触发更新
			triggerEffects(this.dep);
		}
	}
}

有了前边的基础,写起ref来,就显得非常得心应手,核心其实就这几行代码,通过注释,我们就不难发现,如果传入的是对象,那么就是利用了之前写的reactive进行包装处理,如果传入了其他类型的数据,那么就和computed中的方法一模一样,需要进行依赖收集和触发更新。

实现toReftoRefs

这两个方法,其实我们开发中,用的会比较少,所以还是先简单介绍下用法,然后再思考下如何实现,最后再来写一下它们的原理:

html
<script>
	const state = reactive({name: '张三'}))
	// 单独把name取出来
	let name = state.name
	effect(() => {
	  app.innerHTML = name
	})
	setTimeout(() => {
	  state.name = '李四'
	}, 1000)
</script>

这是上边的代码可以看到,当我们将let name = state.name单独取出来之后,再修改state.name的值之后,name的值就不会再发生变化了,页面上的名字也不会随之发生变化,也就是所谓的丢失响应式,那么利用toRef就可以解决这种问题:

html
<script>
	const state = reactive({name: '张三'}))
	// 单独把name取出来
	let name = toRef(state, 'name')
	effect(() => {
	  app.innerHTML = name.value
	})
	setTimeout(() => {
	  state.name = '李四'
	}, 1000)
</script>

我们来思考一下如何实现呢?为了不丢失响应式,所以就需要联系,那么肯定就是在namestate.name之间存在某种联系,当改变state.name值的时候,从而能使得name同步进行变动。既然这样,那不就可以做一层代理,当访问和修改name的时候,实际是去访问和修改state.name的值么?思路有了,我们便可以通过代码来实现:

typescript
// reactivity/src/ref.ts

import { isObject } from "@vue/shared";
import { activeEffect, trackEffects, triggerEffects } from "./effect";
import { reactive } from "./reactive";

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

class RefImpl {
	public _value;
	public dep = new Set();
	public __v_isRef = true;
	constructor(public rawValue) {
		// 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
		this._value = toReactive(rawValue);
	}
	get value() {
		if (activeEffect) {
			// 2. 进行依赖收集
			trackEffects(this.dep);
		}
		return this._value;
	}
	set value(newVal) {
		if (newVal !== this.rawValue) {
			this.rawValue = newVal;
			this._value = toReactive(newVal);
			// 3. 进行触发更新
			triggerEffects(this.dep);
		}
	}
}
// 导出ref
export function ref(value) {
	return new RefImpl(value);
}

class ObjectRefImpl {
	public __v_isRef = true;
	constructor(public _object, public _key) {}
	get value() {
		return this._object[this._key];
	}
	set value(newVal) {
		this._object[this._key] = newVal;
	}
}
// 导出toRef
export function toRef(object, key) {
	return new ObjectRefImpl(object, key);
}
// 导出toRefs
export function toRefs(object) {
	// 如果传入数组,就创建一个空数组,如果是对象,那就创建一个新对象
	const ret = isArray(object) ? new Array(object.length) : {};
	for (const key in object) {
		ret[key] = toRef(object, key);
	}
	return ret;
}

代码非常简单,就是进行了一次代理转化,而我们项目中常用的是toRefs,也是遍历每个属性,并借助toRef来实现的。

proxyRef的实现

这个方法可能听起来很陌生,但是只要写过Vue3的项目,就一定会用到这个方法,举个例子就明白了:

html
<template>
	<div>{{ name }}</div>
</template>
<script>
	let name = ref("张三");
</script>

当我们在代码中,用ref声明了一个字符串类型的数据后,如果在代码中使用这个值,是不是需要通过name.value的方式来调用呢?但是当我们在模板中使用的时候,却可以直接来用这个name而并不需要再.value来取值,诶,这就是Vue3在模板编译的时候,内部调用了这个方法,帮助我们对ref声明变量,进行自动脱钩,那么细心的朋友也发现了,不管是在computed,还是ref代码中,都有一样public __v_isRef = true这个标识,没错,接下来就要用到这个标识了,这个标识就是为了在自动脱钩的时候,来进行分辨的。那么我们来实现这个proxyRef方法吧~

typescript
// reactivity/src/ref.ts

import { isObject } from "@vue/shared";
import { activeEffect, trackEffects, triggerEffects } from "./effect";
import { reactive } from "./reactive";

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

class RefImpl {
	public _value;
	public dep = new Set();
	public __v_isRef = true;
	constructor(public rawValue) {
		// 1. 判断传入的值,如果是对象类型,那么就将其包装成响应式对象
		this._value = toReactive(rawValue);
	}
	get value() {
		if (activeEffect) {
			// 2. 进行依赖收集
			trackEffects(this.dep);
		}
		return this._value;
	}
	set value(newVal) {
		if (newVal !== this.rawValue) {
			this.rawValue = newVal;
			this._value = toReactive(newVal);
			// 3. 进行触发更新
			triggerEffects(this.dep);
		}
	}
}
// 导出ref
export function ref(value) {
	return new RefImpl(value);
}

class ObjectRefImpl {
	public __v_isRef = true;
	constructor(public _object, public _key) {}
	get value() {
		return this._object[this._key];
	}
	set value(newVal) {
		this._object[this._key] = newVal;
	}
}
// 导出toRef
export function toRef(object, key) {
	return new ObjectRefImpl(object, key);
}
// 导出toRefs
export function toRefs(object) {
	// 如果传入数组,就创建一个空数组,如果是对象,那就创建一个新对象
	const ret = isArray(object) ? new Array(object.length) : {};
	for (const key in object) {
		ret[key] = toRef(object, key);
	}
	return ret;
}

export function isRef(value) {
	return !!(value && value.__v_isRef === true);
}
// 如果是ref则取ref.value
export function proxyRefs(objectWithRefs) {
	return new Proxy(objectWithRefs, {
		get(target, key, receiver) {
			let v = Reflect.get(target, key, receiver);
			return isRef(v) ? v.value : v;
		},
		set(target, key, value, receiver) {
			const oldValue = target[key];
			// 老的值如果是个ref,那么实际上赋值的时候应该给他的.value进行赋值
			if (oldValue.__v_isRef) {
				oldValue.value = value;
				return true;
			} else {
				// 其他情况,正常赋值
				return Reflect.set(target, key, value, receiver);
			}
		},
	});
}

这个proxyRef方法,在后续文章中,会用到,这里只是提前介绍下这个方法。

结语

那么至此,我们reactivity响应式模块中的一些个核心的方法,基本上已经实现了最核心的逻辑,这样,我们再去阅读源码的时候,就不会变得一头雾水了,好好再熟悉一遍reactivity模块的方法吧,然后再去看下Vue3源码中的reactivity逻辑;我们接下来会继续分析Vue3,其他模块的核心代码。

Vue3 渲染原理

Vue3的组成,是有编译时运行时的概念。

  • 编译时:其实就是将模板转化为函数的过程,举个例子,就是将我们写的模板代码,如<template></template>转化为函数。之所以用模板的方式来写,纯粹是为了减少开发的心智负担,能够根据语义化进行代码书写,而不必用各种函数调用的方式来生成。
  • 运行时:运行时又分为两个部分,那么运行时的核心,也就是runtime-core是不依赖任何平台的,

那么模块之间的依赖就是runtime-dom提供了浏览器运行环境中的DOM API,而runtime-core提供了虚拟dom的核心逻辑,通过runtime-dom提供的API,从而生成真实DOM,而runtime-core中又会引入reactivity包中的内容,所以整体的流程是Vue -> runtime-dom -> runtime-core -> reactivity后者均是前者的子级,由前者导入使用。我们本篇文章主要讲运行时相关的内容。

runtime-core包中,提供了一个方法createRenderer,看着虽然陌生,但是在我们项目中的createApp(在runtime-dom包中实现),其实底层调用的就是这个方法,那么我们便从这个方法开始,一步步学习runtime-domruntime-core这两个包吧!

runtime-dom的实现

首先,我们依旧是要创建文件夹,和之前的套路一样,先看下示例效果,再进行代码书写。和reactivity包位置相同,我们创建runtime-dom文件夹和package.json文件,并且在runtime-dom文件夹下边创建src/index.ts作为入口;

创建dist/index.html作为效果展示示例页面。同样,我们把node_modules文件夹中,Vue官方打包好的compiler-dom.esm-browser.js文件,复制进dist目录下,和我们之前reactivity的操作一模一样,先看看人家官方的方法实现效果,再自己实现一遍。

最后,别忘了将script/dev.js中的target改为runtime-dom,这样,我们就是从runtime-dom/src/index.ts作为入口进行打包了。万事具备,我们写一下测试代码,看看有没有跑通吧:

js
// runtime-dom/src/index.ts
export const testName = "测试runtime-dom";

然后执行npm run dev,对我们runtime-dom模块的代码进行打包,之后修改dist/index.html文件内容,执行npx serve dist,在浏览器控制台观测结果,成功打印了testName,便说明我们已经调通了。

runtime-dom/dist/index.html:

html
<script type="module">
	import { testName } from "./runtime-dom.js";
	console.log(testName);
</script>

我们首先看下两个方法:createRendererh的用法。

html
<script type="module">
	// runtime-dom/dist/index.html 文件
	// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";
</script>

那么这两个方法,其实是runtime-core中提供的,前文也说过,其实runtime-dom提供的主要是浏览器相关的API,作为参数传入createRenderer中。什么意思呢?我们一步一步来看。

相信h方法,大家都有所耳闻,可以生成一个虚拟DOM,那么调用createRenderer就可以将虚拟DOM,通过我们传入的API,在页面中生成真实的DOM,我们再次修改示例代码,然后查看控制台结果。

html
<script type="module">
	// runtime-dom/dist/index.html 文件
	// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";
	const renderer = createRenderer();
	// 将h1渲染到页面上
	renderer.render(h("h1", "hello world"), document.getElementById("app"));
</script>

发现控制台竟然报错了:

image.png

代码非常简单,就是想要将h1标签渲染到页面上,但是为啥报错了呢?我们查看报错的内容,可以发现,提示我们缺少insert方法,这是啥意思呢?没错,前文提到了runtime-dom这个包中,提供了DOM操作的API,将这些API配置项等传入createRenderer,才能够正常的执行代码,所以我们此时要传入一个insert方法,告诉runtime-core在将虚拟DOM转化为真实DOM,进行插入操作,要用我们传入的这个insert方法:

html
<script type="module">
	// runtime-dom/dist/index.html 文件
	// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";

	const renderOptions = {
		// 我们自己提供一个insert方法,当做api来调用
		insert(el, container, anchor = null) {
			container.insertBefore(el, anchor);
		},
	};
	// 传入配置项,里边包含各种操作的api
	const renderer = createRenderer(renderOptions);
	// 将h1渲染到页面上
	renderer.render(h("h1", "hello world"), document.getElementById("app"));
</script>

此时我们再刷新页面,发现又有了新的报错,很明显,有了前边的经验,我们很容易能明白,原来还缺少一个创建元素的方法,runtime-core不知道用哪个API来进行元素的创建,于是我们又补充了一下代码:

image.png

html
<script type="module">
	// runtime-dom/dist/index.html 文件
	// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";

	const renderOptions = {
		// 我们自己提供一个insert方法,当做api来调用
		insert(el, container, anchor = null) {
			container.insertBefore(el, anchor);
		},
		// 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
		createElement(element) {
			return document.createElement(element);
		},
	};
	// 传入配置项,里边包含各种操作的api
	const renderer = createRenderer(renderOptions);
	// 将h1渲染到页面上
	renderer.render(h("h1", "hello world"), document.getElementById("app"));
</script>

我们再运行代码,发现又有了一个报错,还真是没完没了- -,我们不难分析出来,还需要提供一个设置元素值的方法,于是我们再次修改了配置项

image.png

html
<script type="module">
	// runtime-dom/dist/index.html 文件
	// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";

	const renderOptions = {
		// 我们自己提供一个insert方法,当做api来调用
		insert(el, container, anchor = null) {
			container.insertBefore(el, anchor);
		},
		// 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
		createElement(element) {
			return document.createElement(element);
		},
		// 将文字内容赋值给元素
		setElementText(el, text) {
			el.innerHTML = text;
		},
	};
	// 传入配置项,里边包含各种操作的api
	const renderer = createRenderer(renderOptions);
	// 将h1渲染到页面上
	renderer.render(h("h1", "hello world"), document.getElementById("app"));
</script>

这次我们再刷新页面,可以发现,页面上终于打印出了hello world,也就是说,至少要提供创建元素、插入元素、设置元素内容这 3 个API,才能够在页面上正常显示一个基本的元素。

说了这么多,大家应该知道runtime-dom的大致作用了吧?没错,就是提供了上述的这些个renderOptionsDOM相关的API。所以,我们有了大致的思路,便开始实现一下吧!

目录结构如下:

js
runtime - dom;
src;
module; // 存放模块文件
index.ts; // 入口文件
nodeOps.ts; // 操作节点相关的api
patchProp.ts; // 属性相关的api
// src/index.ts
import { nodeOps } from "./nodeOps";
import { patchProp } from "./patchProp";

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps);

nodeOps.ts文件中,我们存放了和节点相关的操作,不止上文中提到的三个,常见的还有如下一些API

javascript
// src/nodeOps.ts文件
export const nodeOps = {
	insert: (child, parent, anchor) => {
		// 添加节点
		parent.insertBefore(child, anchor || null);
	},
	remove: (child) => {
		// 节点删除
		const parent = child.parentNode;
		if (parent) {
			parent.removeChild(child);
		}
	},
	createElement: (tag) => document.createElement(tag), // 创建节点
	createText: (text) => document.createTextNode(text), // 创建文本
	setText: (node, text) => (node.nodeValue = text), //  设置文本节点内容
	setElementText: (el, text) => (el.textContent = text), // 设置文本元素中的内容
	parentNode: (node) => node.parentNode, // 父节点
	nextSibling: (node) => node.nextSibling, // 下一个节点
	querySelector: (selector) => document.querySelector(selector), // 查找元素
};

除了节点操作,还涉及到了对比属性的方法,比如处理类,样式的替换,事件的绑定解绑,这些都写在src/patchProp.ts文件中:

javascript
// src/patchProp.ts文件

import { patchClass } from "./module/class";
import { patchStyle } from "./module/style";
import { patchEvent } from "./module/event";
import { patchAttr } from "./module/attr";

// 比对属性的方法
export const patchProp = (el, key, prevValue, nextValue) => {
	if (key === "class") {
		patchClass(el, nextValue);
	} else if (key === "style") {
		patchStyle(el, prevValue, nextValue);
	} else if (/^on[^a-z]/.test(key)) {
		patchEvent(el, key, nextValue);
	} else {
		patchAttr(el, key, nextValue);
	}
};

针对不同情况的处理,把这些文件单独放在module文件夹下

javascript
// src/module/attr.ts 文件
export function patchAttr(el, key, value) {
	// 更新属性
	if (value == null) {
		el.removeAttribute(key);
	} else {
		el.setAttribute(key, value);
	}
}
// src/module/class.ts 文件
export function patchClass(el, value) {
	// 根据最新值设置类名
	if (value == null) {
		el.removeAttribute("class");
	} else {
		el.className = value;
	}
}
// src/module/event.ts 文件

function createInvoker(initialValue) {
	const invoker = (e) => invoker.value(e);
	// 真实的方法,是绑定在.value上的
	invoker.value = initialValue;
	return invoker;
}
export function patchEvent(el, rawName, nextValue) {
	const invokers = el._vei || (el._vei = {});
	const exisitingInvoker = invokers[rawName]; // 是否缓存过

	if (nextValue && exisitingInvoker) {
		// 有新值并且绑定过事件,需要进行换绑操作
		exisitingInvoker.value = nextValue;
	} else {
		// 获取注册事件的名称
		const name = rawName.slice(2).toLowerCase();
		if (nextValue) {
			// 缓存函数
			const invoker = (invokers[rawName] = createInvoker(nextValue));
			el.addEventListener(name, invoker);
		} else if (exisitingInvoker) {
			el.removeEventListener(name, exisitingInvoker);
			invokers[rawName] = undefined;
		}
	}
}
// src/module/style.ts 文件
export function patchStyle(el, prev, next) {
	// 更新style
	const style = el.style;
	for (const key in next) {
		// 用最新的直接覆盖
		style[key] = next[key];
	}
	if (prev) {
		for (const key in prev) {
			// 老的有新的没有删除
			if (next[key] == null) {
				style[key] = null;
			}
		}
	}
}

那么有了这些个APIruntime-core就知道,应该用哪些方法将虚拟DOM转化为真实DOM了。之后,我们引入自己的renderOptions看看能不能正常渲染:

html
<script type="module">
	import { createRenderer, h } from "./runtime-dom.esm-browser.js";
	import { renderOptions } from "./runtime-dom.js";
	const renderer = createRenderer(renderOptions);
	renderer.render(h("h1", "hello"), app);
</script>

页面正常渲染了!那么针对上文这种方式,适合针对某个平台(跨平台),自己定义一套渲染API,可以随意进行定制化,如果在浏览器环境下,其实正如上文所说,API都已经在runtime-dom中了,所以在内部又提供了一个方法(render),默认把这一坨renderOptions自动传进去了,不用我们再手动传入:

html
<script type="module">
	import { createRenderer, h, render } from "./runtime-dom.esm-browser.js";
	// import { renderOptions } from './runtime-dom.js'
	//const renderer = createRenderer(renderOptions)
	//renderer.render(h('h1', 'hello'), app)
	render(h("h1", "hello"), app);
</script>

再次运行代码,发现结果没变,还是能正常运行,说明这两种方式都可行,使用render的话,相当于默认传入浏览器环境下的API,使用createRenderer可以自定义传入API,比较灵活,所以,我们最后还需要改一下入口文件的内容:

javascript
// src/index.ts
import { nodeOps } from "./nodeOps";
import { patchProp } from "./patchProp";
import { createRenderer as renderer } from "@vue/runtime-core";

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps);

export function createRenderer(options) {
	// 提供了渲染的api,但实际调用的是runtime-core中的方法
	return renderer(options);
}

// 专门给浏览器环境中使用
export function render(vnode, container) {
	const renderer = createRenderer(renderOptions);
	return renderer.render(vnode, container);
}
// 将runtime-core中的方法都进行导出
export * from "@vue/runtime-core";

既然从runtime-core包中引入了渲染的方法,那么接下来我们需要的就是来实现runtime-core的核心逻辑了。

runtime-core的实现

老规矩,首先还是创建相应文件夹:

bash
runtime-core
      src
        index.ts // 入口文件
        createVNode.ts // 创建虚拟DOM
        renderer.ts // 创建真实DOM进行渲染
        h.ts // 封装createVNode,形成h方法

先在入口文件进行导出操作,防止后边忘记掉,我们不难发现,正如我们之前所说,runtime-domDOM相关API传给runtime-coreruntime-core中又使用了reactivity模块,至此,三个模块便互相串通了起来。

javascript
// index.ts 入口文件
export * from "./renderer";
export * from "./createVNode";
export * from "./h";
export * from "@vue/reactivity";

我们还是先紧跟着runtime-dom的逻辑,先写下renderer.ts的大概逻辑,那么这个就是我们runtime-dom中使用的createRenderer方法,实际上调用的还是runtime-core中的方法。

javascript
// renderer.ts文件
export function createRenderer(renderOptions) {
	// 从renderOptions中解构api,并重命名
	const {
		insert: hostInsert,
		remove: hostRemove,
		patchProp: hostPatchProp,
		createElement: hostCreateElement,
		createText: hostCreateText,
		setText: hostSetText,
		setElementText: hostSetElementText,
		parentNode: hostParentNode,
		nextSibling: hostNextSibling,
		querySelector: hostQuerySelector,
	} = renderOptions;
	const render = (vnode, container) => {
		console.log("render");
	};
	return {
		render,
	};
}

接下来我们写一下createVNode.ts中的逻辑。所谓虚拟DOM,就是用对象的形式,来形容一个节点,标注了各种信息,为了之后转化成真实DOM

javascript
import { ShapeFlags } from "@vue/shared";
// 判断是不是一个虚拟节点
export function isVNode(value) {
	return value ? value.__v_isVNode === true : false;
}
export function createVNode(type, props, children = null) {
	const shapeFlag = typeof type === "string" ? ShapeFlags.ELEMENT : 0;
	// 虚拟节点包含的信息
	const vnode = {
		__v_isVNode: true, // 判断对象是不是虚拟节点
		type,
		props,
		key: props && props["key"], // 虚拟节点的key,主要用于diff算法
		el: null, // 虚拟节点对应的真实节点
		children,
		shapeFlag,
	};
	if (children) {
		let type = 0;
		if (Array.isArray(children)) {
			type = ShapeFlags.ARRAY_CHILDREN;
		} else {
			children = String(children);
			type = ShapeFlags.TEXT_CHILDREN;
		}
		vnode.shapeFlag |= type;
		// 如果shapeFlag结果为9 说明元素中包含一个文本
		// 如果shapeFlag结果为17 说明元素中有多个子节点
	}
	// 返回的虚拟节点并且标注了虚拟节点的类型,之后生成真实DOM时,根据shapFlag调用不同的方法。
	return vnode;
}

我们在@vue/shared包中补充下ShapFlags,并且来详细解释一下,这到底是个什么东西。

javascript
// shared/src/index.ts
export const enum ShapeFlags { // Vue3提供的标识
  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 // 按位或操作,相当于包含两种类型
}

很多朋友可能对<< | &移位、按位或、按位与很陌生,就算知道其定义,也不知道有哪些个使用场景。其实在Vue3中,就有很好的例子。比如这个ShapFlags通过名称我们便能大致猜出来,是描述形状的标志,比如一个普通的元素,就用 1 来代表,函数式组件就用 1 向左移 1 位来表示,普通的状态组件,就用 1 向左移 2 位来表示。为啥要用移位操作呢?搞几个普通的枚举值不行么,其实,之所以用移位来进行标识,是为了后续进行按位与,按位或操作提供了极大的便利。我们举个例子,我们有如下的 3 种权限:

bash
测试:1,二进制为001
开发者:1 << 1,二进制为010
超级管理员:1 << 2,二进制为100

那么当A,既是开发者,又是超级管理员的时候,那么只需要将开发者超级管理员的权限进行按位或操作,也就是010100进行按位或操作,得到的结果为110,大于0。那么判断A有没有测试权限,只需要将刚才的结果和测试的权限进行按位与操作,即110001进行按位与操作,得到的结果为000,等于0。从而我们可以发现,在使用移位符操作的枚举值,进行|操作后,相当于权限相加的操作,进行&操作后,如果结果大于0,说明包含相关权限,如果结果等于0,则说明不包括相关权限。

那么再回到我们之前的实例,我们改动下index.html中的代码,来调试下代码有没有生效,先调试下createVNode方法:

html
<script>
	import { createVNode } from "./runtime-dom.js";
	console.log(createVNode("div", null, ["hello", "world"]));
</script>

打印结果可以看到,虚拟节点成功的被创建了:

image.png

但是createVNode这个方法,写法是固定的,比如传参的顺序和类型,都不能变,并不灵活,(特别注意,createVNode的第三个参数,只能传字符串和数组类型的数据),所以,我们可以基于createVNode进行封装,那么这个方法就是我们熟悉的h方法了,首先我们先看下h方法能怎么传参:

  • 只传 1 个参数,就是标签;
  • 传 2 个参数,可能是传标签和属性:h('div', { style: { color: 'red' } }),也可能是传标签和子元素:h('div', h('span', null, 'hello')) h('div', [h('span', null, 'hello')]),还可能是传标签和内容:h('div', 'hello')
  • 传 3 个参数,那就是和createVNode的传参一样了,即h('div', { style: {color: 'red'} }, 'hello')
  • 传 3 个以上的参数,第二个参数必须是属性,之后的参数都作为内容:h('div', null, 'hello', 'world', '!')

那么知道了以上的用法,我们便可以按照传参数量的不同,分别处理相应逻辑,来编写h方法了:

javascript
// h.ts 文件
import { isArray, isObject } from "@vue/shared";
import { createVNode, isVNode } from "./createVNode";

export function h(type, propsOrChildren?, children?) {
	const l = arguments.length;
	if (l === 2) {
		// 只有属性,或者只有一个生成的虚拟元素的时候
		if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
			// 区分第二个参数是属性还是生成的虚拟元素,比如h('div',h('span'))
			if (isVNode(propsOrChildren)) {
				// 如果是虚拟元素,根据createVNode的传参要求,就要用数组包起来
				return createVNode(type, null, [propsOrChildren]);
			}
			// 如果是h('div',{style:{color:'red'}}),则进行如下传参
			return createVNode(type, propsOrChildren);
		} else {
			// 传递儿子列表h('div',null,[h('span'),h('span')])或者h('div', 'hello')的情况
			return createVNode(type, null, propsOrChildren);
		}
	} else {
		// 除了前2个,后边的都是子元素
		if (l > 3) {
			children = Array.prototype.slice.call(arguments, 2);
		} else if (l === 3 && isVNode(children)) {
			// 第三个参数传入的是生成的虚拟元素
			children = [children];
		}
		return createVNode(type, propsOrChildren, children);
	}
}

到此,我们h方法便写好了,是不是没有想象中那么困难呢?

接下来,就该完善createRenderer方法,也就是渲染方法了,之后二者一结合,就能够在页面中,将虚拟DOM渲染成真实DOM了,刚才我们写到了render方法,那我们继续完善吧!

javascript
// renderer.ts 文件
export function createRenderer(renderOptions) {
	// 从renderOptions中解构api,并重命名
	const {
		insert: hostInsert,
		remove: hostRemove,
		patchProp: hostPatchProp,
		createElement: hostCreateElement,
		createText: hostCreateText,
		setText: hostSetText,
		setElementText: hostSetElementText,
		parentNode: hostParentNode,
		nextSibling: hostNextSibling,
		querySelector: hostQuerySelector,
	} = renderOptions;
	const render = (vnode, container) => {
		// 虚拟节点渲染成真实DOM,挂载到页面上
		// 卸载操作 render(null, container)
		// 初始化和更新虚拟DOM
		if (vnode == null) {
			// 卸载逻辑
		} else {
			// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
			patch(container._vnode || null, vnode, container);
		}
		container._vnode = vnode;
	};
	return {
		render,
	};
}
const mountElement = (vnode, container) => {
	// 将虚拟节点转化为真实DOM
};

// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
	if (n1 == n2) {
		return;
	}
	if (n1 == null) {
		// 初始化情况
		mountElement(n2, container);
	} else {
		// n1, n2不相等,diff算法逻辑
	}
};

那么整个流程的架子我们已经搭好了,接下来就一个个来实现具体的方法,我们先实现将虚拟DOM转化为真实DOMmountElement方法:

javascript
// renderer.ts 文件
export function createRenderer(renderOptions) {
	// 从renderOptions中解构api,并重命名
	const {
		insert: hostInsert,
		remove: hostRemove,
		patchProp: hostPatchProp,
		createElement: hostCreateElement,
		createText: hostCreateText,
		setText: hostSetText,
		setElementText: hostSetElementText,
		parentNode: hostParentNode,
		nextSibling: hostNextSibling,
		querySelector: hostQuerySelector,
	} = renderOptions;
	const mountChildren = (children, container) => {
		for (let i = 0; i < children.length; i++) {
			// 递归调用patch方法
			patch(null, children[i], container);
		}
	};
	const mountElement = (vnode, container) => {
		// 将虚拟节点转化为真实DOM
		const { type, props, shapeFlag, children } = vnode;
		// 创建真实元素,挂载到虚拟节点上
		let el = (vnode.el = hostCreateElement(type));
		// 如果有props,则处理属性
		if (props) {
			for (const key in props) {
				hostPatchProp(el, key, null, props[key]);
			}
		}
		if (children) {
			if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
				// 说明是文本
				hostSetElementText(el, vnode.children);
			} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
				// 说明有多个儿子
				mountChildren(vnode.children, el);
			}
		}
		hostInsert(el, container); // 插入到容器中
	};
	// 虚拟节点对比逻辑
	const patch = (n1, n2, container) => {
		if (n1 == n2) {
			return;
		}
		if (n1 == null) {
			// 初始化情况
			mountElement(n2, container);
		} else {
			// n1, n2不相等,diff算法逻辑
		}
	};
	const render = (vnode, container) => {
		// 虚拟节点渲染成真实DOM,挂载到页面上
		// 卸载操作 render(null, container)
		// 初始化和更新虚拟DOM
		if (vnode == null) {
			// 卸载逻辑
		} else {
			// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
			patch(container._vnode || null, vnode, container);
		}
		container._vnode = vnode;
	};
	return {
		render,
	};
}

我们可以清楚的看到,就是用了runtime-dom中的API,来递归生成真实的DOM元素,我们来验证一下,代码是否有问题吧:

html
<script type="module">
	import { h, render } from "./runtime-dom.js";
	render(h("h1", { style: { color: "red" } }, "hello"), app);
</script>

在浏览器中运行完代码,发现,hello已经成功被渲染到页面上了。那么初始化阶段的渲染的逻辑,便写完了!那么初始化逻辑写完后,我们再写一下卸载的逻辑,什么是卸载的逻辑呢?可以理解为render(null, app),也就是传入了null的时候,要把页面中元素清除掉,我们之前已经预留出来卸载逻辑的位置,那我们现在便可以来完善了:

javascript
// renderer.ts 文件
export function createRenderer(renderOptions) {
	// 从renderOptions中解构api,并重命名
	const {
		insert: hostInsert,
		remove: hostRemove,
		patchProp: hostPatchProp,
		createElement: hostCreateElement,
		createText: hostCreateText,
		setText: hostSetText,
		setElementText: hostSetElementText,
		parentNode: hostParentNode,
		nextSibling: hostNextSibling,
		querySelector: hostQuerySelector,
	} = renderOptions;
	const mountChildren = (children, container) => {
		for (let i = 0; i < children.length; i++) {
			// 递归调用patch方法
			patch(null, children[i], container);
		}
	};
	const mountElement = (vnode, container) => {
		// 将虚拟节点转化为真实DOM
		const { type, props, shapeFlag, children } = vnode;
		// 创建真实元素,挂载到虚拟节点上
		let el = (vnode.el = hostCreateElement(type));
		// 如果有props,则处理属性
		if (props) {
			for (const key in props) {
				hostPatchProp(el, key, null, props[key]);
			}
		}
		if (children) {
			if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
				// 说明是文本
				hostSetElementText(el, vnode.children);
			} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
				// 说明有多个儿子
				mountChildren(vnode.children, el);
			}
		}
		hostInsert(el, container); // 插入到容器中
	};
	// 虚拟节点对比逻辑
	const patch = (n1, n2, container) => {
		if (n1 == n2) {
			return;
		}
		if (n1 == null) {
			// 初始化情况
			mountElement(n2, container);
		} else {
			// n1, n2不相等,diff算法逻辑
		}
	};
	// 卸载元素的方法
	const unmount = (vnode) => {
		const { shapeFlag } = vnode;
		if (shapeFlag & ShapeFlags.ELEMENT) {
			// 如果是一个元素,那么直接删除DOM即可
			hostRemove(vnode.el);
		}
	};
	const render = (vnode, container) => {
		// 虚拟节点渲染成真实DOM,挂载到页面上
		// 卸载操作 render(null, container)
		// 初始化和更新虚拟DOM
		if (vnode == null) {
			// 卸载逻辑
			if (container._vnode) {
				// 找到对应的真实节点,将其卸载
				unmount(container._vnode);
			}
		} else {
			// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
			patch(container._vnode || null, vnode, container);
		}
		container._vnode = vnode;
	};
	return {
		render,
	};
}

到这里,就只剩下元素更新的逻辑了,那么元素更新的逻辑,涉及的内容又非常多,我们先讲一些关键性的点,从而为后续文章做好铺垫。我们用几个不同的例子,来表明什么时候触发更新,也就是说,怎么判断两个虚拟节点相同,可以复用:

html
<script type="module">
	import { h, render } from "./runtime-dom.js";
	// 1、可以看到,标签名不相同,所以就不能够进行DOM复用
	render(h("h1", { style: { color: "red" } }, "hello"), app);
	render(h("div", { style: { color: "blue" } }, "world"), app);
	// 2、那么如果标签名相同,又不想复用DOM,那么这时候就需要提供key,来进行区分了
	render(h("div", { style: { color: "blue" }, key: 1 }, "world"), app);
	render(h("div", { style: { color: "blue" }, key: 2 }, "world"), app);
	// 3、如果标签名相同,也没有key,那么就进行复用
	render(h("h1", { style: { color: "red" } }, "hello"), app);
	render(h("h1", { style: { color: "red" } }, "hello"), app);
</script>

所以我们可以得到结论,当两个虚拟节点的标签类型不同时候,或者两个虚拟节点标签类型相同,但是 key 不同,那么就不会进行复用;如果两个虚拟节点的标签类型相同,并且不传入 key,或者 key 相同,那么就进行复用。

所以,我们需要继续改进下更新下patch方法中的代码:

javascript
// renderer.ts 文件
export function createRenderer(renderOptions) {
	// 从renderOptions中解构api,并重命名
	const {
		insert: hostInsert,
		remove: hostRemove,
		patchProp: hostPatchProp,
		createElement: hostCreateElement,
		createText: hostCreateText,
		setText: hostSetText,
		setElementText: hostSetElementText,
		parentNode: hostParentNode,
		nextSibling: hostNextSibling,
		querySelector: hostQuerySelector,
	} = renderOptions;
	const mountChildren = (children, container) => {
		for (let i = 0; i < children.length; i++) {
			// 递归调用patch方法
			patch(null, children[i], container);
		}
	};
	const mountElement = (vnode, container) => {
		// 将虚拟节点转化为真实DOM
		const { type, props, shapeFlag, children } = vnode;
		// 创建真实元素,挂载到虚拟节点上
		let el = (vnode.el = hostCreateElement(type));
		// 如果有props,则处理属性
		if (props) {
			for (const key in props) {
				hostPatchProp(el, key, null, props[key]);
			}
		}
		if (children) {
			if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
				// 说明是文本
				hostSetElementText(el, vnode.children);
			} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
				// 说明有多个儿子
				mountChildren(vnode.children, el);
			}
		}
		hostInsert(el, container); // 插入到容器中
	};

	// 判断是不是相同的虚拟节点
	const isSameVNode = (n1, n2) => {
		return n1.type === n2.type && n1.key === n2.key;
	};
	// 处理元素
	const processElement = (n1, n2, container) => {
		if (n1 == null) {
			// 初始化情况
			mountElement(n2, container);
		} else {
			// 元素相同,属性更新了,可以进行复用,进行diff的逻辑
			console.log(n1, n2);
		}
	};
	// 虚拟节点对比逻辑
	const patch = (n1, n2, container) => {
		if (n1 == n2) {
			return;
		}
		if (n1 && !isSameVNode(n1, n2)) {
			unmount(n1);
			n1 = null;
		}
		// 处理元素
		processElement(n1, n2, container);
	};
	// 卸载元素的方法
	const unmount = (vnode) => {
		const { shapeFlag } = vnode;
		if (shapeFlag & ShapeFlags.ELEMENT) {
			// 如果是一个元素,那么直接删除DOM即可
			hostRemove(vnode.el);
		}
	};
	const render = (vnode, container) => {
		// 虚拟节点渲染成真实DOM,挂载到页面上
		// 卸载操作 render(null, container)
		// 初始化和更新虚拟DOM
		if (vnode == null) {
			// 卸载逻辑
			if (container._vnode) {
				// 找到对应的真实节点,将其卸载
				unmount(container._vnode);
			}
		} else {
			// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
			patch(container._vnode || null, vnode, container);
		}
		container._vnode = vnode;
	};
	return {
		render,
	};
}

我们又将当两个虚拟节点不相同时的更新逻辑写完了,我们改下调试代码,在页面上看效果,发现,过了 1 秒钟后,成功的渲染了新的虚拟节点:

html
<script type="module">
	import { h, render } from "./runtime-dom.js";
	render(h("h1", { style: { color: "red" } }, "hello"), app);
	setTimeout(() => {
		render(h("div", { style: { color: "blue" } }, "world"), app);
	}, 2000);
</script>

所以,当两个虚拟节点可以复用时的逻辑,我们就放到后续文章中,进行详细的讲解,因为会涉及到我们耳熟能详的diff算法。

其实watchwatchEffect并不在reactivity响应式模块里,而是在runtime-dom模块里,那为啥还要在reactivity这个响应式模块中,来介绍这两个API呢?一是因为这两个API我们在项目中太常见,二才是最主要的,是因为watchwatchEffect都是基于上篇文章说的effect进行了封装,从而得到的。所以说么,effect是最底层的方法,弄懂了上篇文章的内容,那么这篇文章就显得相对好理解很多。

文章

Vue3 源码解读开源:https://github.com/cuixiaorui/mini-vue

掘金文章说明:https://juejin.cn/post/6925668019884523534

这个库把 Vue3 源码中最核心的逻辑剥离出来,只留下核心逻辑,以供大家学习。带有详细的中文注释,以及完善的输出,帮助用户理解运行时流程。

视频脑图:https://github.com/lgd8981289/book_read_quickly

视频文档:https://juejin.cn/post/7197980894363156540

《Vue3 源码解析,打造自己的 Vue3 框架》:https://coding.imooc.com/class/608.html

DSL 编译器《Vue 编译器的核心逻辑》:https://juejin.cn/post/7197977396603256890

有点难的《最新 diff 算法详解》:https://juejin.cn/post/7190726242042118200

看呆面试官的《手写响应式模块》:https://juejin.cn/post/7189161043552108599

Vue3 响应式原理

Released under the MIT License.