手寫 Vue3 響應式系統:核心就一個數據結構

響應式是 Vue 的特色,如果你簡歷裏寫了 Vue 項目,那基本都會問響應式實現原理。

而且不只是 Vue,狀態管理庫 Mobx 也是基於響應式實現的。

那響應式是具體怎麼實現的呢?

與其空談原理,不如讓我們來手寫一個簡易版吧。

響應式

首先,什麼是響應式呢?

響應式就是被觀察的數據變化的時候做一系列聯動處理。

就像一個社會熱點事件,當它有消息更新的時候,各方媒體都會跟進做相關報道。

這裏社會熱點事件就是被觀察的目標。

那在前端框架裏,這個被觀察的目標是什麼呢?

很明顯,是狀態。

狀態一般是多個,會通過對象的方式來組織。所以,我們觀察狀態對象的每個 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 響應式的核心:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/U3oSWF1OfhlAU1x9EnQ-sw