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-typescript2 | rollup 和 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
- effect 可以接收第二个参数,通过传递的 lazy 值,来决定自己首次是否自动执行。
- 构建响应式 effect,利用栈结构维护当前 effct 函数,并在属性取值时,做依赖收集操作,依赖收集的构建的数据结构很有趣哦。
- 用户取值时,把当前 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
- 用户取值时,把当前 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());
}
总结
我们在这里完善了两种数组更新的策略。
- 如果更改的数组长度小于依赖收集的长度,要触发重新渲染 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
- effct 中 newAge.value 取值时,会触发 newAge 的 get 拦截器函数,我们执行了 this._value = this._effct() 对计算属性的值进行计算,该方法执行 effct 函数并返回用户的回调函数 fn (就是getter)结果,fn 的执行,会对 proxy.age 取值, computedEffct 作为 activeEffect 被 proxy.age 收集。
- 因为 newAge 不是一个响应式的数据,我们在 get 拦截器中手动去触发 track(this, 'get', 'value') 去收集此次对 newAge 取值的外层的 effct。
- 当 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);
}