vue 响应式 响应系统的设计与实现 余生 2024-11-14 2024-11-15 响应式数据与副作用函数
副作用函数是指会产生副作用的函数(这是一句废话 )
副作用(side effect)指的是在函数执行过程中,除了计算返回值之外,发生的任何额外的状态变化。换句话说,当函数的执行影响了外部的环境或状态时,就会产生副作用。 通常副作用包括了修改全局变量、改变函数外部的状态、进行 I/O 操作(例如修改 DOM、发送网络请求、打印日志等)
举个例子:
1 2 3 function effect ( ){ document .body .innerText = "hello world" }
该函数在执行过程中,会修改dom元素,而dom元素在其他函数中也会被使用或修改,也就是说,effect函数的运行是有可能影响到其他函数运行的。
下面对响应式数据 做出介绍。
一般来说,在一个副作用函数中读取了某个对象的属性,当这个属性值发生了变更,则该副作用函数会再次运行 。 如果能实现这个目标,则该对象就是一个响应式数据。
1 2 3 4 const obj = { a : "123" }function effect ( ){ document .body .innerText = obj.a }
比如,上面的effect使用了object.a属性,当obj.a的值发生变化后,effect又会运行一次
响应式数据的基本实现
实现这个效果的关键在于拦截一个对象属性的读取和设置操作。
在ES2015之前,只能通过Object.defineProperty函数实现,这也是Vue2采用的方式。在ES2015+中,可以使用Proxy来实现,这也是Vue3的实现方式。
一个响应系统的
设计一个完善的响应系统
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 bucket = new WeakMap ()const globalEffectFn = null function effect (fn )} globalEffectFn = fn fn () } function track (target, key )} if (!globalEffectFn) 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 (globalEffectFn) } function trigger (target, key ) { const depsMap = bucket.get (target) if (!depsMap) return const deps = depsMap.get (key) deps && deps.forEach (fn =>fn ()) } const obj = new Proxy (data, { get (target, key ) { track (target, key) return target[key] }, set (target, key, value ) { target[key] = value trigger (target, key) } })
补充,weakMap和Map的区别,
主要不同,weakMap对其key为弱引用,如果key在用户侧没再被使用,被垃圾回收器回收了,则对应的键和值就无法访问。 而Map中的key不会被垃圾回收器从内存中移除。
如果一个响应对象不在被使用了,则bucket中就不必再存放对应的副作用函数了。
分支切换与cleanup
1 2 3 effect (function effectFn ( ){ document .body .innerText = obj.ok ? obj.text : 'not ok' })
上述代码,在obj.ok为true的时候,实际上会为ok属性和text属性分别建立依赖集合。 因而会造成一种非预期中的情况,即ok在由true变为false后,text的值发生的改变,此时又触发了副作用函数的执行,而实际上,这次执行并不会造成影响,是一次不必的更新。
为了解决由text产生的一六副作用,解决方式为在每次副作用函数执行前先把它从所在的依赖集合中删除自己,在副作用函数之后时,会重新建立依赖解析,在新的联系中不会有遗留的。
用上面的代码举例,ok默认为true的时候,ok的依赖集合Set1上有effectFn这个副作用函数,text的依赖集合Set2上也有这个函数。当ok的值变为false时,effectFn找到自己在Set1和Set2上存在,然后从Set1和Set2上去掉自己。 然后effectFn执行,在执行的过程中重新建立依赖,因为此时为ok的值为false,不会读取text的值,因为text上不会新增依赖函数。
重新设计effect函数
为了让副作用函数知道自己在哪些依赖集合中被使用,需要重新设计effect函数如下,
1 2 3 4 5 6 7 8 9 10 function effect (fn ) { const effectFn = ( ) => { globalEffectFn = effectFn fn () } effectFn.deps = [] effectFn () }
收集依赖集合
在副作用函数在track函数中被加到依赖集合的过程中,就可以将该依赖集合又添加了这个副作用函数的依赖集合数组deps中,即
1 2 3 4 5 function track (target, effectFn (key)} deps.add(globalEffectFn) globalEffectFn.deps.push(deps) }
移除副作用函数
在副作用函数执行时,从依赖集合中清除自身
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function effect (fn ) { const effectFn = ( ) => { cleanup (effectFn) globalEffectFn = effectFn fn () } 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 }
对于上述代码,需要注意,在响应式数据set后,使用forEach触发某个依赖集合中的所有副作用函数执行,在执行过程中,会先清理,然后执行fn,期间重新将其添加到依赖集合中。 在 Set 的遍历过程中修改 Set 本身是不安全的,由于ES语言特性,这个被重新添加的副作用函数会被认为是job (未访问过的,又会重新执行,导致死循环。
为了解决这个问题,在执行的时候可以构造另一个Set,即
1 2 3 4 5 6 7 8 function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const deps = depsMap.get(key) // 创建新的 Set,避免在遍历过程中修改原始 Set const effectToRun = new Set(deps) effectToRun.forEach(effectFn => job (effectFn key)}effectFn ()) }
嵌套的effect和effect栈
分析如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 let temp1, temp2effect (function effectFn1 ( ) { console .log ('effectFn1 execute' ); effect (function effectFn2 ( ) { console .log ('effectFn2 execute' ); temp1 = obj.bar }) temp2 = obj.foo })
为了解决这个问题,我们可以设置一个副作用函数栈,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let effectStack = []function effect (fn ) { const effectFn = ( ) => { cleanup (effectFn) globalEffectFn = effectFn effectStack.push (effectFn) fn () effectStack.pop () globalEffectFn = effectStack[effectStack.length - 1 ] } effectFn.deps = [] effectFn () }
避免无限循环递归
1 2 3 4 effect (()=> { obj.foo ++ })
上述代码在执行过程中,会先设置globalEffectFn,然后执行fn读取obj.foo的值,执行track向依赖集合中添加副作用函数, 然后+1 重新赋值,此时触发trigger函数,执行依赖集合中的函数,此时又会碰到该副作用函数,导致栈溢出。
为了解决这个问题,需要在trigger执行中,判断将要触发执行的副作用函数和当前正在执行的副作用函数是否相同,相同则不执行,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const deps = depsMap.get(key) effectTorRun = new Set() deps && deps.forEach(effect=>{ if(effect !== Set (globalEffectFn)} effectToRun.add(effect) } }) effectToRun.forEach(effectFn => ==effectFn ()) }
调度执行
可调度性: 当trigger动作触发副作用函数执行时,有能力决定副作用函数执行的时机、次数以及方式。
为effect增加第二参数options,为中间副作用函数增加options属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function effect (fn, options = {} ) { const effectFn = ( ) => { cleanup (effectFn) globalEffectFn = effectFn effectStack.push (effectFn) fn () effectStack.pop () globalEffectFn = effectStack[effectStack.length - 1 ] } effectFn.options = options effectFn.deps = [] effectFn () }
options有个属性schedule,是一个函数,该函数接收副作用函数,自定义其执行情况,需要调整trigger函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function trigger (target, key ) { const depsMap = bucket.get (target) if (!depsMap) return const deps = depsMap.get (key) effectTorRun = new Set () deps && deps.forEach (effect => { if (effect !== globalEffectFn) { effectToRun.add (effect) } }) effectToRun.forEach (effectFn => { if (effectFn.options .schedule ) { effectFn.options .schedule (effectFn) } else { effectFn () } }) }
通过这种方式,如果需要实现在一个同步函数中多次修改某个属性的值,但只触发一次副作用,则可以这么实现:
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 const jobQueue = new Set ()const p = Promise .resolve ()let isFlushing = false function flushJob ( ) { if (isFlushing) return isFlushing = true p.then (() => { jobQueue.forEach (job => job ()) }).finally (() => { isFlushing = false }) } effect (() => { console .log (obj.foo ); }, { schedule (fn ) { jobQueue.add (fn) flushJob () } }) obj.foo ++ obj.foo ++
对于上述代码,obj.foo第一次自增后,trigger中触发schedule,该schedule首先将副作用函数添加到jobQueue中,然后初次执行flushjob,期间,设置flush为true,然后设置回调事件。 然后第二次obj.foo自增,因为jobQueue是一个set,因此不会加进去,另外,flushJob中因为isFlushing状态为true,因此也不会执行。在同步代码执行完毕之后,开始执行微任务队列中的代码,遍历jobQueue中的函数,最后重置isFlushing
计算属性computed和lazy
懒执行的effect
按照上述的设计,注册一个副作用函数的时候,会立即执行,在某些场景下,我们希望在特定的时机实现。可以通过在options上添加lazy属性.。 此时effect会返回一个中间副作用函数,手动在需要的地方执行即可。
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 effect (() => { console .log (obj.foo ); }, { lazy : true }) function effect (fn, options = {} ) { const effectFn = ( ) => { cleanup (effectFn) globalEffectFn = effectFn effectStack.push (effectFn) const res = fn () effectStack.pop () globalEffectFn = effectStack[effectStack.length - 1 ] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy ) { effectFn () } return effectFn }
计算属性
主要关注:
如何缓存计算结果
计算属性依赖的响应数据变化后,如何触发依赖这个计算数据的副作用函数执行
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 sumRes = computed (() => obj.foo + obj.bar )function computed (getter ) { let value let dirty = true const effectFn = effect (getter, { lazy : true , schedule ( ) { dirty = true trigger (obj, 'value' ) } }) const obj = { get value () { if (dirty) { value = effectFn () dirty = false } return value } } return obj }
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 28 29 30 31 32 33 34 35 36 37 38 function 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 () } function 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和回调执行事件
在vue中,watch可以通过指定immediate为true,立即执行回调,而不必等监听对象或属性发生变化后再执行。针对这个场景,可以把schedule中的逻辑抽离出来作为job函数,当指定immediate参数时,立即执行该方法。
此外,还可以增加flush控制回调执行时机,有pre,post,sync。 post可以将job放在一个微任务队列里,这样就会等DOM更新结束 再执行(需补充事件循环知识)。
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 function 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 : () => { if (options.flush === "post" ) { const p = Promise .resolve () p.then (job) } else { job () } } } ) if (options.immediate ) { job () } else { oldValue = effectFn () } }
过期的副作用
在开发中,经常会碰到竞态问题,特别是在网络请求的时候。比如下面的情况
在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 49 50 51 52 53 54 55 56 57 58 59 60 watch (obj, async (newValue, oldValue, onInvalidate) => { let expired = false onInvalidate (() => { expired = true }) const res = await fetch ('https://api.github.com' ) if (!expired) { finalData = res } }) function watch (source, cb, options = {} ) { let getter if (typeof source === "function" ) { getter = source } else { getter = () => traverse (source) } let oldValue, newValue let cleanup function onInvalidate (fn ) { cleanup = fn } const job = ( ) => { newValue = effectFn () if (cleanup) { cleanup () } cb (newValue, oldValue, onInvalidate) oldValue = newValue } const effectFn = effect ( () => getter (), { lazy : true , schedule : () => { if (options.flush === "post" ) { const p = Promise .resolve () p.then (job) } else { job () } } } ) if (options.immediate ) { job () } else { oldValue = effectFn () } }
上述代码,如有obj.foo++, 则第一次cleanup还是undefined, 在执行cb后,watch内cleanup注册了第一次的过期回调。当请求为结束时,触发第二次obj.foo++, 在执行job时,发现cleanup有值,则执行第一次的过期回调,修改闭包内的expired的值为true,所以当第一次的网络请求结束后,不会执行赋值操作。