1. 响应式数据与副作用函数
当一个函数的执行直接或间接的影响了其他函数的执行,我们就称其产生了副作用,比如:
let val = 1;
const effect = () => {
val = 2
}
const fn = () => {
console.log(val)
}
当effect函数执行的时候,会改变val的值,会影响了fn函数中打印的结果。此时我们就称effect函数产生了副作用。理解了副作用函数,再来看下什么是响应式数据。有以下一段代码:
const obj = {
text: '123'
}
effect = () => {
console.log(obj.text)
}
我们希望当obj.text重新赋值的时候,effect函数可以重新执行,打印出最新的数据。如果这个需求能够实现的话,那么我们就可以称obj为响应式数据了。
2. 响应式数据的基本实现
通过对以上代码的观察发现,我们的要达到的目标是1. 当effect函数执行的时候,我们会读取obj.text;2. 当obj.text改变时,effect函数要重新执行。
想要完成这个需求就得劫持数据的读取和改变,我们知道vue2是通过object.defineProperty来完成数据劫持的,而vue3是利用了es6中的Proxy对象来完成数据劫持,这两个方案今天就不展开讨论了。如果当effect函数中的obj.text读取时就将effect函数给存储起来,然后当obj.text值改变时,我们把暂存起来的effect函数掏出来运行一遍就ok咯。写段代码尝试一下:
const bucket = new Set();
let activeFuction
const reactive = (data) => {
return new Proxy(data, {
get (target, key) {
if (activeFuction) {
bucket.add(activeFuction)
}
return target[key]
},
set (target, key, value) {
target[key] = value
bucket.forEach(f => f())
return true
}
})
}
const effect = (fn) => {
activeFuction = fn
fn()
}
const data = reactive({
a: 1,
b: 2,
c: 3
})
effect(() => {
console.log(data.a)
})
data.a++
运行之后发现,控制台打印出了1和2,但是问题来了,如果执行data.b++也会打印触发一次effect中传入的函数。奇了怪了,函数中没有访问data.b,也就是说data.b的get根本就没有触发,为啥会出现这样的现象呢?问题在于bucket和activeFuction!
3. 响应式系统雏形
如果bucket只是一个简单的Set结构,那么所有数据的依赖都会被收集到这个bucket中,当其中一个数据发生了改变就需要把bucket中收集的所有函数全部都执行一遍,这显然是不合理的。那么应该怎么做呢?首先需要一个合理的数据结构来存储这些函数,data、effect和bucketMap对应关系示例如下:
const data1 = {
key1: 1,
key2: 2
}
const data2 = {
key1: 1,
key2: 2
}
const effect1 = () => {
console.log(data1.key1, data1.key2, data2.key2)
}
const effect2 = () => {
console.log(data1.key1, data2.key1, data2.key2)
}
const effect3 = () => {
console.log(data2.key2)
}
const effect4 = () => {
console.log(data2.key2)
}
// 可以用json对象的形式来描述一下最终的bucketMap
bucketMap = {
[data1]: {
[data1.key1]: new Set([effect1, effect2]),
[data1.key2]: new Set([effect1]),
},
[data2]: {
[data2.key1]: new Set([effect2]),
[data2.key2]: new Set([effect1, effect2, effect3, effect4]),
}
}
也可以画个图来简单描述一下这个结构:
如此设计,data的每一个属性都能有独立的bucket来存储依赖,当然track和trigger的逻辑也要相应的改变。
const bucketMap = new WeakMap()
let activeFuction
const track = (target, key) => {
if (!activeFuction) return
let targetMap = bucketMap.get(target)
if (!targetMap) {
bucketMap.set(target, new Map())
targetMap = bucketMap.get(target)
}
let deps = targetMap.get(key)
if (!deps) {
targetMap.set(key, new Set())
deps = targetMap.get(key)
}
deps.add(activeFuction)
}
const trigger = (target, key) => {
let targetMap = bucketMap.get(target)
if (!targetMap) return
let deps = targetMap.get(key)
if (!deps) return
deps.forEach(f => f());
}
const reactive = (data) => {
return new Proxy(data, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, value) {
target[key] = value
trigger(target, key)
return true
}
})
}
const effect = (fn) => {
activeFuction = fn
fn()
// activeFuction = null
}
const data = reactive({
a: 1,
b: 2,
c: 3
})
effect(() => {
console.log(data.a)
})
data.a++
data.b++
这样的话,执行data.b++就不会打印data.a了吗? 试了一下 还是会的,为啥呢???是因为activeFuction,我们没有及时将activeFuction置为null,导致data.b++执行的时候首先是触发data.b的get,此时在其track中判断activeFuction是存在的,则将其加入到了data.b的依赖,接下来触发了data.b的set,就把data.b的依赖全都拉出来执行了。所以将以上代码中的activeFuction = null注释打开就可以啦。
4. 分支切换与cleanup
首先我们来明确一下什么是“分支切换”
const data = reactive({
a: 1,
b: 2,
c: true
})
effect(() => {
console.log(data.c ? data.a : data.b)
})
data.b = 10
这段代码执行以后,是不会触发打印的,但把data.b = 10之前加上一个data.c = false呢,至少在我们的预期中,data.b = 10是应该触发打印的,但实际上没有,这种问题就需要分支切换来解决了。
分析一下这几行代码,当effect执行的时候,会把传入的fn赋给activeFunction,然后fn执行,访问data.c,将activeFunction加入到data.c的bucket中,然后访问data.a,将activeFunction加入到data.a的bucket中,最后activeFunction = null。之后再改变任何的值都不会再次操作activeFunction这个变量,所以访问任何属性时都不会再收集依赖了。
要解决这个问题就得让每一次执行依赖的时候都重新操作activeFunction变量
const effect = (fn) => {
const effectFn = () => {
activeFuction = effectFn
fn()
activeFuction = null
}
effectFn()
}
这样一来,加入bucket的其实是effectFn,执行依赖的时候首先会将activeFunction重新赋值然后再执行fn。
const data = reactive({
a: 1,
b: 2,
c: true
})
effect(() => {
console.log(data.c ? data.a : data.b)
})
data.c = false
data.a = 10
这种情况下,我们的预期是不需要,但实际上打印了,我们需要在本依赖运行之前 在其对应的bucket中删掉该依赖,依赖运行时重新收集就好了,清空bucket的步骤就是cleanup,根据这个思路,我们应该还需要建立一个依赖 -> bucket关系。
const effect = (fn) => {
const effectFn = () => {
effectFn.deps.forEach(bucket => bucket.delete(effectFn));
effectFn.deps = []
activeFuction = effectFn
fn()
activeFuction = null
}
effectFn.deps = []
effectFn()
}
Vue3中使用effectFn.deps来收集bucket,并在track的时候将bucketpush到effectFn.deps中来。
const track = (target, key) => {
if (!activeFuction) return
let targetMap = bucketMap.get(target)
if (!targetMap) {
bucketMap.set(target, new Map())
targetMap = bucketMap.get(target)
}
let deps = targetMap.get(key)
if (!deps) {
targetMap.set(key, new Set())
deps = targetMap.get(key)
}
deps.add(activeFuction)
activeFuction.deps.push(deps) // 新增
}
运行一下会发现程序出现了无限循环,其实是因为trigger中试图遍历bucket执行依赖A,真正的fn执行之前操作bucket将依赖删除,然后fn执行时访问了data的属性,又在track中往bucket加入了依赖A,这就导致trigger中的遍历永远都不会结束了。
const trigger = (target, key) => {
let targetMap = bucketMap.get(target)
if (!targetMap) return
let deps = targetMap.get(key)
if (!deps) return
new Set(deps).forEach(f => f());
}
我们需要在trigger中的new一个Set对象来遍历就可以解决这个问题了。
. 嵌套的effect
跑跑以下示例代码
const data = reactive({
a: 1,
b: 2,
c: true
})
effect(() => {
console.log('a:', data.a)
effect(() => {
console.log('b:', data.b)
})
console.log('c:', data.c)
})
data.a++
data.b++
data.c = false
data.a++会把a,b,c全部都打印出来,
data.b++只会把data.b打印出来,
data.c = false则不会打印任何的东西。
这结果显然是不符合预期的,其实解决这个问题的思路比较简单。就是把正在执行的fn全部推入一个栈中,activeFunction取栈顶fn,fn执行结束后将fn出栈,activeFunction继续取栈顶的fn,直到栈内无任何fn。
const effect = (fn) => {
const effectFn = () => {
effectFn.deps.forEach(bucket => bucket.delete(effectFn));
effectFn.deps = []
effectStack.push(effectFn)
activeFuction = effectFn
fn()
effectStack.pop()
activeFuction = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
6. 避免无限递归循环
运行以下代码
const data = reactive({
a: 1,
b: 2,
c: true
})
effect(() => {
data.a++
console.log('a:', data.a)
})

会产生这个问题,原因是fn中访问了data.a触发了get方法,把fn包了一层作为一个依赖收集到到了data.a对应的bucket中,然后又触发了data.a的set方法,于是又把bucket中的依赖拉出来执行,依赖执行的过程中又重复了以上的过程,最终导致栈溢出。其实这个问题的解决思路也不难想到,只需要在tigger方法遍历deps的时候加个activeFuction判断就可以了。
const trigger = (target, key) => {
let targetMap = bucketMap.get(target)
if (!targetMap) return
let deps = targetMap.get(key)
if (!deps) return
new Set(deps).forEach(f => {
if (f !== activeFuction) f()
});
}
7. 简易的响应式系统就此完成,完整代码如下。
const bucketMap = new WeakMap()
const effectStack = []
let activeFuction
const track = (target, key) => {
if (!activeFuction) return
let targetMap = bucketMap.get(target)
if (!targetMap) {
bucketMap.set(target, new Map())
targetMap = bucketMap.get(target)
}
let deps = targetMap.get(key)
if (!deps) {
targetMap.set(key, new Set())
deps = targetMap.get(key)
}
deps.add(activeFuction)
activeFuction.deps.push(deps) // 新增
}
const trigger = (target, key) => {
let targetMap = bucketMap.get(target)
if (!targetMap) return
let deps = targetMap.get(key)
if (!deps) return
new Set(deps).forEach(f => {
if (f !== activeFuction) f()
});
}
const reactive = (data) => {
return new Proxy(data, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, value) {
target[key] = value
trigger(target, key)
return true
}
})
}
const effect = (fn) => {
const effectFn = () => {
effectFn.deps.forEach(bucket => bucket.delete(effectFn));
effectFn.deps = []
effectStack.push(effectFn)
activeFuction = effectFn
fn()
effectStack.pop()
activeFuction = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}

文章探讨了副作用函数的概念,以及如何通过响应式数据来处理这种影响。在Vue2和Vue3中,数据劫持是实现响应式的关键,文章通过代码示例展示了如何使用Proxy进行数据追踪和更改。接着,作者构建了一个简易的响应式系统,包括bucketMap来存储依赖关系,解决了数据改变时不必要的函数执行问题,以及处理了分支切换、清理和避免无限递归循环的策略。最后,文章提供了完整的响应式系统实现代码。
1625





