手寫?Vue3?響應式系統(tǒng)(核心就一個數(shù)據(jù)結(jié)構(gòu))
前言
響應式是 Vue 的特色,如果你簡歷里寫了 Vue 項目,那基本都會問響應式實現(xiàn)原理。而且不只是 Vue,狀態(tài)管理庫 Mobx 也是基于響應式實現(xiàn)的。那響應式是具體怎么實現(xiàn)的呢?與其空談原理,不如讓我們來手寫一個簡易版吧。
響應式
首先,什么是響應式呢?
響應式就是被觀察的數(shù)據(jù)變化的時候做一系列聯(lián)動處理。就像一個社會熱點事件,當它有消息更新的時候,各方媒體都會跟進做相關(guān)報道。這里社會熱點事件就是被觀察的目標。那在前端框架里,這個被觀察的目標是什么呢?很明顯,是狀態(tài)。狀態(tài)一般是多個,會通過對象的方式來組織。所以,我們觀察狀態(tài)對象的每個 key 的變化,聯(lián)動做一系列處理就可以了。
我們要維護這樣的數(shù)據(jù)結(jié)構(gòu):
狀態(tài)對象的每個 key 都有關(guān)聯(lián)的一系列 effect 副作用函數(shù),也就是變化的時候聯(lián)動執(zhí)行的邏輯,通過 Set 來組織。
每個 key 都是這樣關(guān)聯(lián)了一系列 effect 函數(shù),那多個 key 就可以放到一個 Map 里維護。
這個 Map 是在對象存在的時候它就存在,對象銷毀的時候它也要跟著銷毀。(因為對象都沒了自然也不需要維護每個 key 關(guān)聯(lián)的 effect 了)
而 WeakMap 正好就有這樣的特性,WeakMap 的 key 必須是一個對象,value 可以是任意數(shù)據(jù),key 的對象銷毀的時候,value 也會銷毀。
所以,響應式的 Map 會用 WeakMap 來保存,key 為原對象。
這個數(shù)據(jù)結(jié)構(gòu)就是響應式的核心數(shù)據(jù)結(jié)構(gòu)了。
比如這樣的狀態(tài)對象:
const obj = { a: 1, b: 2 }
它的響應式數(shù)據(jù)結(jié)構(gòu)就是這樣的:
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);
創(chuàng)建出的數(shù)據(jù)結(jié)構(gòu)就是圖中的那個:
然后添加 deps 依賴,比如一個函數(shù)依賴了 a,那就要添加到 a 的 deps 集合里:
effect(() => { console.log(obj.a); });
也就是這樣:
const depsMap = reactiveMap.get(obj); const aDeps = depsMap.get('a'); aDeps.add(該函數(shù));
這樣維護 deps 功能上沒啥問題,但是難道要讓用戶手動添加 deps 么?那不但會侵入業(yè)務代碼,而且還容易遺漏。
所以肯定不會讓用戶手動維護 deps,而是要做自動的依賴收集。那怎么自動收集依賴呢?讀取狀態(tài)值的時候,就建立了和該狀態(tài)的依賴關(guān)系,所以很容易想到可以代理狀態(tài)的 get 來實現(xiàn)。通過 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 會執(zhí)行傳入的回調(diào)函數(shù) fn,當你在 fn 里讀取 obj.a 的時候,就會觸發(fā) get,會拿到對象的響應式的 Map,從里面取出 a 對應的 deps 集合,往里面添加當前的 effect 函數(shù)。
這樣就完成了一次依賴收集。
當你修改 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 會先執(zhí)行一次傳入的回調(diào)函數(shù),觸發(fā) get 來收集依賴,這時候打印的 obj.a 是 1然后當 obj.a 賦值為 3 后,會觸發(fā) set,執(zhí)行收集的依賴,這時候打印 obj.a 是 3
依賴也正確收集到了:
結(jié)果是對的,我們完成了基本的響應式!當然,響應式不會只有這么點代碼的,我們現(xiàn)在的實現(xiàn)還不完善,還有一些問題。比如,如果代碼里有分支切換,上次執(zhí)行會依賴 obj.b 下次執(zhí)行又不依賴了,這時候是不是就有了無效的依賴?
這樣一段代碼:
const obj = { a: 1, b: 2 } effect(() => { console.log(obj.a ? obj.b : 'nothing'); }); obj.a = undefined; obj.b = 3;
第一次執(zhí)行 effect 函數(shù),obj.a 是 1,這時候會走到第一個分支,又依賴了 obj.b。把 obj.a 修改為 undefined,觸發(fā) set,執(zhí)行所有的依賴函數(shù),這時候走到分支二,不再依賴 obj.b。
把 obj.b 修改為 3,按理說這時候沒有依賴 b 的函數(shù)了,我們執(zhí)行試一下:
第一次打印 2 是對的,也就是走到了第一個分支,打印 obj.b
第二次打印 nothing 也是對的,這時候走到第二個分支。但是第三次打印 nothing 就不對了,因為這時候 obj.b 已經(jīng)沒有依賴函數(shù)了,但是還是打印了。
打印看下 deps,會發(fā)現(xiàn) obj.b 的 deps 沒有清除
所以解決方案就是每次添加依賴前清空下上次的 deps。怎么清空某個函數(shù)關(guān)聯(lián)的所有 deps 呢?記錄下就好了。
我們改造下現(xiàn)有的 effect 函數(shù):
let activeEffect function effect(fn) { activeEffect = fn fn() }
記錄下這個 effect 函數(shù)被放到了哪些 deps 集合里。也就是:
let activeEffect function effect(fn) { const effectFn = () => { activeEffect = effectFn fn() } effectFn.deps = [] effectFn() }
對之前的 fn 包一層,在函數(shù)上添加個 deps 數(shù)組來記錄被添加到哪些依賴集合里。
get 收集依賴的時候,也記錄一份到這里:
這樣下次再執(zhí)行這個 effect 函數(shù)的時候,就可以把這個 effect 函數(shù)從上次添加到的依賴集合里刪掉:
cleanup 實現(xiàn)如下:
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 數(shù)組記錄了被添加到的 deps 集合,從中刪掉自己。全刪完之后就把上次記錄的 deps 數(shù)組置空。
我們再來測試下:
無限循環(huán)打印了,什么鬼?
問題出現(xiàn)在這里:
set 的時候會執(zhí)行所有的當前 key 的 deps 集合里的 effect 函數(shù)。
而我們執(zhí)行 effect 函數(shù)之前會把它從之前的 deps 集合中清掉:
執(zhí)行的時候又被添加到了 deps 集合。這樣 delete 又 add,delete 又 add,所以就無限循環(huán)了。
解決的方式就是創(chuàng)建第二個 Set,只用于遍歷:
這樣就不會無限循環(huán)了。
再測試一次:
現(xiàn)在當 obj.a 賦值為 undefined 之后,再次執(zhí)行 effect 函數(shù),obj.b 的 deps 集合就被清空了,所以需改 obj.b 也不會打印啥。
看下現(xiàn)在的響應式數(shù)據(jù)結(jié)構(gòu):
確實,b 的 deps 集合被清空了。那現(xiàn)在的響應式實現(xiàn)是完善的了么?也不是,還有一個問題:
如果 effect 嵌套了,那依賴還能正確的收集么?
首先講下為什么要支持 effect 嵌套,因為組件是可以嵌套的,而且組件里會寫 effect,那也就是 effect 嵌套了,所以必須支持嵌套。
我們嵌套下試試:
effect(() => { console.log('effect1'); effect(() => { console.log('effect2'); obj.b; }); obj.a; }); obj.a = 3;
按理說會打印一次 effect1、一次 effect2,這是最開始的那次執(zhí)行。然后 obj.a 修改為 3 后,會觸發(fā)一次 effect1 的打印,執(zhí)行內(nèi)層 effect,又觸發(fā)一次 effect2 的打印。也就是會打印 effect1、effect2、effect1、effect2。
我們測試下:
打印了 effect1、effet2 這是對的,但第三次打印的是 effect2,這說明 obj.a 修改后并沒有執(zhí)行外層函數(shù),而是執(zhí)行的內(nèi)層函數(shù)。為什么呢?
看下這段代碼:
我們執(zhí)行 effect 的時候,會把它賦值給一個全局變量 activeEffect,然后后面收集依賴就用的這個。
當嵌套 effect 的時候,內(nèi)層函數(shù)執(zhí)行后會修改 activeEffect 這樣收集到的依賴就不對了。
怎么辦呢?嵌套的話加一個棧來記錄 effect 不就行了?
也就是這樣:
執(zhí)行 effect 函數(shù)前把當前 effectFn 入棧,執(zhí)行完以后出棧,修改 activeEffect 為棧頂?shù)?effectFn。
這樣就保證了收集到的依賴是正確的。
這種思想的應用還是很多的,需要保存和恢復上下文的時候,都是這樣加一個棧。
我們再測試一下:
現(xiàn)在的打印就對了。至此,我們的響應式系統(tǒng)就算比較完善了。
全部代碼如下:
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()); } })
總結(jié)
響應式就是數(shù)據(jù)變化的時候做一系列聯(lián)動的處理。
核心是這樣一個數(shù)據(jù)結(jié)構(gòu):
最外層是 WeakMap,key 為對象,value 為響應式的 Map。這樣當對象銷毀時,Map 也會銷毀。Map 里保存了每個 key 的依賴集合,用 Set 組織。
我們通過 Proxy 來完成自動的依賴收集,也就是添加 effect 到對應 key 的 deps 的集合里。 set 的時候觸發(fā)所有的 effect 函數(shù)執(zhí)行。
這就是基本的響應式系統(tǒng)。
但是還不夠完善,每次執(zhí)行 effect 前要從上次添加到的 deps 集合中刪掉它,然后重新收集依賴。這樣可以避免因為分支切換產(chǎn)生的無效依賴。并且執(zhí)行 deps 中的 effect 前要創(chuàng)建一個新的 Set 來執(zhí)行,避免 add、delete 循環(huán)起來。此外,為了支持嵌套 effect,需要在執(zhí)行 effect 之前把它推到棧里,然后執(zhí)行完出棧。解決了這幾個問題之后,就是一個完善的 Vue 響應式系統(tǒng)了。當然,現(xiàn)在雖然功能是完善的,但是沒有實現(xiàn) computed、watch 等功能,之后再實現(xiàn)。
最后,再來看一下這個數(shù)據(jù)結(jié)構(gòu),理解了它就理解了 vue 響應式的核心:
到此這篇關(guān)于手寫 Vue3 響應式系統(tǒng)(核心就一個數(shù)據(jù)結(jié)構(gòu))的文章就介紹到這了,更多相關(guān)Vue3 響應式系統(tǒng)內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
vue2基本響應式實現(xiàn)方式之讓數(shù)組也變成響應式
這篇文章主要介紹了vue2基本響應式實現(xiàn)方式之讓數(shù)組也變成響應式問題,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-04-04vue.js內(nèi)部自定義指令與全局自定義指令的實現(xiàn)詳解(利用directive)
這篇文章主要給大家介紹了關(guān)于vue.js內(nèi)部自定義指令與全局自定義指令的實現(xiàn)方法,vue.js中實現(xiàn)自定義指令的主要是利用directive,directive這個單詞是我們寫自定義指令的關(guān)鍵字,需要的朋友們下面跟著小編來一起學習學習吧。2017-07-07使用Vue+MySQL實現(xiàn)登錄注冊的實戰(zhàn)案例
第一次用Vue+MySQL實現(xiàn)注冊登錄功能,就已經(jīng)踩了很多坑,下面這篇文章主要給大家介紹了關(guān)于使用Vue+MySQL實現(xiàn)登錄注冊案例的相關(guān)資料,需要的朋友可以參考下2022-05-05vue 輸入框輸入任意內(nèi)容返回數(shù)字的實現(xiàn)
本文主要介紹了vue 輸入框輸入任意內(nèi)容返回數(shù)字的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2022-03-03