1.创建effect
在reactivity文件夹在创建一个effect.ts文件,作为effect的入口。文件中导出一个effect函数,并通过index.ts统一导出。
这个函数接受两个参数,第一个参数是一个回调函数,即是用户使用响应式对象时所写的逻辑代码,第二个参数是一个配置对象,后续会详细讲解。
在effect中,我们需要返回一个响应式的effect对象,作为墨盒响应式数据的某个属性依赖,因此我们创建一个ReactiveEffect类
代码如下:
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函数。
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
# 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<any> = {
get(target,key,recevier) {
...
// 取值时,需要将响应式属性与effect进行绑定 - 依赖收集
track(target,key) // 收集依赖
...
},
set(target,key,value,recevier) {
...
},
}
注意,上面的run函数中还存在一个极其严重的问题,当我们有两个以上的effect嵌套书写时,就会出现问题。
例如:
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。
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名称便于分辨。
// 判断当前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表数量。
// 添加依赖
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方法调用后,同时需要就当前触发的对象某个属性的就旧值与新值,并做比较,如果两者不同,再进行触发更新。并且在触发更新之前必须要先将新值设置上去。
# 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都执行一遍。
# 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中所保存在依赖就会有一部分变为无用依赖,因此我们也需要将其清除。
// 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——>这个是为了后续从头对比依赖表,从而覆盖没用的依赖
# 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依赖表取出来,将整个依赖于本次依赖做对比,如果相同则代表本次依赖和上次添加的对应位置的依赖相同,无需清除,如果不同则需要清除上一次的依赖,保存本次的依赖。
# 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. 在当前依赖执行完之后,需要清除掉多余的依赖。
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
}
}