# minVue **Repository Path**: DT-guyan/min-vue ## Basic Information - **Project Name**: minVue - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2024-12-23 - **Last Updated**: 2024-12-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README #

手写Vue3源码

## 项目依赖 ```json "esbuild": "^0.24.2", // 构建工具,代码书写完之后进行打包 "minimist": "^1.2.8", // 解析命令行参数 "typescript": "^5.7.2" // ts管理类型 ``` ## 第一章 搭建monorepo开发环境 ### 1.1 创建monorepo仓库 ```bash # 安装pnpm 使用pnpm管理仓库 npm install -g pnpm # 初始化仓库 pnpm init -y ``` ### 1.2 配置 **(1)修改package.json** ```json { "name": "vue3-lesson", "private": true, // 开启仓库私有 "version": "1.0.0", "description": "", "main": "index.js", "type": "module", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "dependencies": {}, "devDependencies": {} } ``` **(2)修改安装依赖** ```bash # 安装依赖 # typescript 使用ts管理类型 # 安装esbuild 构建工具 # 安装minimist 解析命令行参数 pnpm install -D typescript esbuild minimist ``` **(3)pnpm配置** 由于当前pnpm会默认以树状层级存放安装的依赖,因此我们需要将依赖包下的所有依赖配置为平铺,方便之后导入时的路径书写 在根目录下创建.npmrc文件,在文件中写入以下配置 ```bash shamefully-hoist=true ``` 我们还需要指定monorepo管理的包所存放的目录,因此我们在根目录下创建一个packages文件夹专门来存放每个包模块 我们还需配置仓库管理目录,在根目录下创建一个pnpm-workspace.yaml文件,写入以下配置: ```yaml packages: - "packages/*" - "packages/*/*" ``` 此后如果需要安装项目全局依赖,我们需要加一个参数,即 -w **(4)typescript配置** 在根目录下创建一个tsconfig.json文件(可以使用命令tsc -init生成),写入以下配置: ```json { "compilerOptions": { "target": "es2016", // 使用ES 2016语法规范 "lib": ["ESNext", "DOM"], // 支持的类库 "jsx": "preserve", // jsx不转义 "module": "ESNext", // 模块格式 "moduleResolution": "node10", // 模块的解析方式 "resolveJsonModule": true, // 解析json模块 "sourceMap": true, // 采用sourcemap "outDir": "dist", // 打包目录 "esModuleInterop": true, // 允许使用es6语法引入commonjs模块 "strict": false // 严格模式 } } ``` ### 1.3 创建打包脚本 **(1).创建目录结构** - 在根目录下创建一个script文件夹,用于存放打包的脚本文件 - 在其中创建一个dev.js文件,作为打包入口文件 - 在packages下创建一个reactivity文件夹 其结构如下: ```bash packages ├── reactivity │ ├── src │ │ └── index.ts │ └── package.json ``` - 在根目录的package.json中添加打包命令 ```json "scripts": { "dev": "node script/dev.js reactivity -f esm" }, ``` - 在reactivity中创建src文件夹,并在其中创建index.ts文件,作为入口文件 - 在reactivity中创建package.json文件,写入以下配置: ```json { "name": "@vue/reactivity", "version": "1.0.0", "description": "Reactivity for Vue", "module": "dist/reactivity.esm-bundler.js", "unpkg": "dist/reactivity.global.js", "buildOptions": { "name": "VueReactivity", "formats": ["es-bundler", "es-browser", "cjs", "global"] }, "dependencies": {} } ``` **(2).编写打包脚本** 在script/dev.js中写入以下代码: ```js // 这里使用了es6的模块化导入,需要在package.json中加入一个字段 "type": "module",否则会报错 import minimist from "minimist"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import esbuild from "esbuild"; // 解析当前文件路径 const __filename = fileURLToPath(import.meta.url); // 获取当前文件路径(这里由于es模块无法像cjs模块一样直接拿到__dirname,所以需要手动解析) const __dirname = dirname(__filename); // 解析当前文件路径 // 获取模块导入方法require const require = createRequire(import.meta.url); // 创建require // 解析参数(即在package.json 中配置的那条命令所打包的文件的参数) // node中的命令按参数可以通过process获取 const argv = process.argv.slice(2); // process.argv // [ // 'D:\\Data\\Node\\node.exe', // 'D:\\360Downloads\\miniVue\\script\\dev.js', // 'reactivity', // '-f', // 'esm' // ] // 解析参数 const args = minimist(argv); // 提取需要打包的模块名 const target = args._[0] || "reactivity"; // 打包那个项目 // 提取打包格式 const formate = args.f || "iife"; // 打包格式 // 提取包名 这个主要是在打包为iife(自执行函数)格式时,需要为iife函数命名 const pkg = require(`../packages/${target}/package.json`); // 解析,模块文件入口 const entry = resolve(__dirname, `../packages/${target}/src/index.ts`); // 根据需要开始打包 esbuild .context({ entryPoints: [entry], // 入口文件 outfile: resolve(__dirname, `../packages/${target}/dist/${target}.js`), // 打包出口 platform: "browser", // 打包环境(打包完的代码运行平台) sourcemap: true, // 可以调试源代码 format: formate, // 打包格式 /** * cjs: 打包成commonjs格式 * esm: 打包成es模块 * iife: 打包成自执行函数(打包位自执行函数时需要为该函数变量命名) */ globalName: pkg.buildOptions?.name, // 打包名称 }) .then((ctx) => { console.log(`${target}打包完成`); return ctx.watch(); // 监听文件变化 }); ``` **(3).打包依赖文件** 在packages下依照reactivity的结构在创建一个shared模块,在index.ts写出部分代码,并导出。在reactivity中导入并使用。可以直接在reactivity中使用pnpm 安装 这里就需要去tsconfig.json中添加以下配置 ```json "baseUrl": "./", "paths": { "@vue/*": ["packages/*/src"] }, ``` ```bash # 安装 pnpm install @vue/shared ``` 在dev.js中添加打包配置 ```js esbuild.context({ ... bundle: true, // 将所有主文件与依赖文件打包为单个文件 }) ``` 这样一来无论引入多少个模块,打包完成之后就只有一个js文件 至此,整个打包的环境就配置完成了。 ## 第二章 reactivity模块 ### 2.1 创建reactive **(1).创建文件** 在reactivity文件夹中创建reactive.ts文件,作为reactive的入口 在index.ts中将其统一导出 **(2).实现reactive** 在reactive.ts中导出一个函数reactive,该函数接收一个对象作为参数,返回一个proxy代理对象 具体待码如下: ```js export function reactive(target) { return createReactiveObject(target); // 实现响应式函数 } ``` **(3).实现createReactiveObject** 1. 判断参数是否为对象,如果不是则直接返回 ```js // 1.判断传入的值是否是对象,如果是对象正经行代理,如果不是直接返回 // isObject函数时当时在shared创建的一个判断是否是对象的函数,当时用于多个依赖包之间共享打包,在这里直接可以用于判断是否是对象 if (!isObject(target)) { return target; } ``` 2. 创建一个代理对象并返回 ```js // 这里的代理对象的操作配置属性对象我们使用一个变量来存储 // 代理操作 const mutableHandlers: ProxyHandler = { get(target,key,recevier) { }, set(target,key,value,recevier) { return true } } let proxy = new Proxy(target,mutableHandlers) return proxy; ``` 这样一个基本的响应式变量就完成了。但是我们发现当两次同时传入同一个对象进行代理时,我们完全没有必要每次都在重新创建一个proxy,所以我们维护一个WeakMap,来存放每一个对象对应的proxy。 当两次需要代理的对象相同时,我们直接从WeakMap中获取proxy即可。3. 实现WeakMap缓存 ```js // 记录已经代理过的对象,避免重复代理 // 这里为了防止循环引用,所以用弱引用 const reactiveMap = new WeakMap(); // 判断当前是否已经代理过,如果已经代理过直接返回 if (reactiveMap.has(target)) { return reactiveMap.get(target); } ``` 在这里我们发现,当一个对象被代理之后,又重新将这个已经是响应式对象的对象再次传入,我们就不需要对其做任何操作,直接返回即可。4. 判断target是否已经是响应式对象 这里用到了一个很巧妙的操作,我们在每次代理对象之前,从对象上读取一个标记属性,然后在每个代理对象的get方法中,专门判断这个标记属性 如果get方法判断到了这个属性,就说明这个对象在之前已经代理过,因此我们设置的get方法才能生效,否则就说明这个对象还没有被代理过,我们就可以进行代理操作。 ```js enum ReactiveFlags { IS_REACTIVE = '__v_isReactive', // 已经代理过 } // 判断当前传入的值是否是一个响应式对象,如果是则直接返回 // 这里的判断方式是通过获取响应式对象的属性,然后再get方法中特定的返回,如果有返回,则代表其已经被代理了get方法 if(target[ReactiveFlags.IS_REACTIVE]) { return target } // proxy get方法 get(target,key,recevier) { // 判断当前是否已经是响应式对象 if(key === ReactiveFlags.IS_REACTIVE) { return true } }, ``` **(4).实现get set方法中的返回值** - 这里我们现将上面所写的枚举ReactiveFlags,对象mutableHandlers移入一个新文件中,命名为baseHandler.ts 在将其导出,在reactive.ts中导入,实现分模块 - 这里我们需要在get和set方法中返回读取的数据(主要是在get方法中),但是我们却不能直接使用target[key]来读取数据,这主要涉及到一个问题,我们来看下面一个例子: ```js const user = { name: "zhangsan", get desc() { return "我叫" + this.name } } // 这里我们读取user.desc时,就会间接的读取this.name let proxy = new Proxy(user,{ get(target,key,recevier) { return target[key] }, set(target,key,value) {} }) ``` 我们来看上面的这个代码 在读取proxy.desc时,就会触发get方法,而desc间接依赖于name,在desc的逻辑当中,此时还是会读取this.name,而这个this还是user 这样一来就会出现一个问题,既然desc依赖于name,那么读取desc时就必须要读取name,这样才能将desc收集到name的effect,从而在未来name发生变化时,desc也会跟着变化 因此这样的方法可能会导致某些依赖我们无法收集到。那么使用recevier[key]行不行呢?我们知道recevier就是代理后的proxy对象,我们直接从代理后的对象上读取属性不是就可以触发name的get方法从而收集到依赖了 但是这里也有一个致命的问题,就是当我们在get内部去再去读取proxy的属性是,就会再次触发get方法,这样就会导致get一直被循环触发,最终导致请程序崩溃。 为了解决这一个问题,我们需要引入另一个API,Reflect :这个api就是专门用来解决循环读取属性的,它不会触发get方法,而是直接从proxy对象中读取属性。 ```js get(target,key,recevier) { // 判断当前是否已经是响应式对象 if(key === ReactiveFlags.IS_REACTIVE) { return true } // 取值时,需要将响应式属性与effect进行绑定 return Reflect.get(target,key,recevier) }, set(target,key,value,recevier) { // 找到属性,然对应的effect触发执行 return Reflect.set(target,key,value,recevier) }, ``` 这个api不会循环触发get,set方法而是直接读取设置属性,这样就可以解决循环读取的问题。 ### 2.2 创建effect **(1).创建effect** 在reactivity文件夹在创建一个effect.ts文件,作为effect的入口。文件中导出一个effect函数,并通过index.ts统一导出。 - 这个函数接受两个参数,第一个参数是一个回调函数,即是用户使用响应式对象时所写的逻辑代码,第二个参数是一个配置对象,后续会详细讲解。 - 在effect中,我们需要返回一个响应式的effect对象,作为墨盒响应式数据的某个属性依赖,因此我们创建一个ReactiveEffect类 代码如下: ```js export function effect(fn, options?) { 1.// 创建一个响应式effect,数据变化后可以重新执行 const _effect = new ReactiveEffect(fn,() => { _effect.run() }) _effect.run() // 默认执行一次 return _effect } class ReactiveEffect { public active = true; // 默认创建的就是响应式effect /** * * @param fn 用户传入的函数 * @param scheduler 如果数据变化后再次执行的函数 -> 调用实例的run方法 */ constructor(public fn,public scheduler) { } run () { // 如果不是响应式effect,则执行fn并返回,不做特殊处理 if (!this.active) { return this.fn() } // 收集依赖操作 return this.fn() } } ``` 这个类有一个run方法,这个方法就是用于调用传入的函数,并且当这个函数中所用到的响应数数据变化后,再次调用这个effect的run方法从而达到数响应 并且这个类上需要有一个状态来标注其是不是响应式effect,如果不是就无需后续任何操作。 **(2).收集依赖** (1 )在effect中,我们需要收集依赖,也就是收集effect所依赖的响应式数据。因此我们下现在定义一个全局变量currentEffect并导出,当run函数调用时,就讲本次的effect赋给这个变量,然后再执行fn函数。 ```js export let currentEffect = null; run () { // 如果不是响应式effect,则执行fn并返回,不做特殊处理 if (!this.active) { return this.fn() } currentEffect = this // 收集依赖操作 return this.fn() } ``` 如上这样,当fn函数执行时,currentEffect已经将当前的effect保存下来了,然后再fn执行时,由于使用到了响应式对象的某个属性,因此一定会触发响应式对象的ge方法我们只需要在get方法所在文件中导入currentEffect,然后将其作为依赖保存起来待到数据变化时,再重新执行其run方法即可。 由于代码量大,我们再建一个文件reactiveEffect.ts ,里面导出一个函数track,我们在reactive的get方法中使用这个函数并把相应的target,key传入。在这个文件中直接导入currentEffect ```js # reactiveEffect.ts import { currentEffect } from './effect' export function track(target, key) { // 判断当前effect是否有值,如果有值,则说明需要收集为当前target的key的依赖 if (currentEffect) { // 收集依赖 } } # baseHandler.ts import {track } from './reactiveEffect' ... // 代理操作 export const mutableHandlers: ProxyHandler = { get(target,key,recevier) { ... // 取值时,需要将响应式属性与effect进行绑定 - 依赖收集 track(target,key) // 收集依赖 ... }, set(target,key,value,recevier) { ... }, } ``` 注意,上面的run函数中还存在一个极其严重的问题,当我们有两个以上的effect嵌套书写时,就会出现问题。 ```js effect(() => { document.body.innerHTML = user.name; effect(() => { user.age++; }); user.age++; }); ``` 首先,我们必须要在run函数执行完之后要把currentEffect置为null,这是为了本次run方法结束之后这个变量还能被其他位置访问到,造成了这个变量泄露,有可能由于其他位置的代码访问到这个变量从而造成某些不必要的错误。 其次,我们发现上面代码中,当执行完第一个effect的run方法后,有创建了第二个effect,这两次的依赖收集是没有问题的,但是当第二个effect结束之后,我们有访问了user.age,并且本次访问是在第一个effect中完成的,但是由于第二次effect执行的完毕,currentEffect已经为null了,因此这里的currentEffect就无效了。 1. 使用调用栈解决问题 我们要解决这个问题,最简单的办法就是在每次执行run方法时将本次的effect保存到一个调用栈中,当进入第二个effect时就会在保存一个effect到栈内,当第二个effect执行完毕之后就从栈顶弹出一个,并且在每次为currentEffect赋值时,都从栈顶取出元素,这样就解决了这嵌套问题。 2. 使用缓存变量解决问题 确实,在前几个版本的Vue3源码中也确实是这样做的,但这样就需要维护一个调用栈,在数据结构上无法达到最优。后来就有人提出了一种方法,类似于动态规划来维护一个缓存变量lastEffect,每次执行run时都将currectEffect赋值给lastEffect,当本次run方法技术时再将lastEffect赋值给currentEffect。这样就相当于保存了上一个的effect,待到下一个effect结束,再将lastEffect赋值给currentEffect。 ```js run () { // 如果不是响应式effect,则执行fn并返回,不做特殊处理 if (!this.active) { return this.fn() } // 保存上一次使用后effect let lastEffect = currentEffect // 主要解决嵌套effect问题 // 保存当前effect try { currentEffect = this return this.fn() } finally { currentEffect = lastEffect } } ``` (2)接着我们就需要编写依赖收集的方法track了,首先我们需要在全局定一个手机对象映射的map,然后我们判断当前target是否在map中存储过,如果存过,就再次判断是否存过当前地区的key。如果未存过,就将当前的target存入map,值设为一个空的map映射。 然后如果判断到当前key属性也不存在,则需要创建一个map映射表,并将key与map添加到对应的target属性上。同时我们为了后续方便清理,我们为每一个key的每一值都添加一个clear方法,用于清除依赖,同时为其挂载一个name属性用于指定map名称便于分辨。 ```js // 判断当前effect是否有值,如果有值,则说明需要收集为当前target的key的依赖 if (currentEffect) { // 收集依赖 // 1.判断map中是否存在target,不存在则创建 let depsMap = targetMap.get(target) if (!depsMap) { targetMap.set(target, (depsMap = new Map())) } // 2.判断是否存在当前属性的依赖集合 let deps = depsMap.get(key) if (!deps) { deps = createDep(() => {depsMap.delete(key)},key) depsMap.set(key, deps) } // 保存依赖 trackEffect(currentEffect,deps) } // 创建一个依赖表并挂载一个清除函数 export function createDep(callback, key) { const dep = new Map() as any dep.clear = callback dep.name = key return dep } ``` (3) 接着我们实现trackEffect函数,这个函数用于收集依赖,传入两个参数,第一个参数是当前读取属性的effect,第二个参数是当前读取属性的依赖表 我们只需要将effect添加到当前依赖表中,并且这里我们需要将effect作为key存下来,而value我们需要保存一个ReactiveEffect类的一个属性\_trackId,这个属性代表当前effect被调用了多少次。 然后我们需要将当前的deps依赖表也关联到当前的effect上,实现双向关联。在ReactiveEffect类上定义一个属性deps = [] 用于记录当前effect所依赖的deps表,\_depsLength 用于记录当前effect所依赖的deps表数量。 ```js // 添加依赖 export function trackEffect(effect,deps) { deps.set(effect,effect._trackId) // 添加到依赖表 effect.deps[effect._depsLength++] = deps // 收集依赖表 } class ReactiveEffect { public active = true; // 默认创建的就是响应式effect _trackId = 0; // 用于记录当前effect的执行数量 deps = [] // effect所关联的依赖表 _depsLength = 0; // deps的长度 ... } ``` 至此依赖添加基本完成 **(3).触发更新** 触发更新发生在当前响应式对象的set方法调用后,同时需要就当前触发的对象某个属性的就旧值与新值,并做比较,如果两者不同,再进行触发更新。并且在触发更新之前必须要先将新值设置上去。 ```js # baseHandler.ts set(target,key,value,recevier) { // 找到属性,然对应的effect触发执行 -触发更新 let oldValue = target[key] // 记录旧值 const result = Reflect.set(target,key,value,recevier) // 判断两个值是否相同 if(oldValue !== value) { // 触发更新 trigger(target,key,value,oldValue) } return result }, ``` 这里和依赖收集一样我们使用一个外部的函数来执行触发更新逻辑。 trigger实现: 这里主要就是将当前对象target,key,newValue,oldValue都传入到trigger函数中(传入新旧值的原因是后期watch中通常会挂载新旧值) 首先对象映射表中取出当前对象的依赖表,判断当前是否存在,如果不存在这表明这个对象中的这个属性并未被外界使用effect收集过,因此不需要更新。 如果存在就在从这个依赖表中取出当前key的依赖表,然后判断这个依赖表是否存在,如果不存在说明这个属性没有被使用过,因此也不需要更新。 然后就讲这个依赖表中的所有effect都执行一遍。 ```js # reactiveEffect.ts export function trigger(target,key,newValue,oldValue) { const depsMap = targetMap.get(target) if (!depsMap) return; // 当前所设置的值并未被收集,说明这个值并没有被使用,无需更新 // 获取依赖集合 const deps = depsMap.get(key); if(deps) { // 触发所有的依赖更新 triggerEffect(deps) } } // 触发依赖 export function triggerEffect(deps) { for(const effect of deps.keys()) { if(effect.scheduler) { effect.scheduler() } } } ``` 这次简单的依赖收集和触发更新就完成了。 **(4).依赖清理** 我们先来看存在的三个问题 1. 当一个effect中重复访问同一个属性时,在依赖表中就会重复保存多个相同的effect,浪费性能。 2. 当在effect中右判断,比如说通过响应式对象上的某个属性值来判断接下来访问哪个属性,这样一来当这个判断值变化之后,第一次effect中访问的属性与第二次的属性全然不同,因此又会在依赖表中收集第二次新访问的属性,如果这样的操作过多就会导致依赖表中存在过多无用的依赖。 3. 当effect第一次访问了过多依赖,而第二次通过判断所访问的属性数量下降,在effect.dep中所保存在依赖就会有一部分变为无用依赖,因此我们也需要将其清除。 ```js // 1 effect(() => { app.innerHTML = user.name + user.name + user.name; // 总共收集了三个完全相同的依赖 }); // 2 effect(() => { app.innerHTML = user.state ? user.name : user.age; // 访问哪个属性取决于user.state,其变化前effect中{state,name},变化后{state,age} 因此我们需要将第一次的name删除才能达到预期 }); // 3 effect(() => { if (user.state) { app.innerHTML = user.name + user.age; } else { app.innerHTML = user.name; } // 如2一样的操作,第一次收集了三个依赖,但值变化之后就只需要收集两个依赖,因此我们需要将多余的删除 }); ``` _解决思路:_ 1. 首先我们在每次依赖执行前将effect.\_trackId 数值增加 ——> 这个属性就代表这个effect被调用了多少次,然后将effect.\_desLength置为0——>这个是为了后续从头对比依赖表,从而覆盖没用的依赖 ```js # effect.ts try { currentEffect = this // 这里为了避免无用的effect依赖,每次触发收集之前将原先的依赖表清空 preClearEffect(this) return this.fn() } finally { currentEffect = lastEffect } function preClearEffect(effect) { effect._depsLength = 0 // 清除依赖表function postDepEffect(effect) { // 判断effect以前是否有在用不到的依赖 if(effect.deps.length > effect._depsLength) { // 循环删除从effect._depsLength起到最终的所有不需要的依赖 for(let i = effect._depsLength;i < effect.deps.length;i++) { clearDepEffect(effect.deps[i],effect) } // 将依赖表截断,只保留有用的依赖 effect.deps.length = effect._depsLength } } effect._trackId++ // 重新计算trackId ``` 2. 接着我们需要在依赖收集的位置判断当前以来的effect.\_trackId 与在deps中取出的值是否相同,如果相同则代表本个依赖已经存入依赖表中了,并且当前只被调用了一次。如果不同则代表执行以来后,本次属性的读取在依赖表中找不到到与其相对应的依赖,则代表这个依赖需要存入依赖表中。然后让我们现将上一次effect.deps对应位置的dep依赖表取出来,将整个依赖于本次依赖做对比,如果相同则代表本次依赖和上次添加的对应位置的依赖相同,无需清除,如果不同则需要清除上一次的依赖,保存本次的依赖。 ```js # effect.ts // 添加依赖 export function trackEffect(effect,deps) { // 判断当前是否已经收集了这个依赖 if(deps.get(effect) !== effect._trackId) { deps.set(effect,effect._trackId) // 添加到依赖表 } // 判断上一次依赖与这次的依赖对比 let oldDep = effect.deps[effect._depsLength] if(oldDep !== deps) { if(oldDep) { // 需要删除上一次的依赖 clearDepEffect(oldDep,effect) } effect.deps[effect._depsLength++] = deps // 更新依赖表 } } function clearDepEffect(dep,effect) { dep.delete(effect) // 判断当前依赖表还是否有依赖,如果没有则删除 if(dep.size === 0) { dep.clear() } } ``` 3. 在当前依赖执行完之后,需要清除掉多余的依赖。 ```js try { currentEffect = this; // 这里为了避免无用的effect依赖,每次触发收集之前将原先的依赖表清空 preClearEffect(this); return this.fn(); } finally { // 删除以前依赖表中多余的依赖 postDepEffect(this); currentEffect = lastEffect; } function postDepEffect(effect) { // 判断effect以前是否有在用不到的依赖 if (effect.deps.length > effect._depsLength) { // 循环删除从effect._depsLength起到最终的所有不需要的依赖 for (let i = effect._depsLength; i < effect.deps.length; i++) { clearDepEffect(effect.deps[i], effect); } // 将依赖表截断,只保留有用的依赖 effect.deps.length = effect._depsLength; } } ``` ### 2.3 细节处理 **(1).手动调用effect** 如果我们在数据更新之后第一时间不想去更新视图,我们在官方的vue中就可以通过effect中的一个配置来实现,即effect.scheduler。因此我们也需要实现这个功能。 1. 首先我们在effect函数调用时传入第二个参数作为一个配置对象,并将这个配置对象合并到\_effect中。 ```js export function effect(fn, options?) { 1.// 创建一个响应式effect,数据变化后可以重新执行 const _effect = new ReactiveEffect(fn,() => { _effect.run() }) _effect.run() // 默认执行一次 // 为effect挂载options if(options) { Object.assign(_effect,options) // 覆盖掉之前的 } } ``` 由于我们前天在依赖调用是调用的是effect的scheduler,因此这个操作就会覆盖掉我们设置的scheduler。从而执行用户自定义的逻辑。2. 接下来我们需要将effect的run方法作为值暴露出去为用时使用,当用户根据自身情况调用,同时在run函数上挂载当前的effect实例。 ```js # effect.ts function effect(fn, options?) { ... // 将effect的run方法暴露给外部 const runner = _effect.run.bind(_effect) runner.effect = _effect return runner } ``` **(2).嵌套调用** 我们先来看这样一段代码 ```js const user = reactive({ name: "张三", age: 18, state: { title: 1, }, }); effect(() => { document.body.innerHTML = user.name; user.name = Math.random(); }); ``` 如上所示的代码,当effect执行之后在函数内部即读取了属性,又设置了属性值,此时就会连续触发effect的收集与执行,因此我们需要在依赖执行前判断当前依赖是否是正在执行,如果正在执行,则无需再触发。 我们先为ReactiveEffect设置一个属性咱们用于保存当前正在运行的effect数量, \_running ```js # effect.ts // 每次依赖执行前_running + 1,执行完后 - 1 try { currentEffect = this // 这里为了避免无用的effect依赖,每次触发收集之前将原先的依赖表清空 preClearEffect(this) this._running++ // 记录当前正在运行的effect数量 return this.fn() } finally { // 删除以前依赖表中多余的依赖 postDepEffect(this) this._running-- // 记录当前正在运行的effect数量 currentEffect = lastEffect } // 再出发依赖的位置判断是否需要触发 // 触发依赖 export function triggerEffect(deps) { for(const effect of deps.keys()) { if(effect.scheduler) { // 判断当前是否已经有正在执行的effect if(effect._running === 0) { effect.scheduler() // 默认相当于调用了effect.run() } } } } ``` **(3).深度代理** 当前reactive的实现中,如果数据是对象类型,则只对第一层进行代理,如果对象内部还有对象,则不会进行代理。 但是如果是对象嵌套对象形式,根据源码的逻辑,需要在读取到该对象的子对象是才对其子对象进行代理。因此我们需要在读取属性时判读所读取的属性是否是对象,如果是则进行代理。 ```js # baseHandler.ts export const mutableHandlers: ProxyHandler = { get(target,key,recevier) { // 判断当前是否已经是响应式对象 if(key === ReactiveFlags.IS_REACTIVE) { return true } // 取值时,需要将响应式属性与effect进行绑定 - 依赖收集 track(target,key) // 收集依赖 const result = Reflect.get(target,key,recevier) // 深度代理 if(isObject(result)) { return reactive(result) } return result }, set: ... } ``` ### 2.4 ref实现 **(1).ref基本实现** ref函数接收一个一个值,如果这个值是基础数据类型,则直接返回一个RefImpl对象,并对这个对象设置属性访问器和属性修改器,在其中进行对应的依赖收集和触发更新 如果接受一个复杂类型的数据,则会将数据包装成一个RefImpl对象,并将其value值通过reactive进行代理,最后设置给RefImpl对象。 1. 创建ref.ts 文件,在文件中导出一个函数ref,并在index。ts中统一导出 ```js # ref.ts export function ref(value) { return createRef(value) // 创建一个Ref对象并返回 } ``` 2. 实现createRef函数 ```js function createRef(value) { return new RefImpl(value) } class RefImpl { __v_isRef = true // 标识当前变量是否是ref对象 _value // 存储ref对象的值 dep = [] // 用于收集依赖 constructor(public rawValue) { // 判断是否是对象,如果是对象则递归ref this._value = toReactive(rawValue) // 将对对象代理,将普通值原封不动返回 } get value() { return this._value // 返回ref对象的值 } set value(newValue) { if(newValue !== this._value) { this._value = newValue // 更新ref对象的值 this.rawValue = newValue // 更新ref对象的原始值 } } } # reactive.ts export function toReactive(value) { return isObject(value) ? reactive(value) : value } ``` 3. 收集依赖并触发更新 ```js # ref.ts import { currentEffect, trackEffect, triggerEffect } from "./effect" import { toReactive } from "./reactive" import { createDep } from "./reactiveEffect" class RefImpl { ... get value() { ... trackrRefValue(this) // 收集依赖 ... } set value(newValue) { ... triggerRefValue(this) // 触发依赖 } } function trackrRefValue(ref) { // 判断当前需要收集的依赖是否存在 if(currentEffect) { // 收集依赖,并创建一个以当前ref为key的依赖表 trackEffect(currentEffect,ref.dep = createDep(() => ref.dep = undefined,"undefined")) } } function triggerRefValue(ref) { // 触发更新 let dep = ref.dep // 提取当前ref的依赖表 if(dep) { triggerEffect(dep) // 触发依赖表 } } ``` **(2).toRef基本实现** 在我们书写代码时,有可能会遇到下面这种状况 ```js const user = reactive({ name: "zhangsan", age: 18, }); const { name, age } = user; ``` 如上,我们对一个reactive响应式对象进行了结构,这个操作在本质上会破坏proxy对象的代理,让响应式对象失去响应式特性,因此就有了toRef函数,专门用于将reactive对象中的某个属性结构出来并转换成ref对象。 该函数需要接受两个参数,第一个参数是reactive对象,第二个参数是reactive对象的属性key。返回一个ref对象,该对象的value属性就是reactive对象中key对应的值。并且支持响应式 1. 创建toRef函数 ```js # ref.ts export function toRef(reactive,key) { return new ObjectRefImpl(reactive,key) } ``` 2. 实现ObjectRefImpl类 ```js class ObjectRefImpl { __v_isRef = true // 标识当前变量是否是ref对象 constructor(public _object, public _key) { } get value() { return this._object[this._key] // 返回对象的值 } set value(newValue) { this._object[this._key] = newValue // 更新对象的值 } } ``` **(3).toRefs基本实现** toRefs函数用于将reactive对象中的所有属性都转换成ref对象,并且返回一个包含所有ref对象的proxy对象。 1. 创建toRefs函数 ```js export function toRefs(reactive) { const res = {}; for (const key in reactive) { res[key] = toRef(reactive, key); } return res; } ``` **(4).proxyRefs基本实现** 这个api主要用于在模板渲染时将所有的ref对象都代理成Proxy,在每次读取ref对象的属性值时,直接将其的value属性返回,这样在每次使用时就不需要再.value调用了。 1. 创建proxyRefs函数 ```js export function proxyRefs(objectWithRefs) { return new Proxy(objectWithRefs, { get(target, key, receiver) { let r = Reflect.get(target, key, receiver); return r.__v_isRef ? r.value : r; // 自动将value去掉包裹 }, set(target, key, value, receiver) { const oldValue = target[key]; // 如果oldValue是ref对象,则更新ref对象的值 if (oldValue && oldValue.__v_isRef) { oldValue.value = value; // 更新ref对象的值 return true; } else { // 如果oldValue不是ref对象,则更新对象的值 return Reflect.set(target, key, value, receiver); // 更新对象 } }, }); } ``` ### 2.5 computed实现 vue中,计算属性api主要是通过其所依赖的响应式数据来动态计算所需要的值。在Vue3中,它的本质上其实是一个函数,该函数返回一个ref对象,其value属性就是计算结果。 同时也可以传入一个配置对象,配置对象中包含一个get函数与一个set函数,分别用于计算与更新。 计算属性最重要的一点是他有缓存机制,就是当第一次计算出结果之后,如果所依赖的数据没有变化,在下一次使用的计算属性时就不会在重新计算了,而是从缓存中读取数据直接返回。 这个缓存的重点是在其所依赖的effect上设一个脏值dirty,当effect的run函数执行时,将dirty设为非,在下一次读取计算属性的值是需要判断其所依赖effect的dirty值,如果为true则重新计算,否则直接从缓存中读取。 **(1).为effect对象设置dirty属性** 默认为每个effect设置脏值属性为Dirty,并设置dirty属性访问器与设置器,便于后续访问与设置 ```js # constants.ts export enum DirtyLevels { Dirty = 4, // 脏值,意味着取值时需要重新计算 NoDirty = 0// 未脏,意味着取值时不需要重新计算 } # effect.ts export class ReactiveEffect { ... _dirtyLevel = DirtyLevels.Dirty; // 当前effect的脏等级 ... // 脏值属性访问器 public get dirty() { return this._dirtyLevel === DirtyLevels.Dirty; } // 脏值属性设置器 public set dirty(value) { this._dirtyLevel = value ? DirtyLevels.Dirty : DirtyLevels.NoDirty } run () { // 调用时设置脏值为NoDirty this._dirtyLevel = DirtyLevels.NoDirty .... } } ``` **(2).computed函数实现** computed函数传入一个参数,一般为一个配置对象,也可以是一个函数,如果是函数则将函数作为配置对的get函数 返回一个ref对象 ```js # computed.ts import { isFunction } from "@vue/shared"; export function computed(getterOrOptions) { let getter = null; let setter = null; // 判断传入的配置对象是否是一个函数 if(isFunction(getterOrOptions)) { getter = getterOrOptions; setter = () => {} } else { getter = getterOrOptions.get; setter = getterOrOptions.set; } // 创建ref对象 return new ComputedRefImpl(getter,setter) } ``` **(3).实现ComputedRefImpl类** 1. ComputedRefImpl和普通的ref一样需要设置属性访问器与设置器。同时在创建对象的时候需要将getter作为effect的run函数,创建一个effect对象,用于后续计算值 2. 本质上ComputedRefImpl最重要的就是属性访问器与effect依赖 3. 当访问属性时,通过调用effect.run来获取到结果并返回 4. 在get属性访问器中,优先判断efect是否是脏值,一但脏值就说明efferct.run函数还未调用,因此调用函数获取到结果并保存到\_value中,再一次读取时就直接会从\_value中读取,不需要再次调用effect.run函数 ```js class ComputedRefImpl { public _value; public effect; public dep; constructor(getter,public setter) { // 创建依赖 this.effect = new ReactiveEffect(() => getter(this._value), () => { // TODO: 数据更新后出发依赖 }) } get value() { if(this.effect.dirty) { this._value = this.effect.run(); // TODO : 如果当前在effect中访问了计算属性,需要将使用的effect收集为依赖 } return this._value } set value(newValue) { // 赋值 this.setter(newValue); } } ``` **(4).收集并触发依赖** 1. 当第一次计算结果时,我们需要将当前的effect对象收集为当前ref对象的依赖,本质上就是将其添加到ref.dep中,我们前面在开发ref模块时书写了一个将当前依赖添加到ref对象上的函数trackrRefValue,直接调用函数即可 2. 当计算属性依赖的数据更新后,就会触发this.\_effect的构造函数的第二个参数,因此我们需要在该函数中来进行触发更新,前面也写过一个函数,用户触发ref对象上的所有依赖triggerRefValue 3. 现在当依赖数据变化,就会通过triggerRefValue 触发ComputedRefImpl对象的所有依赖执行一遍,就做到了数据随着依赖值得变化而变化。 ```js class ComputedRefImpl { ... constructor(getter,public setter) { // 创建依赖 this.effect = new ReactiveEffect(() => getter(this._value), () => { triggerRefValue(this) }) } get value() { if(this.effect.dirty) { this._value = this.effect.run(); // 如果当前在effect中访问了计算属性,需要将使用的effect收集为依赖 trackrRefValue(this) } ... } set value(newValue) { ... } } ``` **(5).触发依赖重新计算** 现在还存在的一个问题就是虽然计算属性可以跟随依赖数据的变化而重新执行依赖,但由于我们未改变effect的脏值状态,当前的结果并未得到更新,因此我们需要在触发依赖的过程中将本ref上的所有effect的脏值全部设为Dirty,这样在接下来的依赖调用时就会重新计算结果 ```js // 触发依赖 export function triggerEffect(deps) { for (const effect of deps.keys()) { effect._dirtyLevel = DirtyLevels.Dirty; // 标记为脏值 if (effect.scheduler) { // 判断当前是否已经有正在执行的effect if (effect._running === 0) { effect.scheduler(); // 默认相当于调用了effect.run() } } } } ``` 至此,计算属性模块基本完成,还存在的问题就是计算属性在被主动赋值是需要打印出来一个警告,目前没有实现 ### 2.6 watch 实现 **(1).创建watch函数** 在Vue3中,watch是一个函数,这个函数接收三个参数,前两个是必须的参数,第三个是一个配置参数。 1. 第一个参数是需要监听的响应式数据,对于复杂类型的数据可以直接传变量本身或者传入一个函数,该函数返回需要监听的数据,如果是简单类型数据,则必须传入一个函数然后函数返回该数据。 2. 第二个参数是一个回调函数,该回调函数会在监听数据发生变化后执行,同时该函数会被注入两个参数,第一个参数为变化后的新值,第二个参数为变化前的旧值。 3. 第三个参数是一个配置对象,可以传入一个deep属性,如果为true,则监听的数据变化后,会递归遍历数据,将所有数据都监听一遍。 ```js export function watch(source,callback,options = {} as any) { return doWatch(source,callback,options) } ``` **(2).doWatch函数实现** 设置doWatch目的是为了与watch相似的API ,watchEffect准备,便后后续代码书写 1. 第一步,判断传入的source是否为函数,如果是函数则调用该函数获取到响应式数据并重新赋值给source,如果不是,则将该函数直接作为getter用于后续创建effect 2. 判断当前source是否还是一个对象,如果是对象则说明第一步中传入的函数返回的是一个对象,监听整个对象,或者说传入的并不是函数而直接是一个对象 3. 通过第二步判断,我们需要开始创建一个getter函数,同时该函数内部需要递归调用某个函数来实现对象属性的访问,递归的过程需要监控当前递归的层级与当前watch的配置问题,如果watch配置为深度监听,就需要完全遍历对象。 4. 创建effect,并传入getter函数作为effect的run函数,同时将callback作为effect的回调函数 ```js function doWatch(source, callback, { deep }) { let getter; // 判断传入是否是一个函数 if (isFunction(source)) { // 判断当前函数返回值是否是简单数据 const result = source(); if (isObject(result)) { source = result; } else { getter = source; } } //判断当前source是否为对象 if (isObject(source)) { const reactiveGetter = (source) => traverse(source, deep === false ? 1 : undefined); getter = () => reactiveGetter(source); } // 执行的工具函数 const job = () => { // 执行逻辑 callback(); }; // 创建依赖对象 const effect = new ReactiveEffect(getter, job); } ``` **(3).实现traverse函数** 1. 遍历对象,需要判断当前层级是否为1,如果为1,说明是第一层,需要将该对象中的所有属性都监听一遍,否则就递归遍历对象 同时为了防止对象中有循环引用,需要设置一个seen集合,用于判断当前对象是否已经遍历过,如果已经遍历过则直接返回 ```js function traverse(source, depth, currentDepth = 0, seen = new Set()) { // 判断是否为对象 if (!isObject(source)) return source; if (depth) { if (currentDepth >= depth) { return source; } currentDepth++; } // 判断是否循环引用 if (seen.has(source)) return source; for (let key in source) { traverse(source[key], depth, currentDepth, seen); } seen.add(source); return source; } ``` **(4).注入新值与旧值** 在effect创建之前,我们创建一个oldValue变量用于存放老值 然后我们主动调用一次effect.run()获取到当前数据 在job调用时我们再次手动调用effect.run获取到新值,然后通过callback函数将新值与老值传入 ```js function doWatch(source,callback,{ deep }) { ... // 保存老值 let oldValue; // 执行的工具函数 const job = () => { // 获取新值 const newValue = effect.run() // 执行逻辑 callback(newValue,oldValue) oldValue = newValue // 替换老值 } // 创建依赖对象 const effect = new ReactiveEffect(getter,job) oldValue = effect.run() } ``` **(5).判断source数据类型** watch只能够监听响应式数据,因此我们需要在getter赋值时来判断当前数据是否是响应式,如果不是响应时则不做处理 还有一种可能就是如果传入的第一个参数是一个函数,第二个参数不是函数或者不传,这就会使用watchEffectAPI,因此我们还需要再判断 ```js # apiWatch.ts function doWatch(source,callback,{ deep }) { ... // 判断是否是响应式对象 if(isReactive(source)) { getter = () => reactiveGetter(source) } else if(isRef(source)) { getter = () => source.value } ... const job = () => { if(callback) { // 获取新值 const newValue = effect.run() // 执行逻辑 callback(newValue,oldValue) oldValue = newValue // 替换老值 } else { // watchEffect } } ... // 判断当前是否传入回调函数 if(callback) { } else { // watchEffect 处理 } } ``` **(6).watch立即执行与watchEffect** 1. watch函数的第三个参数可以传入配置项,其中就有一个配置是immediate,该配置的作用是当监听器创建时立即执行回调函数 ```js // 判断如果是需要立即执行,则先执行一次job函数,将新旧值传入 if (immediate) { job(); } else { oldValue = effect.run(); } ``` 2. watchEffect函数与watch函数很相似,函数接收两个参数,第一个参数传入一个回调函数,第二个参数传入一个配置,当第一个函数依赖的数据变化,就会重新执行该函数,本质上就是一个effect。 ```js export function watchEffect(getter, options = {}) { return doWatch(getter,null,options as any) } ``` **(7).doWatch返回值** 1. 返回一个stop函数,用于停止监听 在这个函数函数中调用effect的stop方法,用于停止监听 ```js function doWatch(source,callback,{ deep }) { ... // 创建依赖对象 const effect = new ReactiveEffect(getter,job) // 判断是否需要立即执行 if(immediate) { job() } else { oldValue = effect.run() } // 返回一个stop函数 return () => { effect.stop() } } ``` ## 第三章 runtime-dom模块 ### 1. rendererOptions **(1).创建文件** 首先我们先创建出一个rendererOptions对象,该对象中保存了创建虚拟DOM节点的方法 因此在创建该函数之前我们需要有一个节点操作和属性操作对象,用来操作dom元素 在src文件夹下创建index.ts,nodeOps.ts, patchProp.ts 三个文件 nodeOps.ts文件用于保存操作DOM节点的方法 ```js nodeOps.ts // 对节点的增删改查 export const nodeOps = { // 插入元素 insert(el,container,anchor) { // 这里由于需要传递第三个参数,即代表将子元素插入到父元素中的另一个子元素前面 // 如果第三个参数不传的话,就默认插入到最后一个,因此这里使用的是insertBefore方法 container.insertBefore(el,anchor || null); }, // 移除元素 remove(el) { const parent = el.parentNode; if(parent) { parent.removeChild(el); } }, createElement(type ) { return document.createElement(type); }, createText(text) { return document.createTextNode(text); }, createComment(text) { return document.createComment(text); }, setText(el,text){ el.nodeValue = text; }, setElementText(el,text) { el.textContent = text; }, parentNode(el) { return el.parentNode; }, nextSibling(el) { return el.nextSibling; }, setScopeId(el,id) { el.setAttribute(id,''); }, cloneNode(el) { return el.cloneNode(true); }, } // index.ts中需要将reactivity模块的数据导出,并且需要导出createRenderer函数,同时将nodeOps和patchProp合并 # index.ts export * from '@vue/reactivity'; import { nodeOps } from './nodeOps'; import patchProp from './patchProp'; // 将节点和属性操作合并 export const rendererOptions = Object.assign({ patchProp }, nodeOps); ``` **(2).patchProp 属性的处理** patchProp.ts用于保存对元素属性的修改,其中主要导出一个patchProp方法对属性进行操作 ```js # patchProp.ts // 属性操作 import { patchClass } from './modules/patchClass' import { patchStyle } from './modules/patchStyle' import { patchEvent } from './modules/patchEvent' import { patchAttr } from './modules/patchAttr' export default function patchProp(el,key,preValue,nextValue) { if(key === "class") { // 处理class属性 return patchClass(el,nextValue) } if(key === "style") { // 处理style属性 return patchStyle(el,preValue,nextValue) } if(/^on[^a-z]/.test(key)) { // 处理事件 return patchEvent(el,key,nextValue) } // 否则为普通属性 return patchAttr(el,key,nextValue) } ``` 同时在元素上绑定的属性共分为四类: 1. class属性 ```js # modules/patchClass.ts // 处理class属性 export function patchClass(el, value) { if(value) { el.className = value } else { el.removeAttribute("class") } } ``` 2. style属性 ```js # modules/patchStyle.ts // 处理style属性 export function patchStyle(el,preValue,newValue) { const style = el.style; for(let key in newValue) { style[key] = newValue[key]; } if(preValue) { // 查询旧属性是否还存在,不存在则删除 for(let key in preValue) { if(!newValue[key]) { style[key] = null; } } } } ``` 3. 事件属性 ```js # modules/patchEvent.ts function createInvoker(value) { // 创建一个调用器 const invoker = (e) => invoker.value(e) invoker.value = value // 更改invoker的value属性即可更改绑定的事件 return invoker } export function patchEvent(el,key,nextValue) { const invokers = el._vei || (el._vei = {}); const eventName = key.slice(2).toLowerCase() // 判断当前绑定事件是否存在 const existingInvokers = invokers[eventName] if(nextValue && existingInvokers) { return existingInvokers.value = nextValue } // 判断是否有最新的处理逻辑 if(nextValue) { const invoker = createInvoker(nextValue) // 将调用器缓存 invokers[eventName] = invoker return el.addEventListener(eventName,invoker) } if(existingInvokers) { // 移除绑定事件 el.removeEventListener(eventName,existingInvokers) invokers[eventName] = null } } ``` 4. 其他属性 ```js # modules/patchAttr.ts export function patchAttr(el,key,value) { if(value) { el.setAttribute(key,value) } else { el.removeAttribute(key) } } ``` ### 2. render函数 与createRenderer函数 示例: ```js import { render, h } from "./runtime-dom.js"; const herr = h( "div", { style: { color: "red", fontSize: "20px", fontWeight: "bold", }, onClick: () => { alert("点击了"); }, class: "text", dataSrc: "xaxaxx", }, [ h("li", { style: { backgroundColor: "blue" } }, "123"), h("li", { style: { backgroundColor: "green" } }, "456"), h("li", { style: { backgroundColor: "yellow" } }, "789"), ], ); render(herr, document.body); ``` - 在这个示例中,我们通过三个api函数两个api函数来将一个生成的虚拟节点渲染到页面中,这也是runtime-dom模块的核心功能,下面我们逐步实现这个过程 **1.render函数** runtime-dom模块有个最核心的方法render,这个函数是用来渲染dom节点的,它接收两个参数,第一个参数是虚拟节点(vnode: **后续会说**),第二个参数是需要渲染的节点的父节点dom元素 该函数会将传入的虚拟节点渲染dom元素并插入到页面中 ```js # index.ts export const render = (vnode,container) => { return createRenderer(rendererOptions).render(vnode,container) } ``` 如上述代码所示,Vue中render并不会直接参与渲染,而是专门会提供一个createRenderer函数,通过调用该函数的render方法来进行渲染。 需要注意的是,Vue并不单是一个Web框架,同时它慢慢的已经成为了一个跨平台框架,因此Vue源码中的某些api不只是需要提供给web端使用,所以这里的createRenderer函数并不会有runtime-dom直接提供,而且被封装在另一个runtime-core模块中,而这个模块即是Vue跨平台的重点所在。 **2.createRenderer函数** 这个函数是runtime-core模块的一个关键函数,该函数接收一个配置对象那个,这个配置的对象其实就是rendererOptions,但我们上面所写的rendererOptions的实现只是使用了一些webAPI来实现,但这里的rendererOptions我们可以传入webApi版本,也可以传入使用其他的平台API实现版本,例如小程序端的API实现版本。这样就达到了跨平台的目的。 1. 首相我们同runtime-dom创建方式一样,重新创建一个runtime-core模块 2. 我们知道在runtime-dom模块中我们可以从中导出reactivity模块的方法,本质上这个reactivity模块是被runtime-core模块所导出,然后经由runtime-dom间接导出使用 3. 本质上runtime-dom其实就是runtime-core对于web平台的进一步封装 4. createRenderer函数返回一个对象,对象的render方即是我们用来渲染虚拟节点的。 5. \我们将传入的配置想解构出来,拿到需要使用的api方法 ```js # runtime-dom/index.ts import { nodeOps } from './nodeOps'; import patchProp from './patchProp'; import { createRenderer} from '@vue/runtime-core' // 将节点和属性操作合并 export const rendererOptions = Object.assign({ patchProp }, nodeOps); // render渲染器是内置调用了createRenderer方法创建的,并且传入的渲染配置默认使用domapi渲染 // 但createRenderer方法并未指定渲染方式,我们可以自己手动通过传入配置的方式指定渲染方式, // 这就有利于uniapp等Vue的衍生框架使用不同意dom的渲染方法进一步拓展 export const render = (vnode,container) => { return createRenderer(rendererOptions).render(vnode,container) } export * from '@vue/runtime-core' ``` ```js # runtime-core/index.ts export function createRenderer(rendererOptions) { // 多次调用会进行虚拟节点的比较 const render = (vnode,container) => { // 将虚拟节点渲染成真实节点 // 将配置信息解构 const { insert: hostInsert, remove: hostRemove, patchProp: hostPatchProp, createElement: hostCreateElement, setElementText: hostSetElementText, parentNode: hostParentNode, nextSibling: hostNextSibling, } = rendererOptions; patch(container._vnode || null, vnode, container) container._vnode = vnode } return { render } } ``` **3.patch函数的实现** patch函数用于将虚拟节点渲染为真实节点 这里为了判断当前节点是否是第一次渲染,我们在patch的第一个参数中传入一个标识参数,第一次渲染是container上没有.\_vnode,故patch第一个参数传入空,代表本次是初始化节点 否则只需要更新节点 ```js const patch = (n1, n2, container) => { // 1. 判断是否是第一次 if (n1 == n2) { return; } // 2. 初始化 if (n1 === null) { mountElement(n2, container); } }; ``` **4.mountElement函数的实现** mountElement中我们调用传入的api方法创建dom节点,挂载属性 ```js // 创建元素 const mountElement = (vnode, container) => { const { type, children, props, shapeFlag } = vnode; console.log(type); // 创建元素 const el = hostCreateElement(type); // 添加属性 if (props) { for (let key in props) { hostPatchProp(el, key, null, props[key]); } } console.log(vnode); // 添加子元素 // 判断子元素类型 // 1. 文本元素 if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { hostSetElementText(el, children); } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 2. 数组元素 mountChildren(children, el); } // 添加到容器中 hostInsert(el, container); }; ``` 这里我们使用到了虚拟dom的四个基本属性,type、children、props、shapeFlag type: 虚拟dom的标签名称 children: 虚拟dom的子元素 props: 虚拟dom的属性 shapeFlag: 虚拟dom子元素的类型 这个类型其实是一个二进制数,通过位运算来判断子元素类型 我们需要在shared模块中创建一个ShapeFlags常量,用于判断子元素类型 ```js # shared/index.ts export enum ShapeFlags { ELEMENT = 1, FUNCTIONAL_COMPONENT = 1 << 1, STATEFUL_COMPONENT = 1 << 2, TEXT_CHILDREN = 1 << 3, ARRAY_CHILDREN = 1 << 4, SLOTS_CHILDREN = 1 << 5, TELEPORT = 1 << 6, SUSPENSE = 1 << 7, COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, COMPONENT_KEPT_ALIVE = 1 << 9, COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT } ``` 然后我们在mountElement函数中判断shapeFlag类型,并调用不同的方法挂载子元素,如果是文本元素我们直接插入即可。 **5. 子元素挂载** 如果当前虚拟节点有子节点,我们需要递归调用patch函数将子节点挂载到当前节点上 ```js // 挂载子元素 const mountChildren = (children, el) => { for (let i = 0; i < children.length; i++) { // 递归挂载子元素 patch(null, children[i], el); // TODO: children[i]可能是文本,后需要单独处理 } }; ``` ### 3. h方法的实现 **1.h函数实现** h方法,本身接受无限的参数个数,但必须保证传入的参数大于等于一个,且第一个参数必须是节点类型,用于生成一个虚拟节点对象 | 参数 | 类型 | 描述 | 是否可选 | | ---- | ----- | -------- | ----- | | type | string | 节点类型 | 必选 | | propsOrChildren | object/Array | 节点的属性对象或者是子元素,子元素数组,元素文本| 可选 | | children | string/Array/vnode | 子元素,子元素数组,元素文本| 可选 | - 如果第三个参数不想传入数组,也可以将数组项挨个传入即可。 h函数会对传入的参数进行判断,从而对每种参数配置进行不同的处理,并最终调用一个createVNode函数生成一个虚拟节点对象 ```js # @vue/runtime-core/h.ts import { isObject, isString, } from "@vue/shared" // 这里的isObject和isString时shared中写的简单的判断数据类型的方法,可自行实现 import { createVnode } from "./createVnode" export function h (type,propsOrChildren?,children? ) { let argsLen = arguments.length // 判断当前参数数量 if(argsLen === 2) { // 判断是否是对象 if(isObject(propsOrChildren)) { // 判断是否是数组 if(Array.isArray(propsOrChildren)) { return createVnode(type,null,propsOrChildren) } // 判断是否是虚拟节点 if(isVnode(propsOrChildren)) { return createVnode(type,null,[propsOrChildren]) } // 否则就是属性对象 return createVnode(type,propsOrChildren,children) } // 判断是否是字符串 if(isString(propsOrChildren)) { return createVnode(type,null,propsOrChildren) } } else { if(argsLen > 3) { children = Array.from(arguments).slice(2) return createVnode(type,propsOrChildren,children) } if(argsLen === 3 && isVnode(children)) { children = [children] } return createVnode(type,propsOrChildren,children) } } ``` **2. createVnode函数实现** createVnode函数接收三个参数,三个差速均为必选参数 | 参数 | 类型 | 描述 | | ---- | ----- | -------- | | type | string | 节点类型 | | props | object/null | 节点属性对象 | | children | string/Array | 子元素数组,元素文本| 函数内部创建一个vnode对象,为其添加一系列的属性,这里我们先添加部分基础属性 ```js import { isString, ShapeFlags } from "@vue/shared"; export function createVnode(type, props, children) { // 初始化元素类型 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : 0; // 创建虚拟节点 const vnode = { __v_isVnode: true, // 标识属性 type: type, // 节点类型 props: props, // 节点属性 children: children, // 子元素 key: props ? props.key : undefined, // key值 用于diff el: null, // dom元素 shapeFlag: shapeFlag, // 元素类型 }; // 判断子元素是否是数组 if (Array.isArray(children) && children) { vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN; // 数组元素类型 } else { children = String(children); vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN; // 文本元素类型 } return vnode; } ``` ### 4.更新页面元素 **1. 移除页面元素** 上面所写的render方法,在第一次如果传入一个虚拟节点和do容器,就会把虚拟节点渲染到页面上。但当再次调用render方法,且第一个参数为null,第二个参数仍为上一次的容器时,就会把容器内的所有元素清除掉。 - 我们需要在runtime-core模块的render方法中进判断,判断传入的第一个参数是否是null,如果是null,则调用对应的方法来移除元素。 - 在此之前我们需要在每一次添加元素之后,所添加的元素挂载到当前虚拟节点的el属性上,方比那后续操作。 ```js # runtime-core/render.ts export function createRenderer(rendererOptions) { ... // 1. 在添加元素之后将元素挂载 const mountElement = (vnode, container) => { ... // 创建元素 const el = hostCreateElement(type); // 关联虚拟节点 vnode.el = el; ... } // 3. unmount函数实现 // 移除挂载的元素 const unmount = (vnode) => { // 获取父元素 hostRemove(vnode.el); // hostRemove函数是rendererOptions配置当中传入的方法 } ... // 2. 在render函数中判断第一个参数是否是null const render = (vnode,container) => { ... // 判断是否需要移除元素 if(vnode === null) { if(container._vnode) { // 移除当前容器中的元素 unmount(container._vnode) } } ... } } ``` **2. 更新页面元素** 1. 更新元素是我们需要在patch函数中来判断两次渲染的元素是否是同一类型且key值相同的元素,如果传入的两个元素类型不同或者是key值不同,则代表两个元素不是同一节点,第二次传入的元素直接替换掉第一次的元素 ```js # runtime-core/render.ts // 先在Vnode创建的文件中写一个判断两个虚拟节点是否相同的方法 export function isSameVnode(oldValue,newValue) { return oldValue.type === newValue.type && oldValue.key === newValue.key } // 1. 判断是否是同一节点 const patch = (n1, n2,container) => { // 1. 判断是否是第一次 if(n1 == n2) { return; } // 3. 判断当前渲染和上一次渲染是否是同一类型的节点 if(n1 && !isSameVnode(n1,n2)) { // 移除上一个元素,重新渲染 unmount(n1); n1 = null } // 2. 初始化 if(n1 === null) { mountElement(n2, container) } } ``` 2. 如果是则代表这两个元素其实就是同一节点,但可能是属性或子元素有变化 ```js //2. 如果未进入上述代码判断,则就需要更新元素的属性或子元素 else { // 4.更新原来的元素 // 比较两个元素的差异 patchElement(n1, n2,container); } // 比较两个元素 const patchElement = (n1,n2,container) => { // 1.比较两个元素的差异,并且需要复用dom n2.el = n1.el let el = n1.el; // 2.比较属性 let oldProps = n1.props || {} let newProps = n2.props || {} patchProp(el,oldProps,newProps); // 3.比较子元素 patchChildren(n1,n2,container); } ``` _1.1 更新属性_ —— 更新属性需要先循环新属性,然后循环老属性,如果新属性不存在则移除 ```js // 比较两个次的属性差异 const patchProp = (el, oldProps, newProps) => { // 循环遍历 // 新属性需要全部生效 for (let key in newProps) { // 挂载属性 hostPatchProp(el, key, oldProps[key], newProps[key]); } // 循环老属性,如果属性不存在,则移除 for (let key in oldProps) { if (!(key in newProps)) { hostPatchProp(el, key, oldProps[key], null); } } }; ``` _1.2 更新子元素_ —— 更新子元素需要先判断新老元素的子元素是否相同,如果不同则移除旧元素的所有子元素,然后重新渲染 ```js // 比较子节点 const patchChildren = (n1, n2, container) => { console.log("patchChildren", n1, n2); // TODO: 比较两次子节点 }; ``` ### 5.更新子节点 **(1).子节点分类** 因为h方法中特殊处理了,因此这里的子节点只有可能为文本,数组或者空。 | 旧节点 | 新节点 | 操作 | | --- | --- | --- | | 文本节点 | 文本节点 | 直接更新文本 | | 文本节点 | 空 | 移除原来的文本节点 | | 文本节点 | 数组 | 移除原来的文本节点,挂载数组中的每一个元素节点 | | 数组 | 文本节点 | 移除原来的所有元素,添加文本节点 | | 数组 | 空 | 清空数组中的所有元素节点 | | 数组 | 数组 | 最复杂的情况,需要完全使用diff算法比较两个数组(本节未实现) | | 空 | 文本节点 | 直接添加文本节点 | | 空 | 数组 | 清空容器,添加数组中的每一个元素节点 | | 空 | 空 | 无操作 | **(2).具体实现** 将以上的9中情况划分归类后,主要分为以下6种实现 1. 新节点是文本,老姐但是数组 —— 老老节点 2. 新节点是文本,老节点是文本 —— 内容不同替换 3. 老节点是数组,新节点是数据 —— 全量diff算法 4. 老节点是数组,新节点不是数组 —— 移除老节点 5. 老节点是文本,新节点是空 —— 移除老节点 6. 老节点是文本,新节点是数组 —— 移除老节点,挂载新节点 ```js // 比较子节点 const patchChildren = (n1, n2, el) => { // TODO: 比较两次子节点 // 获取两次节点的子节点 const c1 = n1.children; const c2 = n2.children; // 获取两次节点的子节点类型 const prevShapeFlag = n1.shapeFlag; const nextShapeFlag = n2.shapeFlag; if (nextShapeFlag & ShapeFlags.TEXT_CHILDREN) { // 1. 老节点是数组,删除 if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { unmountChildren(c1); // 删除原来的子节点 } // 2. 节点非数据翻译新节点不同 if (c1 !== c2) { hostSetElementText(el, c2); } } else { if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 3.老节点是数组,新节点也是数组(全量diff算法) if (nextShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: diff算法 console.log("diff算法"); } else { // 4.老节点是数组某,新节点不是数组 unmountChildren(c1); // 删除原来的子节点 } } else { // 5.老节点是文本,新节点为空 if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { hostSetElementText(el, ""); // 清空文本 } // 6.老接单是文本,新节点是数组 if (nextShapeFlag & ShapeFlags.ARRAY_CHILDREN) { mountChildren(c2, el); } } } }; // 批量删除子节点 const unmountChildren = (children) => { for (let i = 0; i < children.length; i++) { // 递归删除 unmount(children[i]); } }; ```