响应系统也是 Vue.js 的重要组成部分。在本章中,首先讨论什么是响应式数据
和副作用函数
,然后尝试实现一个相对完善的响应系统。在这个过程中,我们会遇到各种各样的问题,例如如何避免无限递归?为什么需要嵌套的副作用函数?两个副作用函数之间会产生哪些影响?以及其他需要考虑的细节。
第二篇 响应系统
响应式数据
副作用函数
Proxy
WekaMap
Set
Map
4.1 响应式数据与副作用函数 副作用函数指的是会产生副作用的函数(影响其他函数执行):
1 2 3 4 5 6 7 let count = 0 const effect = () => { document .body.innerText = 'hello world' } const effect2 = () => { count = 1 }
响应式数据,假设在一个副作用函数中读取了某个对象的属性,该属性发生变化时,该副作用函数重新执行。
1 2 3 4 const obj = { text : '123' }const effect = () => { console .log(obj.text) }
4.2 响应式数据的基本实现 如何让 obj 变成响应式对象呢 —— 使用 Proxy 实现
effect 执行,触发 obj.text 的读取操作,存储 effect
修改 obj.text,触发字段 obj.text 的设置操作,把存储的 effect 取出执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const bucket = new Set ()const data = { text : 'hello world' }const obj = new Proxy (data, { get (target, key) { bucket.add(effect) return target(target[key]) }, set (target, key, newVal) { target[key] = newVal bucket.forEach(fn => fn()) return true } })
4.3 设计一个完善的响应式系统 明确副作用函数
与被操作的的目标字段之间建立明确的关系。
角色:
代理对象(target)
属性(key)
副作用函数 (effectFn)
关系(WeakMap):
target(Map)
key(Set)
effectfn
target1
key1
effectFn1
target1
key2
effectFn1 effectFn2
target2
key3
effectFn2
其他:
effect,注册副作用函数,effect(() => { console.log(target1.key1) })
activeEffect,存储被注册的副作用函数,保存副作用函数的名字,也能处理匿名函数
bucket,存储所有副作用函数的桶
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 const bucket = new WeakMap ()let activeEffectconst effect = fn => { activeEffect = fn fn() } const 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) } const trigger = (target, key ) => { const depsMap = bucket.get(target) if (!depsMap) { return true } const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) } const data = { text : 'hello world' }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(obj.text) }) obj.text = '123'
tips:
key 对应的 Set 数据结构中存储的副作用函数集合称为依赖集合
WeakMap 对 key 是弱引用,一旦没有其他引用与该对象关联,则垃圾回收机制会回收该对象所占用的内存
4.4 分支切换与 cleanup 分支切换的定义:
1 2 3 4 5 6 const data = { ok : true , text : 'hello world' }const obj = new Proxy (data, { })effect(() => { document .body.innerText = obj.ok? obj.text : 'not' })
函数内部存在一个三元表达式,obj.ok 的值发生变化时,代码执行的分支也会跟着变化,这就是所谓的分支切换。
分支的切换可能会产生遗留的副作用函数,如下面的代码,除了没有删除之前存储的副作用函数,遗留的副作用函数还会导致不必要的更新,obj.text 更新后仍然会触发副作用函数执行。
1 2 3 4 5 6 7 8 9 const data = { ok : true , text : 'hello world' }effect(() => { console .log(obj.ok ? obj.text : 'noOk' ) }) obj.ok = false
解决这个问题的思路:每次副作用函数执行时,可以把它从所有与之关联的依赖集合中删除。
副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。
要将一个副作用函数从所有与之关联的依赖集合中移除,需要明确知道哪些依赖集合中包含它,因此在 effect 内部定义了新的 effectFn 函数,并为其添加 effectFn.deps 属性(数组),用来存储所有包含当前副作用函数的依赖集合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 let activeEffectconst cleanup = effectFn => { for (let i = 0 ; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } const effect = fn => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] effectFn() } const 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) }
cleanup 函数接收副作用函数作为参数,遍历副作用函数的 effectFn.deps 数组,将该副作用函数从数组的每一项(依赖集合,即副作用函数集合)中移除,最后重置 effectFn.deps 数组。至此,可以避免副作用函数遗留
。
解决无限循环执行
的问题,问题出现在 trigger 函数内部的 effects && effects.forEach(fn => fn())
中,当副作用函数执行时,会调用 cleanup 进行清除,实际上是从 effects 集合中将当前执行的副作用函数剔除。但是副作用函数的执行会导致其重新被收集到集合中,而此时对于 effects 集合的遍历仍在进行。
如:
1 2 3 4 5 6 7 const set = new Set ([1 ])set.forEach(item => { set.delete(1 ) set.add(1 ) console .log('遍历中' ) })
规范:
在调用 forEach 遍历 Set 集合,如果一个值已经被访问过,但是该值被删除重新添加到集合,如果此时遍历未结束,那么该值会被重新访问。因此,上面的代码会无限执行,解决办法就是构造另一个 Set 集合并遍历它。
1 2 3 4 5 6 7 8 const set = new Set ([1 ])const newSet = new Set (set)newSet.forEach(item => { set.delete(1 ) set.add(1 ) console .log('遍历中' ) })
1 2 3 4 5 6 7 8 9 const trigger = (target, key ) => { const depsMap = bucket.get(target) if (!depsMap) { return true } const effects = depsMap.get(key) const effectsToRun = new Set (effects) effectsToRun.forEach(effectFn => effectFn()) }
4.5 嵌套的 effect 与 effect 栈 effect 是可以发生嵌套的(相当于 Vue 中的组件嵌套,A 组件渲染了 B 组件。),如下面的代码:
1 2 3 4 5 effect(() => { effect(() => { }) })
1 2 3 4 5 6 7 8 9 10 11 12 effect(() => { console .log('effectFn1 执行' ) effect(() => { console .log('effectFn2 执行' ) console .log(obj.text) }) }) obj.text = '123'
我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层的副作用函数会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都是内存副作用函数,这就是问题的所在。
为了解决这个问题,需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 let activeEffectconst effectStack = []const effect = fn => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1 ] } effectFn.deps = [] effectFn() }
4.6 避免无限递归循环 1 2 3 4 5 6 const data = { foo : 1 }const obj = new Proxy (data, { })effect(() => { obj.foo++ })
在 obj.foo++
中,即会读取 obj.foo
的值,又会设置 obj.foo
的值,而这就是导致问题的根本原因,流程:
首先读取 obj.foo
的值,触发 track
操作,将当前副作用函数收集到 obj.foo
的依赖集合中(deps)
接着将其值加 1,触发 set 操作,触发 trigger
操作,将副作用函数从 obj.foo
的依赖集合中(deps)取出并执行。
问题在于副作用函数正在执行中,还没执行完毕,就要开始下一次执行。这样会导致无限递归地调用自己,于是产生了栈溢出。
解决办法并不难。通过分析可得,读取和设置操作是在同一个副作用函数内进行的,此时无论是 track
时收集的副作用函数,还是trigger
时要触发执行的副作用函数,都是 activeEffect
。基于此,我们可以在 trigger 动作发生时增加守卫:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const data = { foo : 1 }const obj = new Proxy (data, { })const trigger = (target, key ) => { const depsMap = bucket.get(target) if (!depsMap) { return true } const effects = depsMap.get(key) const effectsToRun = new Set () effects && effects.forEach( => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => effectFn()) }
4.7 调度执行 可调度性是响应式系统非常重要的特性 ,什么是可调度行?
指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
如何决定副作用函数的执行方式?
1 2 3 4 5 6 7 8 9 10 11 12 const data = { foo : 1 }const obj = new Proxy (data, { })effect(() => { console .log(obj.foo) }) obj.foo++ console .log('结束' )
调整输出的顺序,如 1,结束,2,除了把代码语句互换,有什么能够在不调整代码的情况下实现呢 —— 这时候就需要响应式系统支持调度。
为 effect 函数设计一个选项参数 options
,允许用户指定调度器
1 2 3 4 5 6 7 8 9 10 effect( () => { console .log(obj.foo) }, { scheduler: fn => {} } )
options 对象可以指定一个 scheduler 调度函数,同时在 effect 函数内部我们需要把 options 挂载到对应的副作用函数上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const effect = (fn, options = {} ) => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1 ] } effectFn.options = options effectFn.deps = [] effectFn() }
有了调度函数,我们在 trigger 函数中触发执行副作用函数时,就可以直接调用用户传递的调度器函数,从而把控制权浇给用户:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const trigger = (target, key ) => { const depsMap = bucket.get(target) if (!depsMap) { return true } const effects = depsMap.get(key) const effectsToRun = new Set () effects && effects.forEach(item => { if (item !== activeEffect) { effectsToRun.add(item) } }) effectsToRun.forEach(effectFn => { if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) } else { effectFn() } }) }
如上面代码所示,在 trigger 函数中,我们判断如果存在调度器,则调用该调度器,并将副作用函数作为参数传递,否则执行副作用函数,由用户自己控制如何执行;否则保留之前的行为,即执行副作用函数。
回到开头的需求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const data = { foo : 1 }const obj = new Proxy (data, { })effect( () => { console .log(obj.foo) }, { scheduler: fn => { setTimeout (fn) } } ) obj.foo++ console .log('结束' )
除了控制副作用函数的执行顺序 ,通过调度器还可以控制它的执行次数 ,这点也尤为重要,如:
1 2 3 4 5 6 7 8 9 effect(() => { console .log(obj.foo) }) obj.foo++ obj.foo++
由输出可知,字段 obj.foo 的值一定会从 1 自增到 3,2 只是它的过度状态,如果我们只关心结果,不关心过程,那么执行三次打印操作就是多余的,期望的打印结果是 1,3,基于调度器实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 const jobQueue = new Set ()const p = Promise .resolve()let isFlushing = false const flushJob = () => { if (isFlushing) { return } isFlushing = true p.then(() => { jobQueue.forEach(job => job()) }).finally(() => { isFlushing = false }) } effect( () => { console .log(obj.foo) }, { scheduler: fn => { jobQueue.add(fn) flushJob() } } ) obj.foo++ obj.foo++
每次 scheduler 调度函数执行时,将副作用函数添加到 jobQueue 队列中,然后调用 flushJob 刷新队列。
关于 flushJob,该函数通过 isFlushing 判断是否执行,只有为 false 时才会执行,并将 isFlushing 设为 true,无论调用多少次 flushJob 函数,在 jobQueue 的副作用函数执行完之前只会执行一次。
在这里借助了 js 执行机制,同步代码 -> 微任务 -> 下一个宏任务。该功能类似 Vue 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue 内部实现了更完善的调度器。
4.8 计算属性 computed 与 lazy 在深入讲解计算属性之前,需要先聊聊懒执行的 effect,即 lazy 的 effect。
1 2 3 4 5 6 effect( () => { console .log(obj.foo) } )
有些场景下,我们并不希望它立即执行,而是希望它在需要的时候执行,如计算属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 effect( () => { console .log(obj.foo) }, { lazy: true } ) const effectFn = (fn, options = {} ) => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1 ] } effectFn.options = options effectFn.deps = [] if (!options.lazy) { effectFn() } return effectFn }
通过 options.lazy 实现了让副作用函数不立即执行的功能,那么副作用函数该什么时候执行呢?这里将副作用函数作为返回值返回,只要调用 effect,就能拿到对应的副作用函数,在需要的时候执行。
1 2 3 4 5 const effectFn = effect(() => { console .log(obj.foo) }, { lazy : true }) effectFn()
❗如果我们把传递给 effect 的函数当作一个 getter,返回一个返回值:
1 2 3 4 5 const effectFn = effect(() => { return obj.foo + obj.bar }, { lazy : true }) const value = effectFn()
为了实现这个目标,需要对 effect 函数做一些修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const effect = (fn, options = {} ) => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1 ] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy) { effectFn() } return effectFn }
通过代码可知,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果,接下来可以实现计算属性了:
1 2 3 4 5 6 7 8 9 10 11 12 13 const computed = getter => { const effectFn = effect(getter, { lazy : true }) const obj = { get value () { return effectFn() } } return obj }
例子
1 2 3 4 5 const data = { foo : 1 , bar : 2 }const obj = new Proxy (data, { })const sum = computed(() => obj.foo + obj.bar)console .log(sum.value)
可以看到它能正确工作,不过现在的计算属性只做到了懒计算(只有真正读取 sum.value 的值时,才会进行计算并得到值),还做不到对值进行缓存,假如我们多次访问 sum.value,会导致 effectFn 进行多次计算(每次访问,都会调用 effectFn 重新计算),即使 obj.bar 和 obj.bar 的值并没有发生变化。
为了解决这个问题,需要在 computed 函数中添加缓存功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 const computed = getter => { let value let dirty = true const effectFn = effect(getter, { lazy: true , scheduler: () => { dirty = true } }) const obj = { get value () { if (dirty) { value = effectFn() dirty = false } return value } } return obj }
现在,我们设计的计算属性已经趋于完美,但还有一个缺陷(不会触发读取该计算属性的副作用函数的执行),它体现在当我们在另一个 effect 中读取计算属性的值时:
1 2 3 4 5 6 7 const sum = computed(() => obj.foo + obj.bar)effect(() => { console .log(sum.value) }) obj.foo++
分析问题的原因,我们发现,这本质上是一个 effect 的嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集为依赖。而把计算属性用作与另一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。
解决办法很简单,当读取计算属性的值时,我们可以手动调用 track 函数进行追踪,当计算属性依赖的响应式数据发生变化时,我们可以手动调用 trigger 函数触发响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const computed = getter => { let value let dirty = true const effectFn = effect(getter, { lazy: true , scheduler: () => { dirty = true trigger(obj, 'value' ) } }) const obj = { get value () { if (dirty) { value = effectFn() dirty = false } track(obj, 'value' ) return value } } return obj }
现在,计算属性的实现已经基本完成。对于如下代码:
1 2 3 effect(function effectFn ( ) { console .log(sum.value) })
它会建立这样的关系:
computed(obj) - value - effectFn
4.9 watch 的实现原理 所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数,如:
1 2 3 4 5 6 watch(obj, () => { console .log('数据变了' ) }) obj.foo++
实际上,watch 实现的本质是利用了 effect 以及 options.scheduler 选项:
1 2 3 4 5 6 7 8 9 effect( () => { console .log(obj.foo) }, { scheduler: () => { } } )
当响应式数据发生变化时,会触发 scheduler 执行,基于此,简单的 watch 实现:
1 2 3 4 5 6 7 8 9 10 11 12 const watch = (source, cb ) => { effect( () => source.foo, { scheduler: () => { cb() } } ) }
例子:
1 2 3 4 watch(obj, () => { console .log('数据变了' ) })
这里硬编码了 obj.foo,为了让 watch 函数具有通用性,需要封装一个通用的读取操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const watch = (source, cb ) => { effect( () => traverse(source), { scheduler: () => { cb() } } ) } const traverse = (value, seen = new Set () ) => { if (typeof value!== 'object' || value === null || seen.has(value)) { return } seen.add(value) for (const key in value) { traverse(value[key], seen) } return value }
当对象任意属性发生变化时,都能触发回调函数执行。
watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:
1 2 3 4 5 6 7 8 watch( () => obj.foo, () => { console .log('obj.foo 变了' ) } )
传递给 watch 函数的第一个参数不再是一个响应式数据,二是一个 getter 函数,在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据发生变化时,才会触发回调函数执行,下面的代码实现了这一功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const watch = (source, cb ) => { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse(source) } effect( () => getter(source), { scheduler: () => { cb() } } ) }
通常在 Vue 的 watch 函数中能拿到变化前后的值,那么如何获得新旧值呢?这需要充分利用 effect 函数的 lazy 选项:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const watch = (source, cb ) => { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse(source) } let oldValue, newValue const effectFn = effect( () => getter(), { lazy: true , scheduler: { newValue = effectFn() cb(newValue, oldValue) oldValue = newValue } } ) oldValue = effectFn() }
4.10 立即执行的 watch 与回调执行时机 上一节介绍了 watch 的基本实现,在这个过程中,我们认识到 watch 的本质是对 effect 的二次封装,本节我们继续讨论:
立即执行的函数回调
默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行,但是在 Vue 中可以通过选项 immediate 来指定是否需要立即执行:
1 2 3 watch(obj, () => { console .log('数据变了' ) })
当 immediate 为 true 时,回调函数辉在该 watch 创建时立刻执行一次。仔细思考发现,回调函数的立即执行与后续执行,没有本质的区别,所以我们可以把 scheduler 函数封装为一个通用函数,分别在初始化和变更时执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const watch = (source, cb, options = {} ) => { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse(source) } let oldValue, newValue const job = () => { newValue = effectFn() cb(newValue, oldValue) oldValue = newValue } const effectFn = effect( () => getter(), { lazy: true , scheduler: job } ) if (options.immediate) { job() } else { oldValue = effectFn() } }
除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定函数的执行时机,例如 Vue3 中使用 flush 选项来指定:
1 2 3 4 5 6 7 watch(obj, () => { console .log('数据变了' ) }, { flush: 'pre' })
flush 本质上是在指定调度函数的执行时机,前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。当 flush 的值为 ‘post’ 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:
1 2 3 const watch = (source, cb, options ) => { let getter }
4.11 过期的副作用 1 2 3 4 5 6 let finalDatawatch(obj, async () => { const res = await fetch('path' ) finalData = res })
obj 每次发生变化时候,都会触发副作用函数的执行,如果第一次的结果覆盖了最后一次的结果,就会导致数据数据无效(状态同步问题)。这就需要一个让副作用过期的手段。
在 Vue 中,在 watch 内部每次监测到更新变更后,在副作用函数重新执行之前,会先调用通过 onInvalidate 函数注册过的过期回调函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 const watch = (source, cb, options = {} ) => { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse(source) } let oldValue, newValue let cleanup const onInvalidate = fn => { cleanup = fn } const job = () => { newVal = effectFn() if (cleanup) { cleanup() } cb(newVal, oldVal, onInvalidate) oldValue = newValue } const effectFn = effect( () => getter(), { lazy: true , scheduler: () => { if (options.flush === 'post' ) { const p = Promise .resolve() p.then(job) } else { job() } } } ) if (options.immediate) { job() } else { oldValue = effectFn() } }
在这段代码中,我们首先定义了 cleanup 变量,这个变量用来存储用户通过 onInvalidate 函数注册的过期回调函数。在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数 cleanup。最后我们把 onInvalidate 函数作为参数传递给 cb,以便用户使用。
4.12 总结 在本章中,首先介绍了副作用函数和响应式数据的概念,以及它们的关系。一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶中”;当“设置”操作发生时,再将副作用函数从“桶”中取出并执行。这就是相应系统的根本实现原理。
接着我们实现了一个相对完善的响应式系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。同时,我们也介绍了 WeakMap 和 Map 这两个数据结构之间的区别。WeakMap 是弱引用的,它不会影响垃圾回收器的工作。当用户代码对一个对象没有引用关系时,WeakMap 不会组织垃圾回收器回收该对象。
我们还讨论了分支切换导致的冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用的问题。但在此过程中,我们还遇到了遍历 Set 数导致无限循环的新问题(forEach 遍历 Set 集合,如果一个值被访问过了,但这个值被删除并重新添加到集合,如果 forEach 遍历没结束,那么这个值会被重新访问),解决方案是建立一个新的 Set 数据结构进行遍历。
然后我们讨论了关于嵌套的副作用函数的问题(父子组件),为了避免响应数据和副作用函数之间建立的关系错乱,我们需要使用栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈顶弹出。当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应关系。而后,我们还遇到了副作用函数无限递归调用自身,导致栈溢出的问题,该问题的原因在于对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法为:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
随后,我们讨论了响应系统的可调度行(指 trigger 触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式)。为了实现调度能力,我们为 effecct 函数增加了第二个选项参数,可以通过 scheduler 指定调用器,这样用户可以通过调度器自行完成任务的调度。我们还讲解了如何通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。
而后,我们讲解了计算属性,它实际上是一个懒执行的副作用函数,我们通过 lazy 使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。
之后,我们讨论了 watch 的实现原理,它本质上是利用了副作用函数重新执行时的可调度行,一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在 scheduler 中执行用户通过 watch 注册的回调函数即可。此外还讲解了立即执行回调的 watch,通过添加新的 immediate 实现,还讨论了如何控制回调函数的执行时机,通过 flush 选项指定,本质上是利用了调用器和异步的微任务队列。
最后,我们讨论了过期的副作用函数,会导致状态同步的问题,为了解决这个问题,为 watch 设计了第三个参数 —— onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调函数。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决“竞态”的问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 const data = { ok : true , foo : 1 , bar : 2 }const bucket = new WeakMap ()const effectStack = []let activeEffectconst cleanup = effectFn => { for (let i = 0 ; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } const effect = (fn, options = {} ) => { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1 ] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy) { effectFn() } return effectFn } const 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) } const 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() } }) } const obj = new Proxy (data, { get (target, key) { track(target, key) return target[key] }, set (target, key, newVal) { target[key] = newVal trigger(target, key) return true } }) const computed = getter => { let value let dirty = true const effectFn = effect(getter, { lazy: true , scheduler () { if (!dirty) { trigger(obj, 'value' ) dirty = true } } }) const obj = { get value () { if (dirty) { value = effectFn() dirty = false } track(obj, 'value' ) return value } } return obj } const watch = () => { }