手把手教你撸vue3的响应系统
为什么会有这篇文章?
最近在阅读vue.js的设计与实现
这本书,在读到响应系统的时候,觉得有必要来记录下,自己也可以动手写一写书中的例子来加深理解.虽然以前有差不多完整的实现,但是都是跟着别人一步一步敲的,相当于没有自己的一部分思考,这是很可怕的一件事情,这次融合自己的思考实现一下vue3的响应系统的基础版本,加深自己对这一块的理解.你如果跟着文章敲,最终你会得到一个和我一样的响应系统~
响应系统为我们带来了什么好处?
首先,我想谈一下自己对响应式的认知.这块与书中内容无关,纯属自己瞎BB,可以直接跳过不看.记得第一次听到这个词语觉得很新鲜,就去了解了以下,发现其实在数据
与操作
之间实现一层逻辑来自动的处理某些事情,所以响应性编程是建立在代理一类的思想基础上的.如其名,响应性是指在数据发生改变的时候,其他地方会有一些响应性动作发生.通过建立的中间层来自动的帮我们完成一些事情,这样我们就可以只关心数据这一块的东西,逻辑的执行完全交给中间层来处理.
下边是一个小小的例子来解释这个说法,在这个例子中,当我们每次修改对象属性的时候,我们都需要发送一次请求
const http = (val) => console.log('http', val)
const obj = {
a: 1
}
const KEY_PREFIX = '__'
// vue2时代
Object.defineProperty(obj, 'a', {
get() {
const key = KEY_PREFIX + 'a'
return obj[key]
},
set(value) {
// 如果这里不设置一个映射健,那么set操作就会导致是一个死循环
const key = KEY_PREFIX + 'a'
// 在这里发送一次请求
http(value)
obj[key] = value
}
})
obj.a = 2
obj.a = 3
// 使用Proxy的话,代码就更简单了
const obj3 = { a: 1 }
const proxy = new Proxy(obj3, {
get(target, key) {
const result = Reflect.get(target, key)
return result
},
set(target, key, value, receiver) {
if (target[key] === value) {
return true
}
const result = Reflect.set(target, key, value, receiver)
http(value)
return result
}
})
proxy.a = 6
// 如果没有代理层的逻辑,我们完成本次需求的样子是这样的
const obj2 = { a: 1 }
obj2.a = 2
http(obj2.a)
obj2.a = 3
http(obj2.a)
关于defineProperty
和Proxy
的使用这里就不解释了,如果需要了解可以去mdn看一下: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
可以明显感受到,使用代理的思想处理后,我们每次重新给对象的属性赋值,都会自动的触发我们定义的逻辑,在这个例子中,他会自动的发送一个请求,如果没有这一层的处理,那么发送请求的逻辑我们必须手动来实现,并且这些逻辑可能会散落到各个角落里,这就是响应性编程为我们带来的最直接也是最有用的好处!相信通过这个例子你已经切身体会到代理层的好处,然而我们这里只是简单到不能再简单的一个用例,离真正的响应系统还相差甚远,更别说是vue3所设计的响应系统了.下面我们先来简单实现一个响应性系统应该具备的基础要素.
一个mini版本的响应系统实现
在实现之前,我们先思考一下我们可能会遇到的困难点
- 如何将一个普通数据转成proxy这样的代理对象
- 如何在代理对象的get里收集依赖,依赖是指对象属性所关联的副作用函数,[属性]和[副作用函数]是一个多对多的关系
- 如何在代理对象的set里触发收集的依赖
解决这三个问题也很简单
- 第一个问题我们可以定义一个函数将数据传递进去然后返回它的代理对象
- 第二个问题相对来说比较需要思考,我们需要有一个数据结构来存储我们收集的依赖;我们需要留意两个地方:不同对象之间依赖关系是相互独立的,同一个对象下不同key是所对应的依赖也是相互独立的,基于这两点我们就可以得到一个完整描述依赖关系的数据结构:
// 依赖关系结构
new WeakMap([
['obj1', new Map([
'key1', new Set([fn, fn]),
'key2', new Set([fn])
])],
])
这里使用WeakMap是为了更好的垃圾回收,这种数据类型下的key会被垃圾回收机制忽略引用 接下来需要解决的问题是,我们又该如何收集副作用函数呢,我们需要有一个入口将你的副作用函数传递进来,然后将该副作用函数执行以触发代理对象的get钩子,但是我们在get钩子里如何拿到这个副作用函数呢,答案是在入口函数的地方将该这个副作用函数保存到某个地方,得益于js的单线程特性,一轮下来属性和副作用函数是能对应上的
- 第三个问题就相对容易了,我们只需要找到对应依赖的副作用函数然后依次执行即可
好了,路已经铺好,开始干活吧
// 当前的effect函数
let activeEffect = null
// 依赖关系
const targetMap = new WeakMap()
// 将普通数据转换为代理对象
const toProxy = (val) => {
// 我们最终的目的就是返回一个代理对象
return new Proxy(val, {
// get里主要的工作就是收集依赖以及将对应值返回
get(target, key, receiver) {
// 收集依赖
track(target, key)
// 对象的正常操作
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
// 这里是为了防止某些情况下的重复执行,下面有来自gpt的解释
if (target[key] === value) {
return true
}
const result = Reflect.set(target, key, value, receiver)
// 触发副作用函数
trigger(target, key)
return result
}
})
}
// 追踪函数
const track = (target, key) => {
let depMap
if (targetMap.has(target)) {
depMap = targetMap.get(target)
} else {
targetMap.set(target, (depMap = new Map()))
}
// 将依赖收集到对应的key下
if (depMap.has(key)) {
depMap.get(key).add(activeEffect)
} else {
depMap.set(key, new Set([activeEffect]))
}
}
// 触发函数
const trigger = (target, key) => {
// 逻辑很简单,通过路径拿到属于自己的副作用函数集合,然后执行
targetMap.get(target)?.get(key)?.forEach(effect => {
effect()
});
}
// 接下来是入口函数
const effect = (fn) => {
activeEffect = fn
fn()
}
const state = toProxy({ a: 1 })
effect(()=>{
console.log(state.a)
})
state.a=2
state.a=3
state.a=4
state.a=5
关于Proxy的set钩子重复执行(来自gpt的解释)
Proxy 的 set 钩子一般会在属性被设置时执行,但是有些情况下可能会执行两次。以下是可能导致 set 钩子执行两次的情况:
当前属性的值已经是新值时,set 钩子也会被调用。这是因为在设置属性时,JavaScript 引擎会先检查当前值和新值是否相等,如果不相等才会执行 set 钩子。但是,如果当前值已经等于新值,由于引擎无法确定是否需要执行 set 钩子,因此会执行两次。
使用 Object.assign() 方法合并对象时,如果合并的对象中包含 Proxy 对象,那么 set 钩子也会被调用两次。这是因为 Object.assign() 方法在合并对象时会先拷贝属性值,然后再调用 set 钩子,导致 set 钩子被调用两次。
解决这个问题的方法是在 set 钩子中添加判断,如果当前值已经等于新值,则不执行后续操作。例如:
const obj = new Proxy({}, {
set(target, key, value, receiver) {
if (target[key] === value) { // 如果当前值已经等于新值,则不执行后续操作
return true;
}
// 执行后续操作
return Reflect.set(target, key, value, receiver);
}
});
上边我们已经实现了一个基本的响应系统,但其实还存在诸多问题:
- 如果目标数据是基础类型该怎么办呢?,我们都知道vue中是专门实现了一个函数
ref
来处理这种情况,但是我们这里先简单一点处理,通过将基础类型包裹成引用类型来实现 - effect函数里代码行进路径如果发生改变,那么就会出现遗留的副作用问题,因为如果在某些情况下代码的执行分支不会再访问某个属性,理想的情况下,当我们在修改这个属性的时候,就不应该再触发这个函数了!我们前边提到过属性和副作用函数是多对多的关系,一个属性可以对应多个副作用函数,同样的,一个副作用函数也可以被多个属性依赖,我们要做的也很简单,就是在每次副作用函数执行之前找到对应的属性,然后将该属性下的该副作用函数清理掉,然后执行副作用函数又会重新收集依赖,这样就可以实现依赖图谱的重新形成了,依赖只会包含最新的代码行进分支.那么新的问题出现了,我们应该怎么通过该副作用函数来找到对应的依赖合集呢,我们可以将传递进来的函数包裹一层,在其身上声明一个表示依赖合集的属性,用来存储副作用函数所对应的包含该副作用函数的依赖集合,这样我们在每次执行前都可以先将自身从集合中删除掉,然后去重新收集依赖,这样不同代码执行分支所遗留的问题也就得到了解决了.
- 如果你有定义多个响应数据,那么你会发现你在修改前边定义的数据时,最后定义的数据所关联的副作用函数会被错误执行,这是由于activeEffect没有被重新重置,在副作用函数执行时导致依赖被错误收集,我们有两种解决方案:
- 第一种:在每次effect里完成收集后,都将activeEffect置空,然后在依赖收集处需要判断是否存在activeEffect,不存在就不进行收集,这样就可以解决了
- 第二种:我们可以想办法在每次副作用函数执行时,将activeEffect函数重新指向自身所依赖的副作用函数,这样就不会再出现错误的收集
我们来解决第三个问题,我们可以将代码改造成下边两种
第一种方案的改造
const effect = (fn) => {
activeEffect = effectFn
fn()
// +
activeEffect = null
}
// 追踪函数
const track = (target, key) => {
// + 如果没有副作用函数,那么就不需要收集依赖
if (!activeEffect) return
let depMap
if (targetMap.has(target)) {
depMap = targetMap.get(target)
} else {
targetMap.set(target, (depMap = new Map()))
}
// 将依赖收集到对应的key下
if (depMap.has(key)) {
depMap.get(key).add(activeEffect)
} else {
depMap.set(key, new Set([activeEffect]))
}
}
可以看到这种方式是简单且粗暴的,几乎没有任何可拓展性,所以我们肯定会优先选择第二种方案,端上来吧
第二种方案的改造
// 接下来是入口函数
const effect = (fn) => {
const effectFn = () => {
activeEffect = effectFn
fn()
}
effectFn()
}
我们改造了effect入口函数,使用函数包裹一层,然后将这个函数作为副作用函数,这里的最重要的是将activeEffect = effectFn
这段逻辑包含了进去,这使得以后每次执行副作用时activeEffect都会被赋值为自身依赖的函数,即使再次收集也不出现错误收集了,可以明显感受到,这种方式只需要改动一处地方,而且更具拓展性,毕竟是函数,那就有无限可能~
如果接下来是定义多个响应数据,也不会出现串台现象了~
然后我们来解决第二个问题
根据上边提到的,我们需要将effect
函数改造一下,在改造后,他应该是下边这个样子
const effect = (fn) => {
const effectFn = () => {
activeEffect = effectFn
// 清理
clean(effectFn)
fn()
}
// 在该函数身上声明一个属性,用来储存该函数所对应的依赖set集合
effectFn.deps = []
effectFn()
}
// 清理函数的逻辑也很简单,在将自身从依赖集合中删除后,将deps整个置空即可
const clean = (effectFn) => {
const deps = effectFn.deps || []
deps.forEach(dep => {
dep.delete(effectFn)
})
effectFn.deps.length = 0
}
可以看到,我们在在函数上声明了一个属性,它的作用是用来存储该副作用函数所对应的依赖集合,将来我们需要在每次执行副作用函数前从所有集合中删除自身,然后再次执行该副作用函数后,依赖又会被重新收集起来,等于依赖集合根据环境进行了一次更新.那么,我们应该在何时进行该函数所对应的依赖集合呢?那我们其实可以追踪的地方进行这个操作,那么我们来将追踪函数改造一下
// 追踪函数
const track = (target, key) => {
let depMap
if (targetMap.has(target)) {
depMap = targetMap.get(target)
} else {
targetMap.set(target, (depMap = new Map()))
}
let dep
// 将依赖收集到对应的key下
if (depMap.has(key)) {
dep = depMap.get(key)
dep.add(activeEffect)
} else {
depMap.set(key, (dep = new Set([activeEffect])))
}
// 在这里将依赖集合给到副作用函数deps属性上
activeEffect.deps.push(dep)
}
这样我们的改造也完成了,但是你会发现代码会无线循环,这是由于set在遍历时,如果当前遍历时将某个元素删除掉,而随后又添加了上去,那么循环会重新访问这个元素,我们这里就是这种情况,我们在触发函数里执行了effectFn函数,清理掉了自身,随后又被收集了进来,满足了无限访问这个元素的条件,所以造成了死循环,我们可以使用一个新的set集合来进行循环,不是同一个set集合,那么就不会造成这个问题了,来改造一下trigger吧
const trigger = (target, key) => {
new Set(targetMap.get(target)?.get(key) || []).forEach(effect => {
effect()
});
}
接下来,你可以使用下边的代码进行测试,你可以发现每次代码分支的改变都会导致不同的输出结果
const state = toProxy({
a: 1,
b: true
})
const state2 = toProxy({
a: 1,
b: true
})
effect(() => {
console.log(state.a, 'state')
})
effect(() => {
console.log('reCall', 'state2')
state.b && console.log(state2.a, 'state2')
})
setInterval(() => {
state.a = Date.now()
state2.a = Date.now()
state.b = !state.b
}, 1000)
然后是第一个问题,我们来使用自己的方式解决原始值问题
重点当然是直接改造toProxy函数了啦
const toProxy = (val) => {
let targetVal = val
const isReference = isObject(val)
if (!isReference) {
targetVal = { value: val }
}
// 我们最终的目的就是返回一个代理对象
return new Proxy(targetVal, {
// get里主要的工作就是收集依赖以及将对应值返回
get(target, key, receiver) {
if (!isReference && key !== 'value') {
throw new Error('请使用.value获取值')
}
// 收集依赖
track(target, key)
// 对象的正常操作
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
if (!isReference && key !== 'value') {
throw new Error('请使用.value设置值')
}
// 这里是为了防止某些情况下的重复执行,下面有来自gpt的解释
if (target[key] === value) {
return true
}
const result = Reflect.set(target, key, value, receiver)
// 触发副作用函数
trigger(target, key)
return result
}
})
}
我们在返回代理对象前进行了一些判断,然后强制用户使用我们特定的key去访问原始值,这样就实现了~
然后是一些附加功能
为了让我们的响应系统更加强大,我们需要拓展一些功能
可嵌套的effect
很明显,我们现在所设计的响应系统是无法支持嵌套的,但是这种需求是很常见的,比如在vue中,组件一层套一层,而他们的render函数不就是一个副作用函数吗?那么我们现在来将其改造成可支持嵌套的
如果你熟悉栈这个数据结构,那么你肯定能很快想象到我们需要使用这个数据结构,我们的函数调用栈不也是类似的结构吗;在前边,我们只有一个activeEffect
来支撑我们的逻辑,但是如果需要支持嵌套,我们肯定还需要一个栈来存储因为effect嵌套所压进来的副作用函数.然后我们再执行副作用函数时将函数压栈,执行完毕就出栈,那么副作用函数就能一一对应,不会再出现错乱的问题了
首先是栈结构
// 支撑嵌套逻辑的栈,如果用数组来模拟,那么你应该只去调用pop,以及push这两个api
const effectFnStack = []
然后就是effect函数了!
const effect = (fn) => {
const effectFn = () => {
activeEffect = effectFn
// 清理
clean(effectFn)
// 在函数执行前,将函数推入栈中
effectFnStack.push(effectFn)
fn()
// 执行完毕后,将函数推出栈
effectFnStack.pop()
// 将栈顶的函数赋值给activeEffect
activeEffect = effectFnStack[effectFnStack.length - 1]
}
// 在该函数身上声明一个属性,用来储存该函数所对应的依赖set
effectFn.deps = []
effectFn()
}
然后我们来测试一下
// 测试嵌套effect函数
const state = toProxy({
a: 1
})
const state2 = toProxy({
a: 1
})
effect(() => {
console.log(state.a)
effect(() => {
console.log(state2.a)
})
})
state.a = 444
state2.a = 555
可以发现,我们的目的达到了,当我们修改最外层的state的属性时,联动内部的响应数据的副作用函数也执行了.而我们只修改内部的响应数据时,外层的响应数据所对应的副作用函数不会被执行.但是你会发现,内部的函数会被执行两次,这是由于我们在effect内每次收集的都是一个全新的函数,即使你的函数内容完全一样,还是会新建一个函数,这样就导致Set集合的特性也无法帮我们规避重复函数了,我们应该怎么解决这个问题呢?其实也简单,我们可以将fn挂在到我们新建的函数上,然后每次去检查对应的依赖函数中是否有同样的fn存在,如果存在,那我们就不再进行收集,所以我们需要改动两个地方
const effect = (fn) => {
const effectFn = () => {
activeEffect = effectFn
// 清理
clean(effectFn)
// 在函数执行前,将函数推入栈中
effectFnStack.push(effectFn)
fn()
// 执行完毕后,将函数推出栈
effectFnStack.pop()
// 将栈顶的函数赋值给activeEffect
activeEffect = effectFnStack.at(-1)
}
// 在该函数身上声明一个属性,用来储存该函数所对应的依赖set
effectFn.deps = []
// 将外部函数挂载到effectFn上
effectFn.fn = fn
effectFn()
}
我们将fn挂载到了effectFn上,方便后续收集的时候,进行排除
// 追踪函数
const track = (target, key) => {
let depMap
if (targetMap.has(target)) {
depMap = targetMap.get(target)
} else {
targetMap.set(target, (depMap = new Map()))
}
let dep
// 将依赖收集到对应的key下
if (depMap.has(key)) {
dep = depMap.get(key);
// + 这里收集的时候,我们就需要来判断是否是否存在同样的fn了
([...dep]).every((f) => f.fn !== activeEffect.fn) && dep.add(activeEffect)
} else {
depMap.set(key, (dep = new Set([activeEffect])))
}
// 在这里将依赖集合给到副作用函数deps属性上
activeEffect.deps.push(dep)
}
这里我们在收集函数的时候进行了一个前置比较,如果依赖中已经有包含该外部fn的effectFn,那么我们就不再进行收集
好的,接下来进行测试
// 测试嵌套effect函数
const state = toProxy({
a: 1
})
const state2 = toProxy({
a: 1
})
const fn1 = () => {
console.log(state.a)
}
const fn2 = () => {
console.log(state2.a)
}
effect(() => {
fn1()
effect(fn2)
})
state.a = 444
state2.a = 555
你会发现,我们的目的达到了!你可能还发现了,噫?我们前边的测试代码不是这个样子的呀,这是因为每次全新匿名函数是无法进行比对相等的,所以我进行了一定的改造,其实这样也很合理,你想,一个vue组件的render函数初始化后也不会改变其引用地址~如果你非就要使用前边那样的方式,我暂时还没有想到更好的办法,这本书中也没有提及这个问题,如果你有更好的方法可以联系我.
避免其无限执行
试想一下,如果我们在副作用函数中进行这样的操作
obj.num=obj.num+1
看似很正常的一个操作,但是他会带来无限循环,我们来理一下它的执行流程:
- 首先=右边我们读取了obj.num,那么就会导致这个函数被当成依赖进行收集
- 然后在读取之后,我们进行了+1,最后将其赋值给了obj.num,那么因为此时依赖已经被进行了收集,所以会直接触发这个函数
- 再次运行这个函数,流程再次重复,所以这个操作会导致无限循环执行,不意外了吧
我们只需要改造一个地方即可解决这个问题,我们在副作用函数执行的地方来做下边的改动:
const trigger = (target, key) => {
new Set(targetMap.get(target)?.get(key) || []).forEach(effect => {
effect !== activeEffect && effect()
});
}
我们在执行的地方进行了判断,如果这个函数和当前的副作用函数相等,那么就不执行,这样就切断了无限循环执行的路线.,等于是说如果你在副作用函数内更改数据,该数据依赖的副作用函数不会执行,如果你希望副作用函数执行,更改数据的地方不应该是在副作用函数内.
调度执行
有时候,我们希望我们能让用户自己来决定副作用执行的时机,将副作用函数的执行权交到用户手里,那么我们应该怎么做呢?我们可以在effect函数的地方增加一些参数,这个参数是一些可以让用户自己配置的选项,该参数是一个对象,如果后续需要,也更方便我们来拓展其它功能.
const effect = (fn, options) => {
const effectFn = () => {
activeEffect = effectFn
clean(effectFn)
effectFnStack.push(effectFn)
fn()
effectFnStack.pop()
activeEffect = effectFnStack.at(-1)
}
effectFn.deps = []
effectFn.fn = fn
// +
effectFn.options = options
effectFn()
}
然后我们就需要在触发副作用函数的地方做更改了
const trigger = (target, key) => {
new Set(targetMap.get(target)?.get(key) || []).forEach(effect => {
if (effect.options.scheduler) {
// 我们此时将副作用函数传递给用户自定义的调度器,让用户自己去决定是否执行
effect.options.scheduler(effect)
} else {
effect !== activeEffect && effect()
}
});
}
然后我们的功能就实现了,接下来测试一下
const scheduler = (fn) => {
console.log('scheduler');
fn()
console.log('scheduler end');
}
const state = toProxy({
a: 1
})
effect(() => {
console.log('effect');
console.log(state.a);
}, {
scheduler
})
state.a = 2
你会发现功能如我们所期望的实现了~
但是这个调度器的使用场景在哪里呢?那我们来一些有意义的实战解释吧,相信大家都知道我们在vue组件内更改多次更改数据后,vue并不会每次都进行试图更新,而是在你同步代码所有数据更改完毕后一次性更新视图,依此来避免了视图务无必要的频繁更新,那么vue是怎么做到的呢?答案是利用异步特性和我们自定义调度器,vue将视图更新的操作放到了异步队列,他会根据你的环境来选择最合适的异步任务类型,而组件的render就是充分利用了响应系统的可调度性来实现的,我们下边来演示一下这个东西
我们肯定首先要来实现一个符合我们需求的调度器
// 任务缓存队列,vue官方使用的是一个数组,我们使用set是为了利用其不重复的特性
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例,用来在微任务中刷新任务队列,vue实际上会根据环境来选择使用哪种方式
const p = Promise.resolve()
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job)
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach(job => {
job()
})
} finally {
// 重置状态
isFlushing = false
queue.clear()
}
})
}
}
上边就实现了一个简易的调度器,它的作用就和vue组件的更新机制一样,将函数放置在一个微任务里来执行,这样即使我们在同步代码内频繁的往调度器里传递副作用函数,那么由于这里调度器Set+异步的特性那么其实副作用函数也只会触发一次!
测似一下吧~
const render = () => {
console.log('render');
}
new Array(50).fill().forEach(() => {
queueJob(render)
})
我们一次性往调度器内传入了50个render函数,但是函数只执行了一次,完美达成效果.有了这个调度器,那我们模拟vue的更新机制岂不是易如反掌?好,完整模拟一次
// 你定义的响应数据
const state = toProxy({
a: 1
})
// 模拟vue3组件的更新函数
const update = () => {
console.log(state.a,'update');
}
effect(() => {
update()
}, {
// 自定义调度器
scheduler: queueJob
})
// 我们这里连续更改了3次数据
state.a = 2
state.a = 3
state.a = 4
你会发现update函数一共执行了两次,第一次是effect自动执行一次,随后我们连续更改了三次响应数据,但是update函数并没有执行三次,而是执行了一次!我们的目的达到了~
懒执行
你可以发现,我们现在的effect函数是没有懒执行的能力的,我们传入的函数被立即执行了,你可以先停下来想一想,有没有办法延迟到一定时候再来执行呢?
好了,直接公布答案,我们可以通过一个属性来判断是否立即执行,如果是需要懒执行,我们就不立即执行,而是把这个函数返回,让外部自己去确定执行时机(注意,这个时候依赖是没有进行收集的哟~)
好,我们接下来进行改造effect函数:
const effect = (fn, options) => {
const effectFn = () => {
activeEffect = effectFn
clean(effectFn)
effectFnStack.push(effectFn)
fn()
effectFnStack.pop()
activeEffect = effectFnStack.at(-1)
}
effectFn.deps = []
effectFn.fn = fn
effectFn.options = options
// 直接看这里,改造的是在这里哟~
if (!options.lazy) {
effectFn()
}
return effectFn
}
const state = toProxy({
a: 1
})
const fn = effect(() => {
console.log(state.a, 'state.a');
}, {
lazy: true
})
debugger
fn()
尝试上边的代码,你会发现如果我们没有手动执行,那么这个副作用函数永远不会执行,接着,如果我们在这个函数里返回真正副作用函数的执行结果,那我们岂不是可以实现计算属性?是的!那我们继续改造吧~
const effect = (fn, options) => {
const effectFn = () => {
activeEffect = effectFn
clean(effectFn)
effectFnStack.push(effectFn)
// 1
const res = fn()
effectFnStack.pop()
activeEffect = effectFnStack.at(-1)
// 2
return res
}
effectFn.deps = []
effectFn.fn = fn
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn
}
可以看到在上边我们用res
保存了真正的副作用函数执行后的结果,在最后将其返回~那么接下来,我们就可以实现一个懒执行的computed
的api了!
// 计算属性api
function computed(getter) {
const effectFn = effect(getter, { lazy: true })
return {
get value() {
return effectFn()
}
}
}
是不是比你想象的简单?计算属性返回一个对象,如果没有去读取value这个属性,那么getter函数就永远不执行,避免性能浪费!都进行到这里了,我们继续实现一下计算属性的缓存功能吧,很简单,上代码!
function computed(getter) {
let value, dirty = true
const scheduler = () => {
dirty = true
}
const effectFn = effect(getter, { lazy: true, scheduler })
return {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
}
解释下我们干了什么:我们首先新声明了两个变量,value用来缓存值,dirty表示是否是脏值,如果是就需要重新执行副作用函数了,在进行了第一次计算后,dirty变为了false,这导致了我们永久不再进行计算新值的问题,为了解决这个问题,我们使用了调度器来将dirty重新设置为了true(注意我们这里并不需要接受或者执行调度器传递进来的副作用函数,因为我们这里的计算属性在读取value的时候才需要去执行).哈哈,就这样,我们就实现了一个懒执行并且还具有缓存功能的计算属性api了!
别着急,还有最后一个小问题,如果你尝试在effect中使用计算属性,你会发现即使数据更改了,effect函数也没有执行,这是因为啥啊?我们来分析一下,感觉这个问题有点类似我们前边提到的effect嵌套问题呢,我们不是已经解决了吗,怎么还是会出现这么奇怪的问题?这是因为我们计算属性返回的对象并没有使用响应函数去包裹,那么意味着这个对象本身是不会走我们依赖收集和触发那一套逻辑的,那咋办呢?那我们就自己来收集和触发,这样也可以形成依赖关系.开工~
function computed(getter) {
let value, dirty = true
const scheduler = () => {
dirty = true
// 2
trigger(obj, 'value')
}
const effectFn = effect(getter, { lazy: true, scheduler })
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 1
track(obj, 'value')
return value
}
}
return obj
}
wow~简直完美~
watch函数
既然我们都实现了计算属性api,那咋可能不来实现一下他的兄弟apiwatch
函数呢?你其实可能已经发现,我们所实现的effect函数不就像一个watch吗?其实并不是,我们在使用watch这个api时,是可以明确指定依赖项的,而我们的effect函数,只要内部相数据发生改变就会触发,但是watch只有在依赖项发生变化时才会去执行,我们的effct函数更像时watchEffect
这个api
我们先上一个简单的版本
function watch(obj, cb) {
effect(()=>obj.x, {
scheduler: () => {
cb()
}
})
}
上面的代码就实现了一个简单版本的watch了,我们利用了调度系统来执行我们的回调函数,当我们去更改obj.x的值时就会执行回调.但是这个api也太简陋了吧,不仅没有新值旧值的参数,而且也没有立即执行以及深度监听的方法,别着急,一步一步慢慢来~
如果观测的是一整个对象,而不是某一个属性,我们应该怎么做?
我们希望修改这个对象下的任意属性都可以触发回调.对于这个问题,很简单,那就是将对象进行递归,访问所有后代属性,让其所有后代属性全部关联上我们的回调即可,我们来改造一下吧
function watch(obj, cb) {
effect(traverse(obj), {
scheduler: () => {
cb()
}
})
}
// 这个函数主要用来访问一个对象下的所有后代属性
function traverse(value, seen = new Set) {
if (!isObject(value) || seen.has(value)) {
return
}
// 这里是为了防止循环引用
seen.add(value)
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else {
for (const key in value) {
traverse(value[key], seen)
}
}
}
可以看到,我们多出来了一个traverse函数,我们使用它来递归的访问属性,以达到触发依赖收集的目的
另外,我还有一个地方进行了调整,在effect函数中,我发现如果副作用函数堆栈中已经没有函数了,那就不需要更改当前副作用函数的指向了所以我做了以下调整,注意有注释的行:
const effect = (fn, options={}) => {
const effectFn = () => {
activeEffect = effectFn
clean(effectFn)
effectFnStack.push(effectFn)
const res = fn()
effectFnStack.pop()
// 如果栈中没有函数了,就不需要再改变activeEffect了
effectFnStack.length && (activeEffect = effectFnStack.at(-1))
return res
}
effectFn.deps = []
effectFn.fn = fn
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn
}
让watch支持自定义的getter函数
我们都知道在watch中可以选择传入一个响应式对象或者自定义一个getter,我们已经实现了前者,接下来我们来实现后者.如果你传入的是一个函数那么这个函数就可以当成是一个getter,只有getter函数内使用的数据发现变化,才会触发回调,这就是大致思路,接下来是实现:
我们将watch改造成了下边的样子,主要是更改了effect函数传入的地方
function watch(obj, cb) {
const isGetter = isFunction(obj)
effect(
isGetter ? obj : () => traverse(obj),
{
scheduler: () => {
cb()
}
})
}
新值与旧值
你可能发现我们还缺少一个重要功能,那就是在回调中的新值与旧值的参数我们还没有实现,要实现这个功能我们需要充分使用使用前边我们实现的懒执行的功能,其实与计算属性那块儿的逻辑是差不多的,我们需要更改的地方有两个,一个是watch函数(让其支持懒执行,并获得新值与旧值的交替),一个是深度访问函数traverse(主要是需要一个返回值),接下里实现一下吧:
// 让其始终具有一个返回值
function traverse(value, seen = new Set) {
if (!isObject(value) || seen.has(value)) {
// 返回值
return value
}
seen.add(value)
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else {
for (const key in value) {
traverse(value[key], seen)
}
}
// 返回值
return value
}
// 充分利用effect函数的懒执行
function watch(obj, cb) {
let newVal, oldVal
const isGetter = isFunction(obj)
const effectFn = effect(
isGetter ? obj : () => traverse(obj),
{
lazy: true,
scheduler: () => {
newVal = effectFn()
cb(newVal, oldVal)
// 更新旧值
oldVal = newVal
}
})
// 获取到初始值
oldVal = effectFn()
}
立即执行与调度执行
我们在使用watch的时候还可以指定两个经常用的参数,flush和immediate,flush用来指定回调执行的时机,而immediate是一个boolean值,用来表示是否回调会立即执行,而本次执行不会关心数据是否发生了变化
flush的可选值:
- pre 默认行为,你的回调会在vue进行更新前被执行
- sync 数据发生变化后立即被执行,可能伴随性能问题
- post 在vue进行更新完成后执行你的回调,这个时候在你的回调里能获取到更新完成后的dom结构
immediate的可选值: true|false,默认值false
我们要完成这个功能,需要继续改造我们的watch函数,为它增加第三个参数来完成
function watch(obj, cb, options={}) {
let newVal, oldVal
const { flush = 'pre', immediate } = options
// 我们将回调进行了包装,封装了我们的新值和旧值逻辑
const job = () => {
newVal = effectFn()
cb(newVal, oldVal)
oldVal = newVal
}
// 我们还需要根据不同选项来使用不同的调度器执行我们的回调
const jobFlushMap = {
// vue的更新周期钩子有关系,我们就不实现了,意思一下得了
pre: () => job,
// 这里直接使用我们前边的queueJob来模拟
post: () => queueJob(job),
// 直接执行
sync: job
}
const isGetter = isFunction(obj)
const effectFn = effect(
isGetter ? obj : () => traverse(obj),
{
lazy: true,
// 根据选项去挑选对应的调度函数,如果你传入了其他意外值那么就使用默认行为
scheduler: jobFlushMap[flush] || jobFlushMap['pre']
})
// 是否是立即执行
if (immediate) {
job()
} else {
// 获取到初始值
oldVal = effectFn()
}
}
就这样我们就完成了,更改的地方都伴随有注释,相信你可以看懂;这里会有一个问题是,我们使用立即执行功能,会发现 oldVal是一个undifined,初看觉得是不应该的,但是其实这个逻辑是对的,因为第一次执行根本不存在有旧值的说法
因为flsuh的pre选项会涉及到vue的运行时相关的逻辑,我们暂时是无法模拟的,感兴趣可以去看一下源码,搜一下updateComponent
这个函数,你会看到在组件更新进行patch之前会调用updateComponentPreRender
,里边就是组件更新前执行的逻辑,其中就包含刷新执行我们使用pre选项的watch回调函数
书中还提到了一个有意思的东西:过期的副作用函数.你可以思考这么一个场景,你有一个响应式数据,然后你先后触发了回调函数,但是这个回调里比较特殊的情况是有一个异步行为,你在使用的过程中发现有时候得不到期望的运行结果,最后发现原来是异步行为的原因,由于你后触发的回调的异步执行有时候会慢于前一次,所以导致回调里的行为被前一次覆盖掉了.我们还是使用我们最熟悉的代码来解释一下这个行为:
const state = toProxy({
a: 1,
})
let res = 1
let i=0
watch(state, () => {
i++
setTimeout(() => {
res = i
},
// 模拟第二次慢一些的情况
i===2?2000:3000)
})
state.a = 2
setTimeout(() => {
state.a = 3
}, 100)
在上边的示例中,我们其实希望res最后的结果是2,但是其结果最终却是1,这就是因为第二次异步行为耗时慢了一些的原因.
我们可以给watch的回调函数第三个参数,他是一个函数,专门用来注册让副作用函数失效的方法,然后让其他地方的回调函数去执行这些方法,让前边的回调变为失效的回调,那我们的目的就达到了,好,我们来改造一下
function watch(obj, cb, options = {}) {
let newVal, oldVal
const { flush = 'pre', immediate = false } = options
const cleanups = new Set()
// 注册清理副作用函数
const onInvalidate = (fn) => {
cleanups.add(fn)
}
const job = () => {
newVal = effectFn()
// 每次执行回调之前,先执行清理函数,避免执行过期的副作用函数
cleanups.forEach(cleanup => cleanup())
// 把onInvalidate函数传递给回调函数
cb(newVal, oldVal, onInvalidate)
oldVal = newVal
}
const isGetter = isFunction(obj)
const jobFlushMap = {
pre: () => job(),
post: () => queueJob(job),
sync: job
}
const effectFn = effect(
isGetter ? obj : () => traverse(obj),
{
lazy: true,
scheduler: jobFlushMap[flush] || job
})
if (immediate) {
job()
} else {
oldVal = effectFn()
}
}
然后上测试代码
const state = toProxy({
a: 1,
})
let res = 1
let i = 0
watch(state, (n, o, onInvalidate) => {
i++
let expire = false
onInvalidate(() => {
expire = true
})
setTimeout(() => {
if (!expire) return
res = i
},
// 模拟第二次慢一些的情况
i === 2 ? 1000 : 2000)
})
state.a = 2
setTimeout(() => {
state.a = 3
}, 100)
setTimeout(() => {
console.log(res);//2
}, 3000)
发现一切都正常了,简直完美~
最后贴上所有完整代码
const isObject = (val) => typeof val === 'object' && val !== null
const isFunction = (val) => typeof val === 'function'
// 任务缓存队列,vue官方使用的是一个数组,我们使用set是为了利用其不重复的特性
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例,用来在微任务中刷新任务队列,vue实际上会根据环境来选择使用哪种方式
const p = Promise.resolve()
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job)
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach(job => {
job()
})
} finally {
// 重置状态
isFlushing = false
queue.clear()
}
})
}
}
// 当前的effect函数
let activeEffect = null
// 支撑嵌套逻辑的栈,用数组来模拟,那么你应该只去调用pop,以及push
const effectFnStack = []
// 依赖关系
const targetMap = new WeakMap()
// 将普通数据转换为代理对象
const toProxy = (val) => {
let targetVal = val
const isReference = typeof val === 'object'
if (!isReference) {
targetVal = { value: val }
}
// 我们最终的目的就是返回一个代理对象
return new Proxy(targetVal, {
// get里主要的工作就是收集依赖以及将对应值返回
get(target, key, receiver) {
if (!isReference && key !== 'value') {
throw new Error('请使用.value获取值')
}
// 收集依赖
track(target, key)
// 对象的正常操作
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
if (!isReference && key !== 'value') {
throw new Error('请使用.value设置值')
}
// 这里是为了防止某些情况下的重复执行,下面有来自gpt的解释
if (target[key] === value) {
return true
}
const result = Reflect.set(target, key, value, receiver)
// 触发副作用函数
trigger(target, key)
return result
}
})
}
// 追踪函数
const track = (target, key) => {
let depMap
if (targetMap.has(target)) {
depMap = targetMap.get(target)
} else {
targetMap.set(target, (depMap = new Map()))
}
let dep
// 将依赖收集到对应的key下
if (depMap.has(key)) {
dep = depMap.get(key);
// + 这里收集的时候,我们就需要来判断是否是否存在同样的fn了
([...dep]).every((f) => f.fn !== activeEffect.fn) && dep.add(activeEffect)
} else {
depMap.set(key, (dep = new Set([activeEffect])))
}
// 在这里将依赖集合给到副作用函数deps属性上
activeEffect.deps.push(dep)
}
// 触发函数
const trigger = (target, key) => {
// 逻辑很简单,通过路径拿到属于自己的副作用函数集合,然后执行
new Set(targetMap.get(target)?.get(key) || []).forEach(effect => {
if (effect.options.scheduler) {
// 我们此时将副作用函数传递给用户自定义的调度器,让用户自己去决定是否执行
effect.options.scheduler(effect)
} else {
effect !== activeEffect && effect()
}
});
}
// 最最核心的函数!
const effect = (fn, options = {}) => {
const effectFn = () => {
activeEffect = effectFn
// 清理
clean(effectFn)
// 在函数执行前,将函数推入栈中
effectFnStack.push(effectFn)
const res = fn()
// 执行完毕后,将函数推出栈
effectFnStack.pop()
// 将栈顶的函数赋值给activeEffect,如果栈中没有函数了,就不需要再改变activeEffect了
effectFnStack.length && (activeEffect = effectFnStack.at(-1))
return res
}
// 在该函数身上声明一个属性,用来储存该函数所对应的依赖set
effectFn.deps = []
effectFn.fn = fn
effectFn.options = options
if (!options.lazy) {
effectFn()
}
return effectFn
}
const clean = (effectFn) => {
const deps = effectFn.deps || []
deps.forEach(dep => {
dep.delete(effectFn)
})
effectFn.deps.length = 0
}
// 计算属性api
function computed(getter) {
let value, dirty = true
const scheduler = () => {
dirty = true
trigger(obj, 'value')
}
const effectFn = effect(getter, { lazy: true, scheduler })
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
// 这个函数主要用来访问一个对象下的所有后代属性
function traverse(value, seen = new Set) {
if (!isObject(value) || seen.has(value)) {
return value
}
// 这里是为了防止循环引用
seen.add(value)
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else {
for (const key in value) {
traverse(value[key], seen)
}
}
return value
}
// watch api
function watch(obj, cb, options = {}) {
let newVal, oldVal
const { flush = 'pre', immediate = false } = options
const cleanups = new Set()
// 注册清理副作用函数
const onInvalidate = (fn) => {
cleanups.add(fn)
}
const job = () => {
newVal = effectFn()
// 每次执行回调之前,先执行清理函数,避免执行过期的副作用函数
cleanups.forEach(cleanup => cleanup())
// 把onInvalidate函数传递给回调函数
cb(newVal, oldVal, onInvalidate)
// 更新旧值
oldVal = newVal
}
const isGetter = isFunction(obj)
const jobFlushMap = {
// 这是watch的默认行为,需要新的调度器,用来保证在vue更新前一步执行,会涉及到组件的更新周期钩子问题,这里我们就不做了
pre: () => job(),
// 这里也是一样的,需要使用不同的调度器来保证在vue更新完成后进行调用,这里我们使用前边创建的queueJob来模拟
post: () => queueJob(job),
// 直接执行,不需要任何调度器
sync: job
}
const effectFn = effect(
isGetter ? obj : () => traverse(obj),
{
lazy: true,
scheduler: jobFlushMap[flush] || job
})
// 是否是立即执行
if (immediate) {
job()
} else {
// 获取到初始值
oldVal = effectFn()
}
}
总结
你自己总结吧😄~
结束~goodbye~