新聞中心
而且不只是 Vue,狀態(tài)管理庫 Mobx 也是基于響應式實現(xiàn)的。

那響應式是具體怎么實現(xiàn)的呢?
與其空談原理,不如讓我們來手寫一個簡易版吧。
響應式
首先,什么是響應式呢?
響應式就是被觀察的數(shù)據(jù)變化的時候做一系列聯(lián)動處理。
就像一個社會熱點事件,當它有消息更新的時候,各方媒體都會跟進做相關報道。
這里社會熱點事件就是被觀察的目標。
那在前端框架里,這個被觀察的目標是什么呢?
很明顯,是狀態(tài)。
狀態(tài)一般是多個,會通過對象的方式來組織。所以,我們觀察狀態(tài)對象的每個 key 的變化,聯(lián)動做一系列處理就可以了。
我們要維護這樣的數(shù)據(jù)結構:
狀態(tài)對象的每個 key 都有關聯(lián)的一系列 effect 副作用函數(shù),也就是變化的時候聯(lián)動執(zhí)行的邏輯,通過 Set 來組織。
每個 key 都是這樣關聯(lián)了一系列 effect 函數(shù),那多個 key 就可以放到一個 Map 里維護。
這個 Map 是在對象存在的時候它就存在,對象銷毀的時候它也要跟著銷毀。(因為對象都沒了自然也不需要維護每個 key 關聯(lián)的 effect 了)
而 WeakMap 正好就有這樣的特性,WeakMap 的 key 必須是一個對象,value 可以是任意數(shù)據(jù),key 的對象銷毀的時候,value 也會銷毀。
所以,響應式的 Map 會用 WeakMap 來保存,key 為原對象。
這個數(shù)據(jù)結構就是響應式的核心數(shù)據(jù)結構了。
比如這樣的狀態(tài)對象:
const obj = {
a: 1,
b: 2
}它的響應式數(shù)據(jù)結構就是這樣的:
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ù)結構就是圖中的那個:
然后添加 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)的依賴關系,所以很容易想到可以代理狀態(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í)行傳入的回調函數(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í)行一次傳入的回調函數(shù),觸發(fā) get 來收集依賴,這時候打印的 obj.a 是 1
然后當 obj.a 賦值為 3 后,會觸發(fā) set,執(zhí)行收集的依賴,這時候打印 obj.a 是 3
依賴也正確收集到了:
結果是對的,我們完成了基本的響應式!
當然,響應式不會只有這么點代碼的,我們現(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ù)關聯(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ù)結構:
確實,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í)行內層 effect,又觸發(fā)一次 effect2 的打印。
也就是會打印 effect1、effect2、effect1、effect2。
我們測試下:
打印了 effect1、effet2 這是對的,但第三次打印的是 effect2,這說明 obj.a 修改后并沒有執(zhí)行外層函數(shù),而是執(zhí)行的內層函數(shù)。
為什么呢?
看下這段代碼:
我們執(zhí)行 effect 的時候,會把它賦值給一個全局變量 activeEffect,然后后面收集依賴就用的這個。
當嵌套 effect 的時候,內層函數(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());
}
})總結
響應式就是數(shù)據(jù)變化的時候做一系列聯(lián)動的處理。
核心是這樣一個數(shù)據(jù)結構:
最外層是 WeakMap,key 為對象,value 為響應式的 Map。這樣當對象銷毀時,Map 也會銷毀。
Map 里保存了每個 key 的依賴集合,用 Set 組織。
我們通過 Proxy 來完成自動的依賴收集,也就是添加 effect 到對應 key 的 deps 的集合里。set 的時候觸發(fā)所有的 effect 函數(shù)執(zhí)行。
這就是基本的響應式系統(tǒng)。
但是還不夠完善,每次執(zhí)行 effect 前要從上次添加到的 deps 集合中刪掉它,然后重新收集依賴。這樣可以避免因為分支切換產生的無效依賴。
并且執(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ù)結構,理解了它就理解了 vue 響應式的核心:
分享文章:手寫Vue3響應式系統(tǒng):核心就一個數(shù)據(jù)結構
轉載來源:http://m.fisionsoft.com.cn/article/dpisohi.html


咨詢
建站咨詢
