该篇为阅读《Vue.js设计与实现》过程总结的笔记,光看只会云里雾里,不如手敲一遍,感受一下~
响应式数据的基本实现
-
vue3采用ES6中的
代理对象Proxy
来实现对数据读取
和设置的拦截 -
观察以下代码
jslet obj= { text: 'hello' } function effect() { document.body.innerText = obj.text }
-
思考:当修改obj.text的值后,我们希望effect函数自动执行,但是显然以上代码做不到这一点
- 当effect函数执行时,会触发对数据的读取操作,修改数据时,会触发数据的设置操作
- 所以如果我们能拦截一个对象的读取和修改的操作,事情就变得简单了
-
-
粗糙地实现一个响应式数据
- 以下,obj是原始数据的代理对象,分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作
- 当读取属性时将effect添加到桶里,然后返回属性值,当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行
- 这就实现了响应式数据,在浏览器运行以下代码,能得到预期效果
jsconst bucket = new Set() // 存储副作用函数的'桶' let data = { text: 'hello' } // 原始数据 const obj = new Proxy(data, { // 对数据的代理 // 拦截读取操作 get(target, key) { console.log(target, key) bucket.add(effect) // 将副作用函数effect添加到存储副作用函数的 桶 中 return target[key] // 返回属性值 }, // 拦截设置操作 set(target, key, newVal) { target[key] = newVal // 设置属性值 bucket.forEach(fn => fn()) // 将副作用函数从桶里取出并执行 return true // 返回true表示设置成功 } }) function effect() { // 副作用函数 document.body.innerText = obj.text } effect() // 执行副作用函数触发读取 setTimeout(() => { // 1秒后修改响应式数据 obj.text = 'hello Vue3!' }, 2000)
设计一个完善的响应系统
-
基本实现的代码还需要处理很多细节
-
如一旦函数名字不为effect,那么代码不能正常工作,为了能够正确地将副作用函数甚至是匿名函数收集到桶中,需要提供一个用来注册副作用函数的机制
-
发现一个问题:通过在响应式数据obj上设置一个不存在的属性测试时
- effect run打印了两次
- 但是我们的匿名副作用函数内并没有读取obj.notExist属性的值,所以理论上它们之间并没有建立响应联系,因此定时器内语句的执行不应该触发匿名副作用函数重新执行,这是不正确的
- 导致该问题的根本原因:没有在副作用函数与被操作的目标字段之间建立明确的联系
jslet activeEffect; // 用一个全局变量存储被注册的副作用函数 function effect(fn) { // effect函数用于注册副作用函数 activeEffect = fn; // 当调用effect函数注册副作用函数时,将副作用函数fn赋值给activeEffect fn() // 执行副作用函数 } const obj = new Proxy(data, { get(target, key) { if (activeEffect) { bucket.add(activeEffect) // 将activeEffect中存储的副作用函数添加到桶中 } return target[key] // 返回属性值 }, set(target, key, newVal) { target[key] = newVal // 设置属性值 bucket.forEach(fn => fn()) // 将副作用函数从桶里取出并执行 return true // 返回true表示设置成功 } }) effect(() => { console.log('effect run'); // 浏览器运行代码一开始就输出 2s后再一次输出 document.body.innerText = obj.text }) setTimeout(() => { obj.notExist = 'hello Vue3!!!' }, 2000)
-
-
需要重新设计'桶'
-
用
target
表示一个代理对象所代理的原始对象,key
表示被操作的字段名,effectFn
表示被注册的副作用函数,它们之间应该是以下的树型数据结构 -
用weakMap代替Set作为桶的数据结构
-
修改get/set拦截代码
- 运行以下代码,在浏览器的运行结果如图:
- 这样子实现了只有关联的副作用函数才会执行
- 为了方便描述,后文将Set数据结构存储的副作用函数集合称为key的 依赖集合
jsconst bucket = new WeakMap() const obj = new Proxy(data, { get(target, key) { if (!activeEffect) return target[key] // 没有activeEffect 直接return let depsMap = bucket.get(target); // 根据target从bucket中获取depsMap 它是一个Map类型,key--effect if (!depsMap) { // 如果不存在depsMap 那么新建一个Map并于target关联 bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) // 再根据key从depsMap中取出deps 它是一个Set类型 里面存储着所有与当前key相关的副作用函数:effects if (!deps) { // 如果不存在deps 同样新建一个Set与key关联 depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) // 最后将当前激活的副作用函数添加到bucket中 return target[key] }, set(target, key, newVal) { target[key] = newVal const depsMap = bucket.get(target) // 根据target从桶中取得depsMap key--effects if (!depsMap) return const effects = depsMap.get(key) // 根据key取得所有副作用函数 effects effects && effects.forEach(fn => fn()) // 执行副作用函数 } }) effect(() => { console.log('effect run'); document.body.innerText = obj.text }) effect(() => { console.log('有关联的才log'); document.body.innerText = obj.age }) setTimeout(() => { obj.notExist = 'hello Vue3!!!' obj.age = 20 }, 2000)
-
那么为什么要使用WeakMap作为桶的数据结构
WeakMap和Map的区别
-
用一段代码讲解
jsconst map = new Map(); const weakmap = new WeakMap(); (function () { const foo = { foo: 1 }; const bar = { bar: 2 }; map.set(foo, 1); weakmap.set(bar, 2); })() console.log(map.keys());
-
函数表达式执行完后
-
对于foo对象来说,仍然作为map的key引用着,因此垃圾回收 器不会把它从内存中移除,仍然可以通过map.kyes打印出对象foo
-
对于bar对象来说,由于WeakMap的key是弱引用,不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器会把对象foo从内存移除,并且WeakMap不提供keys方法,无法获取weakmap的key值,也就无法通过weakmap取得对象bar
- 基于这个特性,WeakMap经常用于存储那些只有当key所引用的对象存在时(没有被回收)才有价值的信息
- 例如上诉桶的结构,如果target对象没有任何引用了,说明用户侧不再需要它,这是垃圾回收器会完成回收任务。如果使用Map作为桶结构,用户测的代码对target没有任何引用作用,这个target也不会被回收,最终可能导致内存溢出
最后,可以对上文的代码作封装处理
-
在get拦截函数里,把副作用函数收集到桶的逻辑,更好的做法是封装到一个
track
(追踪)函数中 -
在set拦截函数里,把触发副作用函数重新执行的逻辑,封装到一个
trigger
(触发)函数中js// 进行封装操作 const obj = new Proxy(data, { get(target, key) { track(target, key) // 将副作用函数activeEffect添加到存储副作用函数的桶中 return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) // 把副作用函数从桶中取出来执行 } }) function track(target, key) { // 在get拦截函数内调用track函数追踪变化 if (!activeEffect) return let depsMap = bucket.get(target); if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) } function trigger(target, key) { // 在set拦截函数内调用trigger函数触发变化 const depsMap = bucket.get(target) if (!depsMap) return let effects = depsMap.get(key) effects && effects.forEach(fn => fn()) }
切换分支与 cleanup
-
分支切换定义,如下代码
- 当我们把ok的值改为false,并触发副作用函数重新执行的时候,age的依赖集合遗留了该副作用函数
- 遗留的副作用函数会导致不必要的更新,就比如再修改age的值,最好的结果是不再执行该副作用函数,但是事实上是会重新执行的
- 控制台打印了3次 '当ok为false后 再修改age 这里不应该输出 但是理论上是会被输出的' , 说明最后修改age还是触发了副作用函数执行
jsconst data = { text: 'hello', age: 18, ok: true } const obj = new Proxy(...) effect(() => { console.log('当ok为false后 再修改age 这里不应该输出 但是理论上是会被输出的'); document.body.innerText = obj.ok ? obj.age : 'not' }) setTimeout(() => { obj.ok = false }, 2000) setTimeout(() => { obj.age = 21 }, 3000)
-
解决思路:每次副作用函数执行前,将其从相关联的依赖集合中移除
-
重新设计副作用函数,,并设置一个属性deps,该属性是数组,用来存储所有包含当前副作用函数的依赖集合
-
有了集合和副作用函数间的联系后,可以在每次执行副作用函数时,根据deps获取所有相关的依赖集合,进而将副作用函数从依赖集合中移除
-
cleanup函数接收副作用函数作为参数,遍历effectFn.deps数组,每一项都是一个依赖集合,将副作用函数从依赖集合中移除,可以避免副作用函数产生遗留了
-
此时有新的问题,执行代码会导致无限循环执行,问题出在trigger函数的
effects && effects.forEach(fn => fn())
这句代码中 -
forEach 遍历set集合时,如果一个值已经被访问过,但该值被删除并重新添加到集合,此时遍历还没有结束,该值会被重新访问,解决方法,构造另外一个Set集合并遍历它
js// 直接遍历set进行delete和set操作会无限执行 在set外构造另外一个set可以防止无限执行 const set = new Set([1, 2, 3]) const newSet = new Set(set) newSet.forEach(item => { // 1 2 3 console.log(item); set.delete(1) set.add(1) })
-
重新设计trigger函数,最后得到
- 控制台只打印2次 '当ok为false后这里输出一次 之后再修改age不输出' ,说明该代码优化有效
jsconst data = { text: 'hello', age: 18, ok: true } let activeEffect; function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] // deps是依赖集合 deps.delete(effectFn) // 将effectFn从依赖集合中移除 } effectFn.deps.length = 0 // 最后重置effectFn.deps数组 } function effect(fn) { const effectFn = () => { cleanup(effectFn) // 调用cleanup函数完成清除工作 activeEffect = effectFn; // 当effectFn执行时,将其设置为当前激活的副作用函数 fn() } effectFn.deps = [] effectFn() } const bucket = new WeakMap() function track(target, key) { if (!activeEffect) return let depsMap = bucket.get(target); if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) // deps就是一个与当前副作用函数存在联系的依赖集合 activeEffect.deps.push(deps) // 将其添加到activeEffect.deps数组中 } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return let effects = depsMap.get(key) const effectsToRun = new Set(effects) // 用来避免无限执行 effectsToRun.forEach(effectFn => effectFn()) } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) } }) effect(() => { console.log('有关联的才log'); document.body.innerText = obj.age }) setTimeout(() => { obj.ok = false }, 2000) setTimeout(() => { obj.age = 21 }, 3000) effect(() => { console.log('当ok为false后这里输出一次 之后再修改age不输出'); document.body.innerText = obj.ok ? obj.age : 'not' })
-
嵌套的 effect 与 effect栈
-
effect是可以嵌套的,但是以上的代码实现的响应系统并不支持嵌套
-
如下代码,我们修改的是foo的值,发现fn1并没有执行,反而使得fn2重新执行了,不符合预期
- 控制台打印
jslet temp1, temp2 effect(function effectFn1() { // f1嵌套了f2 console.log('effectFn1执行'); effect(function effectFn2() { console.log('effectFn2执行'); temp2 = obj.bar }) temp1 = obj.foo }) setTimeout(() => { obj.foo = false; }, 2000)
-
观察到我们的副作用函数直接将effectFn赋给activeEffect,意味着同一刻activeEffect所存储的副作用函数只有一个,并且是内层的副作用函数
-
需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前的副作用函数压入栈中,执行完毕从栈中弹出,始终让activeEffect指向栈顶
- 这样子,响应式数据就只会收集直接读取其值的副作用函数作为依赖
jslet effectStack = []; // 定义一个effectStack数组模拟栈 function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn; // 当调用effect函数注册副作用函数时,将副作用函数复制给activeEffect effectStack.push(effectFn) // 在调用副作用函数前将当前副作用函数压入栈 fn() effectStack.pop() // 当副作用函数执行完 将当前副作用函数弹出栈,并把activeEffect还原为之前的值 activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() }
避免无限递归循环
-
如以下例子:
- 读取obj.foo的值,会触发track操作,将副作用函数添加到桶中,接着将其加1再赋值给foo,会触发trigger操作,从桶中取出副作用函数并执行,问题是该副作用函数正在执行,还没执行完毕就要开始下一次执行,无限递归调用自己,尝试栈溢出
-
解决思路:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
jsfunction trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) // const effectsToRun = new Set(effects) // 用来避免无限执行 const effectsToRun = new Set(); effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { // 在这里进行判断 effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => effectFn()) }
调度执行
-
可调度:当trigger动作触发副作用函数重新执行的时,有能力绝对副作用函数执行的时机、次数以及方式
-
需要响应系统支持调度 ,可以为effect函数设计一个选项参数options,允许用户指定调度器:
- 对于effect函数
jsfunction effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn; effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.options = options // 将options挂载到effectFn上班 effectFn.deps = [] effectFn() }
- 对于trigger函数
jsfunction trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) const effectsToRun = new Set(); effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => { if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) // 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递 } else { effectFn() // 否则直接执行副作用函数(之前的默认行为) } }) }
-
基于调度器,我们可以实现该功能,当num自增到5时,不需要执行5次打印,期望打印最开始以及自增的最后结果
jsconst jobQueue = new Set() // 定义一个任务队列 const p = Promise.resolve() // 使用Promise.resolve()创建一个promise实例,我们用它将一个任务添加到微任务队列 let isFlushing = false function flushJob() { // 一个标志代表是否正在刷新队列 if (isFlushing) return // 如果队列正在刷新 则什么都不做 isFlushing = true // 设置为true 代表正在刷新 p.then(() => { // 在微任务队列中刷新jobQueue队列 jobQueue.forEach(job => job()) }).finally(() => { isFlushing = false // 结束后重置isFlushing }) } effect(() => { console.log(obj.num); }, { scheduler(fn) { // 每次调度时,将副作用函数添加到jobQueue队列中 jobQueue.add(fn) flushJob() // 调用flushJob刷新队列 } }) obj.num++ obj.num++ obj.num++ obj.num++ // 控制台打印 // 1 // 5
- 定义了一个任务队列
jobQueue
,为 Set数据结构,目的是利用Set数据结构的自动去重能力 - 调度器
scheduler
的实现,在每次调度执行时,先将当前副作用函数添加到jobQueue队列
中,再调用flushJob函数
刷次队列 flushJob函数
通过isFlushing
标志判断是否需要执行,只有当其为false
时才需要执行,而一旦flushJob函数
开始执行,isFlushing
就会设置为true
,意思是无论调用多少次flushJob函数,在一个周期内都只会执行一次- 注意,在
flushJob
内通过p.then
将一个函数添加到微任务队列,在微任务队列内完成对jobQueue
的遍历执行
- 定义了一个任务队列
-
整体代码效果
- 连续对num执行四次自增操作,会同步且连续地执行四次scheduler调度函数,这意味着同一个副作用函数会被jobQueue.add(fn)语句执行四次,但是由于Set的数据结构的去重能力,最终jobQueue中只会有一项,即当前副作用函数
- flushJob也会同步且连续地执行四次,但由于isFlushing标志的存在,实际上flushJob函数在一个事件循环内只会执行一次,即在微任务队列内执行一次
- 当微任务队列开始执行时,会遍历jobQueue并执行里面存储的副作用函数
- 由于此时jobQueue队列内只有一个副作用函数,所以只会执行一次,并且当它指向时,num已经是5了