首先,什麼是響應式呢?
響應式就是被觀察到的資料變化的時候做一系列連動處理。 就像一個社會熱點事件,當它有訊息更新的時候,各方媒體都會跟進做相關報導。這裡社會熱點事件就是被觀察的目標。那在前端框架裡,這個被觀察的目標是什麼呢?很明顯,是狀態。狀態一般是多個,會透過物件的方式來組織。所以,我們觀察狀態物件的每個 key 的變化,連動做一系列處理就可以了。
我們要維護這樣的資料結構:
#狀態物件的每個key 都有關聯的一系列effect 副作用函數,也就是變化的時候聯動執行的邏輯,透過Set 來組織。
每個 key 都是這樣關聯了一系列 effect 函數,那麼多個 key 就可以放到一個 Map 裡維護。
這個 Map 是在物件存在的時候它就存在,而物件銷毀的時候它也要跟著銷毀。 (因為對像都沒了自然也不需要維護每個key 關聯的effect 了)
而WeakMap 正好就有這樣的特性,WeakMap 的key 必須是一個對象,value 可以是任意數據,key 的物件銷毀的時候,value 也會銷毀。
所以,響應式的 Map 會用 WeakMap 來保存,key 為原始物件。
這個資料結構就是響應式的核心資料結構了。
例如這樣的狀態物件:
const obj = { a: 1, b: 2 }
它的回應式資料結構就是這樣的:
const depsMap = new Map(); const aDeps = new Set(); depsMap.set('a', aDeps); const bDeps = new Set(); depsMap.set('b', bDeps); const reactiveMap = new WeakMap() reactiveMap.set(obj, depsMap);
建立出來的資料結構就是圖中的那個:
然後加入deps 依賴,例如一個函數依賴了a ,那就要加入a 的deps 集合裡:
effect(() => { console.log(obj.a); });
也就是這樣:
const depsMap = reactiveMap.get(obj); const aDeps = depsMap.get('a'); aDeps.add(该函数);
這樣維護deps 功能上沒啥問題,但是難道要讓用戶手動新增deps 麼?那不但會侵入業務代碼,而且還容易遺漏。
所以絕對不會讓使用者手動維護 deps,而是要做自動的依賴收集。那怎麼自動收集依賴呢?讀取狀態值的時候,就建立了和該狀態的依賴關係,所以很容易想到可以代理狀態的 get 來實現。透過Object.defineProperty 或Proxy 都可以:
const data = { a: 1, b: 2 } let activeEffect function effect(fn) { activeEffect = fn fn() } const reactiveMap = new WeakMap() const obj = new Proxy(data, { get(targetObj, key) { let depsMap = reactiveMap.get(targetObj); if (!depsMap) { reactiveMap.set(targetObj, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) return targetObj[key] } })
effect 會執行傳入的回呼函數fn,當你在fn 裡讀取obj.a 的時候,就會觸發get,會拿到物件的回應式的Map,從裡面取出a 對應的deps 集合,往裡面加入目前的effect 函數。
這樣就完成了一次依賴收集。
當你修改obj.a 的時候,要通知所有的deps,所以還要代理set:
set(targetObj, key, newVal) { targetObj[key] = newVal const depsMap = reactiveMap.get(targetObj) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) }
基本的響應式完成了,我們測試一下:
列印了兩次,第一次是1,第二次是3。 effect 會先執行一次傳入的回呼函數,觸發get 來收集依賴,這時候打印的obj.a 是1然後當obj.a 賦值為3 後,會觸發set,執行收集的依賴,這時候打印obj. a 是3
依賴也正確收集到了:
#結果是對的,我們完成了基本的回應式!當然,響應式不會只有這麼點程式碼的,我們現在的實作還不完善,還有一些問題。例如,如果程式碼裡有分支切換,上次執行會依賴 obj.b 下次執行又不依賴了,這時候是不是就有了無效的依賴?
這樣一段程式碼:
const obj = { a: 1, b: 2 } effect(() => { console.log(obj.a ? obj.b : 'nothing'); }); obj.a = undefined; obj.b = 3;
第一次執行effect 函數,obj.a 是1,這時候會走到第一個分支,又依賴了obj.b。把 obj.a 修改為 undefined,觸發 set,執行所有的依賴函數,這時候走到分支二,不再依賴 obj.b。
把obj.b 修改為3,照理說這時候沒有依賴b 的函數了,我們執行試一下:
第一次打印2 是對的,也就是走到了第一個分支,打印obj.b
第二次打印nothing 也是對的,這時候走到第二個分支。但第三次印 nothing 就不對了,因為這時候 obj.b 已經沒有依賴函數了,但還是印了。
列印看下 deps,會發現 obj.b 的 deps 沒有清除
所以解决方案就是每次添加依赖前清空下上次的 deps。怎么清空某个函数关联的所有 deps 呢?记录下就好了。
我们改造下现有的 effect 函数:
let activeEffect function effect(fn) { activeEffect = fn fn() }
记录下这个 effect 函数被放到了哪些 deps 集合里。也就是:
let activeEffect function effect(fn) { const effectFn = () => { activeEffect = effectFn fn() } effectFn.deps = [] effectFn() }
对之前的 fn 包一层,在函数上添加个 deps 数组来记录被添加到哪些依赖集合里。
get 收集依赖的时候,也记录一份到这里:
这样下次再执行这个 effect 函数的时候,就可以把这个 effect 函数从上次添加到的依赖集合里删掉:
cleanup 实现如下:
function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 }
effectFn.deps 数组记录了被添加到的 deps 集合,从中删掉自己。全删完之后就把上次记录的 deps 数组置空。
我们再来测试下:
无限循环打印了,什么鬼?
问题出现在这里:
set 的时候会执行所有的当前 key 的 deps 集合里的 effect 函数。
而我们执行 effect 函数之前会把它从之前的 deps 集合中清掉:
执行的时候又被添加到了 deps 集合。这样 delete 又 add,delete 又 add,所以就无限循环了。
解决的方式就是创建第二个 Set,只用于遍历:
这样就不会无限循环了。
再测试一次:
现在当 obj.a 赋值为 undefined 之后,再次执行 effect 函数,obj.b 的 deps 集合就被清空了,所以需改 obj.b 也不会打印啥。
看下现在的响应式数据结构:
确实,b 的 deps 集合被清空了。那现在的响应式实现是完善的了么?也不是,还有一个问题:
如果 effect 嵌套了,那依赖还能正确的收集么?
首先讲下为什么要支持 effect 嵌套,因为组件是可以嵌套的,而且组件里会写 effect,那也就是 effect 嵌套了,所以必须支持嵌套。
我们嵌套下试试:
effect(() => { console.log('effect1'); effect(() => { console.log('effect2'); obj.b; }); obj.a; }); obj.a = 3;
按理说会打印一次 effect1、一次 effect2,这是最开始的那次执行。然后 obj.a 修改为 3 后,会触发一次 effect1 的打印,执行内层 effect,又触发一次 effect2 的打印。也就是会打印 effect1、effect2、effect1、effect2。
我们测试下:
打印了 effect1、effet2 这是对的,但第三次打印的是 effect2,这说明 obj.a 修改后并没有执行外层函数,而是执行的内层函数。为什么呢?
看下这段代码:
我们执行 effect 的时候,会把它赋值给一个全局变量 activeEffect,然后后面收集依赖就用的这个。
当嵌套 effect 的时候,内层函数执行后会修改 activeEffect 这样收集到的依赖就不对了。
怎么办呢?嵌套的话加一个栈来记录 effect 不就行了?
也就是这样:
执行 effect 函数前把当前 effectFn 入栈,执行完以后出栈,修改 activeEffect 为栈顶的 effectFn。
这样就保证了收集到的依赖是正确的。
这种思想的应用还是很多的,需要保存和恢复上下文的时候,都是这样加一个栈。
我们再测试一下:
现在的打印就对了。至此,我们的响应式系统就算比较完善了。
全部代码如下:
const data = { a: 1, b: 2 } let activeEffect const effectStack = []; function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn); 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 reactiveMap = new WeakMap() const obj = new Proxy(data, { get(targetObj, key) { let depsMap = reactiveMap.get(targetObj) if (!depsMap) { reactiveMap.set(targetObj, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) activeEffect.deps.push(deps); return targetObj[key] }, set(targetObj, key, newVal) { targetObj[key] = newVal const depsMap = reactiveMap.get(targetObj) if (!depsMap) return const effects = depsMap.get(key) // effects && effects.forEach(fn => fn()) const effectsToRun = new Set(effects); effectsToRun.forEach(effectFn => effectFn()); } })
响应式就是数据变化的时候做一系列联动的处理。
核心是这样一个数据结构:
最外层是 WeakMap,key 为对象,value 为响应式的 Map。这样当对象销毁时,Map 也会销毁。Map 里保存了每个 key 的依赖集合,用 Set 组织。
我们通过 Proxy 来完成自动的依赖收集,也就是添加 effect 到对应 key 的 deps 的集合里。 set 的时候触发所有的 effect 函数执行。
这就是基本的响应式系统。
但是还不够完善,每次执行 effect 前要从上次添加到的 deps 集合中删掉它,然后重新收集依赖。这样可以避免因为分支切换产生的无效依赖。并且执行 deps 中的 effect 前要创建一个新的 Set 来执行,避免 add、delete 循环起来。此外,为了支持嵌套 effect,需要在执行 effect 之前把它推到栈里,然后执行完出栈。解决了这几个问题之后,就是一个完善的 Vue 响应式系统了。当然,现在虽然功能是完善的,但是没有实现 computed、watch 等功能,之后再实现。
最后,再来看一下这个数据结构,理解了它就理解了 vue 响应式的核心:
以上是怎麼手寫Vue3響應式系統的詳細內容。更多資訊請關注PHP中文網其他相關文章!