知识预热
- 副作用函数
副作用函数——当某一函数执行时,会直接或间接影响到其他函数执行,例如改变外部变量,影响其他函数执行结果等。这样的函数被称为副作用函数。
let val = 10;
(function effect() {
val = 0;
})();
function demo() {
const a = val + 2
console.log(a)
}
demo();
在上面代码中,effect函数就是一个副作用函数,effect函数的执行影响了外部变量,当effect函数的执行时机早于demo函数时,同样会影响demo函数的执行结果。
- 响应式数据
响应式数据——假设在某一个副作用函数中,引用了某一对象的相应属性,当我们修改被引用的对象属性时,副作用函数会被自动执行。这个被引用的对象即为响应式数据。
响应式数据的基本实现
响应式数据的实现可以理解为——在引用了对象相关属性的副作用函数与对象之间建立联系。基于此可以从两个点出发:
-
当副作用函数effect函数执行时,会触发相关对象属性的读取操作。
-
当修改被引用的对象属性内容时,会触发该属性的设置操作。
那么响应式数据的实现关键点在于如何拦截对象属性的读取和设置操作。这里利用代理对象Proxy来实现:当副作用函数执行时,访问到相关对象属性,触发读取操作。此时可以将该副作用函数收集到一个”容器“中,该容器结构为Set结构,可以避免在副作用函数重复引用时,仅收集一个副作用函数。当修改被引用的对象属性时,触发设置操作,在设置操作中,从“容器”中取出引用了该属性的副作用函数,一一执行。
// 声明存储副作用函数的”容器“
const bucket = new Set()
// 存储当前被注册的副作用函数
let activeEffect;
// 用于注册副作用函数,方便用户使用任意形式,任意命名的函数来作为副作用函数,提高了灵活性
function effect(fn) {
// 存储副作用函数
activeEffect = fn
// 执行副作用函数
fn()
}
// 声明响应式数据
const data = {
count: 0,
text: "text"
}
// 代理对象
const obj = new Proxy(data,{
// 读取操作 target指向对象本省 key指向被访问的属性
get(target,key) {
// 如果存在已注册的相关副作用函数,将该函数添加到”容器中“,不存在,即说明该访问不在任何函数中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
// 设置操作 target指向对象本身,key指向被修改的属性,value指向修改的新值
set(target, key, value) {
// 更新值
target[key] = value
// 遍历执行副作用函数
bucket.forEach(effectFn => {
effectFn()
})
}
})
// 案例
// 注册副作用函数
effect(() => {
const b = obj.count + 10
console.log(b)
})
obj.count++
// 代码执行到此处时, 对应输出结果: 10(注册副作用函数时的自动执行,用于将副作用函数添加到”容器“中) 11(obj.count修改时,触发的副作用函数的执行)
obj.text = "aaaa"
// 代码执行到此处时,对应输出结果:10 11 11
在上面代码中,基本实现了在副作用函数与相应对象之间建立联系。但是在最后修改obj.text属性时,可以发现在控制台中多输出了一个11。问题就在于,目前建立的联系是副作用函数与对象之间的联系,当我们修改对象的其他属性时,同样会触发不与它相关联的副作用函数。归根结底,这样的联系不具有一一对应性。所以我们需要在引用了对象对应属性的副作用函数与相关对象属性之间建立联系,而不是仅与对象建立联系。这时候就需要重新设计整个响应式的结构。
将bucket"容器"从Set结构改变为WeakMap结构,weakMap结构便于存储多个响应式数据及其副作用函数的依赖联系,利于统一管理响应式数据。WeakMap结构与Map结构都能够存储映射关系,那么为什么使用WeakMap结构呢?相比于普通的Map结构,WeakMap结构能够避免可能出现的内存溢出情况。WeakMap结构对key的引用是弱引用,当用户不在引用key值对应的值时,说明用户不在需要它,垃圾回收机制会将该key的引用移除。而对于Map结构,即便key所引用的值被引用一次后,后续不在引用,它所引用的值依然不会被垃圾回收机制所移除。长期来看,可能就会导致内存溢出的情况。
在WeakMap结构中,将响应式数据作为key值,其对应引用值则是该响应式数据的副作用函数映射(Map结构)。在该副作用函数映射中,将各个属性作为key值,其对应引用则是相关的副作用函数集合(Set结构)。每个属性都有其对应的单独的副作用函数集合。这样副作用函数与被引用的对象属性就建立起了一一对应的关系。
const bucket = new WeakMap()
let activeEffect;
function effect (fn) {
activeEffect = fn
fn()
}
const data = {
count: 0,
text: "text",
ok: true
}
// 收集副作用函数(依赖) / 追踪副作用函数(依赖)
function track(target, key) {
// 如果当前读写的代理对象不是处于收集依赖(副作用函数)的状态 而是 修改状态 直接return
if (!activeEffect) return
// 根据当前正在读写的代理对象 从“桶”中取得对应depsMap(用于将代理对象于其相关副作用函数“桶”建立联系) 结构: key---> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap 则新建一个 map 并于 target关联
if (!depsMap) bucket.set(target, ( depsMap = new Map() ))
// 再根据key 从 depsMap 中取得deps, deps为Set类型 存储着所有于当前key相关联的副作用函数: effects
let deps = depsMap.get(key)
// 如果deps 不存在 同样新建一个Set 并与 key 关联
if (!deps) depsMap.set(key, ( deps = new Set() ))
// 将当前激活的副作用函数添加到对应 “桶”里
deps.add(activeEffect)
}
// 触发副作用函数(依赖)
function trigger (target, key) {
// 根据target 从 “桶”中 取得 depsMap, 结构: key ---> effects
const depsMap = bucket.get(target)
// 如果不存在副作用函数,直接返回
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const deps = depsMap.get(key)
// 执行副作用函数
deps && deps.forEach(dep => dep())
}
const obj = new Proxy(data, {
get(target, key) {
// 收集副作用函数(依赖) / 追踪副作用函数(依赖)
track(target, key)
return target[key]
},
set(target, key, value) {
target[key] = value
// 触发副作用函数(依赖)
trigger(target,key)
}
})
effect(() => {
const b = obj.count + 10
console.log(b)
})
effect(() => {
const c = obj.text + "----Text"
console.log(c)
})
console.log("--------------")
obj.demo = 123
obj.count++
obj.text = "demo"
/*输出结果
10 (副作用函数注册时的 自执行)
text----Text (副作用函数注册时的 自执行)
--------------
11 (obj.count修改 引起的副作用函数自执行)
text----Text ?
demo----Text (obj.text修改 引起的副作用函数自执行)
*/
从上面代码来看,当我们添加属性demo并进行读写操作时,并没有触发额外无关的副作用函数执行。说明对象属性与其对应副作用函数的一一对应联系建立成功。但是观察输出结果发现,多出了一行text----Text,这个输出又是来自哪里的呢?通过断点调试,可以发现,这一行结果的出现是由修改obj.count而导致的,之所以会这样,是因为出现了副作用遗留的问题。当我们修改obj.count时,会把依赖集合中的副作用函数遍历取出执行,所以会触发count属性的读取操作,由于每次注册完副作用函数后,activeEffect没有被清除,此时activeEffect存储的是上一个注册的副作用函数即引用了obj.text的副作用函数。在count属性的读取操作中会将该遗留的副作用函数收集到它对应的依赖集合中,由于收集副作用函数时,会自执行一次函数体内容。所以就会出现这么一行令人迷惑的输出结果。 分支切换的情况同样会出现副作用函数遗留的问题。
effect(() => {
document.body.innerHTML = obj.ok ? obj.text : "not"
console.log("执行")
})
obj.ok = false
obj.text = "demo"
/*输出结果:
执行 (副作用函数自执行)
执行 (修改obj.ok 触发相关联副作用函数执行)
执行 (修改obj.text 触发不在关联的副作用函数执行)
*/
当我们添加带有分支切换的副作用函数后,此时修改obj.ok的值一切正常,但是当我们修改obj.text时,会发现照样会触发该副作用函数,即便此时obj.ok的值为false。经过观察可以发现,ok属性的值一开始为true,那么document.body.innerHTML的值会变为obj.text的值内容。在访问obj.text的值时,同样会收集该副作用函数到obj.text的依赖集合中。所以后续修改obj.text的值时,即便obj.ok的值为false同样会触发该已经与它不在有关联的副作用函数。
副作用函数遗留问题的解决
副作用函数遗留会导致不必要的更新,那么又应该从那些方面入手解决呢?经观察发现,每当我们修改属性值时,都会把依赖集合中的副作用函数遍历取出执行,副作用函数执行时,会再次触发该属性的读取操作。在进行读取操作时,上次注册遗留的副作用函数会被该属性收集到其对应的依赖集合中。基于此,我们可以在每次副作用函数执行前,先将它从所有与之关联的的依赖集合中删除,切断联系。当副作用函数执行完毕后,再重新建立联系,这样在新的联系中就不会包含上次注册遗留的副作用函数。要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道那些依赖集合中包含它。
const bucket = new WeakMap()
let activeEffect;
function effect(fn) {
// 声明effectFn函数 对副作用函数进行包装
const effectFn = () => {
// 在副作用函数执行前 切断副作用函数与相关联的依赖集合之间的联系
cleanUp(effectFn)
activeEffect = effectFn
// 实际副作用函数 存储在闭包中
fn()
}
// 添加deps属性 存储包含它的依赖集合
effectFn.deps = []
effectFn()
}
// 切断联系
function cleanUp(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// 遍历取出依赖集合
const deps = effectFn.deps[i]
// 删除当前副作用函数
deps.delete(effectFn)
}
// 重置“清零”
effectFn.deps.length = 0
}
const data = {
count: 0,
text: "text",
ok: true
}
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)
// 建立双向联系, 明确当前副作用函数被那些依赖集合所收集
activeEffect.deps.push(deps)
}
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
// deps && deps.forEach( dep => dep() )
// 为了避免get, set 操作的循环执行 声明一个用于执行的依赖集合
const depsToRun = new Set(deps)
deps && depsToRun.forEach( dep => dep() )
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set (target, key, value) {
target[key] = value
trigger(target, key)
}
})
effect(() => {
const b = obj.count + 10
console.log(b)
})
effect(() => {
const c = obj.ok ? obj.text : "not"
console.log(c)
})
console.log("----------")
obj.count = 10
obj.ok = false
obj.text = "demo"
/*输出结果:
10 (副作用函数自执行)
text (副作用函数自执行)
----------
20 (修改obj.count 触发相关联的副作用函数执行)
not (修改obj.text 触发相关联的副作用函数执行)
*/
观察上面代码可以发现,我们对副作用函数进行了一层包装,并为包装的副作用函数添加了一个deps属性。这个deps属性就是用来明确当前副作用函数被哪些依赖集合所包含,用于建立双向联系。在属性的读取操作中,完成双线联系的建立,在属性对应依赖集合收集完副作用函数后,同时为副作用函数收集对应的依赖集合。执行副作用函数之前,利用cleanUp函数切断双向联系。通过遍历副作用函数的deps属性值-存储着所有相关联的依赖集合数组,取出对应依赖集合,删除当前副作用函数。在trigger函数内部,我们也做出了相应的一些改变,声明了一个新的变量存储依赖集合用于依赖执行。为什么又要声明新的集合专门用于执行副作用函数呢?在原本的依赖集合deps遍历执行中,调用副作用函数会调用cleanUp函数切断联系,实际上就是从deps集合中将当前执行的副作用函数清除。而副作用函数的执行又会将其重新收集到依赖集合deps中。当遍历尚未结束时,该副作用函数就会被重新访问执行。最终导致依赖集合的遍历无限执行的情况出现。声明一个新的变量存储依赖集合用于执行即可避免这种情况的出现,将联系切断与联系重建区分开来。注意:由于对副作用函数进行了一层包装,实际的副作用函数存在于闭包当中
副作用函数嵌套问题的解决
在Vue函数中,可能会出现effect函数嵌套的情况,例如Vue.js的渲染函数就是在一个effect函数中执行的,当出现组件嵌套的情况时,就会出现effect函数嵌套的问题。
effect(() => {
console.log('外层执行');
effect(() => {
const c = obj.text + "--Text";
console.log('内层执行');
});
const b = obj.count + 10;
});
console.log("--------");
obj.count = 10
/*输出结果:
外层执行 (副作用函数的自执行)
内层执行 (副作用函数的自执行)
--------
内层执行 (修改obj.count 触发了内层副作用函数)
*/
上面代码就是一个effect函数嵌套的小实例,按照我们原有的代码实现,会发现当我们修改obj.count的值时,触发的不是真实引用了它的外层副作用函数,反而触发的是嵌套的内层副作用函数。为什么会如此呢?利用断点调试可以发现,当我们修改obj.count的值时,会把依赖集合中的副作用函数取出执行,这样会再次触发count属性的读取操作,这时候会再次收集副作用函数。尽管我们在副作用函数执行前就切断了对应联系,以便后续重建联系。但是由于activeEffect同一时刻只能存储一个副作用函数,当副作用函数发生嵌套时,内层副作用函数的执行会覆盖原先存储着外层副作用函数的activeEffect值,并且永远不会恢复到原来的值。切断联系后,与count属性重建联系的副作用函数就变为了内层副作用函数。为了解决这个问题,我们需要设计一个副作用函数栈effectStack,每当副作用函数执行时,将该副作用函数压入栈中,执行完毕再其弹出。activeEffect始终指向栈顶的副作用函数。
const bucket = new WeakMap()
let activeEffect;
// 声明副作用函数栈
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
// 执行副作用函数前 将副作用函数压入栈中
effectStack.push(activeEffect)
fn()
// 副作用函数执行完毕 将当前副作用函数弹出栈
effectStack.pop()
// 保证activeEffect始终指向栈顶元素
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
function cleanUp(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
const data = {
count: 0,
text: "text",
ok: true
}
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)
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
const depsToRun = new Set(deps)
depsToRun.forEach(dep => dep())
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set (target, key, value) {
target[key] = value
trigger(target, key)
}
})
effect(() => {
console.log('外层执行');
effect(() => {
const c = obj.text + "--Text";
console.log('内层执行');
});
const b = obj.count + 10;
});
console.log("--------");
obj.count = 10
/* 输出结果:
外层执行 (副作用函数自执行)
内层执行 (副作用函数自执行)
--------
外层执行 (修改obj.count 触发相关联的副作用函数)
内层执行 (修改obj.count 间接触发的内层嵌套副作用函数)
*/
利用断点调试观察,修改obj.count属性值后,在count属性与外层副作用函数重建联系时,此时activeEffect的值为undefined。在一开始注册外层副作用函数时,副作用函数自执行前,外层副作用函数被压入副作用函数栈中,接着开始执行外层副作用函数,在其内部又执行了内层副作用函数,在内层副作用函数的注册过程中,内层副作用函数在副作用函数栈中经历了入栈,出栈的过程。等到内层副作用函数注册完毕,事件执行又回到外层副作用函数的注册,此时副作用函数栈的栈顶元素即外层副作用函数,当外层副作用函数执行完毕,外层副作用函数出栈。完成外层副作用函数的注册,此时activeEffect的值变为undefined。activeEffect的值在经历副作用函数注册后,得到了重置,确保了对象属性与副作用函数的一一对应指向联系正确。这样就解决了副作用函数嵌套的问题。
栈溢出问题的解决
effect(() => {
obj.count++
// obj.count = obj.count + 1
})
// 控制台报错 Maximum call stack size exceeded
当我们在副作用函数中进行自增操作时,该操作会引起栈溢出。obj.count++语句既读取了count的值,同时又设置了count的值。当读取到obj.count的时候,触发读取操作,进行依赖收集。紧接着设置count的值,触发设置操作,将依赖集合中的依赖取出执行。而此时本次副作用函数的执行尚未完毕,又开始了下一轮执行,这样就会导致无限递归地调用自己,最终导致栈溢出问题。解决点在于trigger函数上,在副作用函数的取出执行上,添加守卫条件,当trigger函数取出执行的副作用函数与当前正在执行的副作用函数相同,则不触发该函数的重复执行。
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
const depsToRun = new Set()
deps && deps.forEach(dep => {
// 如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (dep !== activeEffect) {
depsToRun.add(dep)
}
})
depsToRun.forEach(dep => dep())
}
总结
const bucket = new WeakMap()
let activeEffect;
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanUp(effectFn)
activeEffect = effectFn
effectStack.push(activeEffect)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
function cleanUp(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]
deps.delete(effectFn)
}
effectFn.deps.length = 0
}
const data = {
count: 0,
text: "text",
ok: true
}
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)
activeEffect.deps.push(deps)
}
function trigger (target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
const depsToRun = new Set()
deps && deps.forEach(dep => {
if (dep !== activeEffect) {
depsToRun.add(dep)
}
})
depsToRun.forEach(dep => dep())
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set (target, key, value) {
target[key] = value
trigger(target, key)
}
})
以上代码为响应式系统的一个相对实现。