Vue3響應式對象是如何實現的(2)
前言
在Vue3響應式對象是如何實現的(1)中,我們已經從功能上實現瞭一個響應式對象。如果僅僅滿足於功能實現,我們就可以止步於此瞭。但在上篇中,我們僅考慮瞭最簡單的情況,想要完成一個完整可用的響應式,需要我們繼續對細節深入思考。在特定場景下,是否存在BUG?是否還能繼續優化?
分支切換的優化
在上篇中,收集副作用函數是利用get
自動收集。那麼被get
自動收集的副作用函數,是否有可能會產生多餘的觸發呢?或者說,我們其實進行瞭多餘的收集呢?同樣,還是從一個例子入手。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } // (1) const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' // (2) console.log('Done!') } effect(fn)
這段代碼中,我們做瞭(1)(2)兩處更改。我們在(1)處給響應式對象新增加瞭一個boolean
類型的屬性ok
,在(2)處我們利用ok
的真值,來選擇將誰賦值給document.body.innerText
。現在,我們將obj.ok
的值置為false
,這就意味著,document.body.innerText
的值不再依賴於obj.text
,而直接取字符串'ops...'
。
此時,我們要能夠註意到一件事,雖然document.body.innerText
的值不再依賴於obj.text
瞭,但由於ok
的初值是true
,也就意味著在ok
的值沒有改變時,document.body.innerText
的值依賴於obj.text
,更進一步說,這個函數已經被obj.text
當作自己的副作用函數收集瞭。這會導致什麼呢?
我們更改瞭obj.text
的值,這會觸發副作用函數。但此時由於ok
的值為false
,界面上顯示的內容沒有發生任何改變。也就是說,此時修改obj.text
觸發的副作用函數的更新是不必要的。
這部分有些繞,讓我們通過畫圖來嘗試說明。當ok
為true
時,數據結構的狀態如圖所示:
從圖中可以看到,obj.text
和obj.ok
都收集瞭同一個副作用函數fn
。這也解釋瞭為什麼即使我們將obj.ok
的值為false
,更改obj.text
仍然會觸發副作用函數fn
。
我們希望的理想狀況是,當ok
為false
時,副作用函數fn
被從obj.text
的副作用函數收集器中刪除,數據結構的狀態能改變為如下狀態。
這就要求我們能夠在每次執行副作用函數前,將該副作用函數從相關的副作用函數收集器中刪除,再重新建立聯系。為瞭實現這一點,就要求我們記錄哪些副作用函數收集器收集瞭該副作用函數。
let activeEffect function cleanup(effectFn) { // (3) for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] // (1) effectFn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) activeEffect.deps.push(fns) // (2) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' console.log('Done!') } effect(fn)
在這段代碼中,我們增加瞭3處改動。為瞭記錄副作用函數被哪些副作用函數收集器收集,我們在(1)處給每個副作用函數掛載瞭一個deps
,用於記錄該副作用函數被誰收集。在(2)處,副作用函數被收集時,我們記錄副作用函數收集器。在(3)處,我們新增瞭cleanup
函數,從含有該副作用函數的副作用函數收集器中,刪除該副作用函數。
看上去好像沒啥問題瞭,但是運行代碼會發現產生瞭死循環。問題出在哪呢?
以下面這段代碼為例:
const set = new Set([1]) set.forEach(item => { set.delete(1) set.add(1) console.log('Done!') })
是的,這段代碼會產生死循環。原因是ECMAScript對Set.prototype.forEach
的規范中明確,使用forEach
遍歷Set
時,如果有值被直接添加到該Set
上,則forEach
會再次訪問該值。
const effectFn = () => { cleanup(effectFn) // (1) activeEffect = effectFn fn() // (2) }
同理,我們的代碼中,當effectFn
被執行時,(1)處的cleanup
清除副作用函數,就相當於set.delete
;而(2)處執行副作用函數fn
時,會觸發依賴收集,將副作用函數又加入到瞭副作用函數收集器中,相當於set.add
,從而造成死循環。
解決的方法也很簡單,我們隻需要避免在原Set
上直接進行遍歷即可。
const set = new Set([1]) const otherSet = new Set(set) otherSet.forEach(item => { set.delete(1) set.add(1) console.log('Done!') })
在上例中,我們復制瞭set
到otherset
中,otherset
僅會執行set.length
次。按照這個思路,修改我們的代碼。
let activeEffect function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] effectFn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) activeEffect.deps.push(fns) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) const otherFns = new Set(fns) // (1) otherFns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' console.log('Done!') } effect(fn)
在(1)處我們新增瞭一個otherFns
,復制瞭fns
用來遍歷。讓我們再來看看結果。
①處,更改obj.ok
的值為false
,改變瞭頁面的顯示,沒有導致死循環。②處,當obj.ok
為false
時,副作用函數沒有執行。至此,我們完成瞭針對分支切換場景下的優化。
副作用函數嵌套產生的BUG
我們繼續從功能角度考慮,前面我們的副作用函數還是不夠復雜,實際應用中(如組件嵌套渲染),副作用函數是可以發生嵌套的。
我們舉個簡單的嵌套示例:
let t1, t2 effect(function effectFn1() { console.log('effectFn1') effect(function effectFn2() { console.log('effectFn2') t2 = obj.bar }) t1 = obj.foo })
這段代碼中,我們將effectFn2
嵌入瞭effectFn1
中,將obj.foo
賦值給t1
,obj.bar
賦值給t2
。從響應式的功能上看,如果我們修改obj.foo
的值,應該會觸發effectFn1
的執行,且間接觸發effectFn2
執行。
修改obj.foo
的值僅觸發瞭effectFn2
的更新,這與我們的預期不符。既然是effect
這裡出瞭問題,讓我們再來過一遍effect
部分的代碼,看看能不能發現點什麼。
let activeEffect // (1) function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() // (2) } effectFn.deps = [] effectFn() }
仔細思考後,不難發現問題所在。我們在(1)處定義瞭一個全局變量activeEffect
用於副作用函數註冊,這意味著同一時刻,我們僅能註冊一個副作用函數。在(2)處執行瞭fn
,此時註意,在我們給出的副作用函數嵌套示例中,effectFn1
是先執行effectFn2
,再執行t1 = obj.foo
。也就是說,此時activeEffect
註冊的副作用函數已經由effectFn1
變為瞭effectFn2
。因此,當執行到t1 = obj.foo
時,track
收集的activeEffect
已經是被effectFn2
覆蓋過的。所以,修改obj.foo
,trigger
觸發的就是effectFn2
瞭。
要解決這個問題也很簡單,既然後出現的要先被收集,後進先出,用棧解決就好瞭。
let activeEffect const effectStack = [] // (1) function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() // (2) effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() }
這段代碼中,我們在(1)處定義瞭一個棧effectStack
。不管(2)處如何更改activeEffect
的內容,都會被effectStack[effectStack.length - 1]
回滾到原先正確的副作用函數上。
運行的結果和我們的預期一致,到此為止,我們已經完成瞭對嵌套副作用函數的處理。
自增/自減操作產生的BUG
這裡還存在一個隱蔽的BUG,還和之前一樣,我們修改effect
。
effect(() => obj.foo++)
很簡單的副作用函數,這會有什麼問題呢?執行一下看看。
很不幸,棧溢出瞭。這個副作用函數僅包含一個obj.foo++
,所以可以確定,棧溢出就是由這個自增運算引起的。接下來的問題就是,這麼簡單的自增操作,怎麼會引起棧溢出呢?為瞭更好的說明問題,讓我們先來拆解問題。
effect(() => obj.foo = obj.foo + 1)
這段代碼中obj.foo = obj.foo + 1
就等價於obj.foo++
。這樣拆開之後問題一下就清楚瞭。這裡同時進行瞭obj.foo
的get
和set
操作。先讀取obj.foo
,收集瞭副作用函數,再設置obj.foo
,觸發瞭副作用函數,而這個副作用函數中obj.foo
又要被讀取,如此往復,產生瞭死循環。為瞭驗證這一點,我們打印執行的副作用函數。
上面的打印結果印證瞭我們的想法。造成這個BUG的主要原因是,當get
和set
操作同時存在時,我們收集和觸發的都是同一個副作用函數。這裡我們隻需要添加一個守衛條件:當觸發的副作用函數正在被執行時,該副作用函數則不必再被執行。
function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) const otherFns = new Set() fns && fns.forEach(fn => { if(fn !== activeEffect) { // (1) otherFns.add(fn) } }) otherFns.forEach(fn => fn()) }
如此一來,相同的副作用函數僅會被觸發一次,避免瞭產生死循環。最後,我們驗證一下即可。
到此這篇關於Vue3響應式對象是如何實現的的文章就介紹到這瞭,更多相關Vue3響應式對象內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!