首頁 >web前端 >Vue.js >怎麼手寫Vue3響應式系統

怎麼手寫Vue3響應式系統

WBOY
WBOY轉載
2023-05-14 09:40:051017瀏覽

響應式

首先,什麼是響應式呢?

響應式就是被觀察到的資料變化的時候做一系列連動處理。 就像一個社會熱點事件,當它有訊息更新的時候,各方媒體都會跟進做相關報導。這裡社會熱點事件就是被觀察的目標。那在前端框架裡,這個被觀察的目標是什麼呢?很明顯,是狀態。狀態一般是多個,會透過物件的方式來組織。所以,我們觀察狀態物件的每個 key 的變化,連動做一系列處理就可以了。

我們要維護這樣的資料結構:

怎麼手寫Vue3響應式系統

#狀態物件的每個key 都有關聯的一系列effect 副作用函數,也就是變化的時候聯動執行的邏輯,透過Set 來組織。

每個 key 都是這樣關聯了一系列 effect 函數,那麼多個 key 就可以放到一個 Map 裡維護。

這個 Map 是在物件存在的時候它就存在,而物件銷毀的時候它也要跟著銷毀。 (因為對像都沒了自然也不需要維護每個key 關聯的effect 了)

而WeakMap 正好就有這樣的特性,WeakMap 的key 必須是一個對象,value 可以是任意數據,key 的物件銷毀的時候,value 也會銷毀。

所以,響應式的 Map 會用 Wea​​kMap 來保存,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);

建立出來的資料結構就是圖中的那個:

怎麼手寫Vue3響應式系統

怎麼手寫Vue3響應式系統

然後加入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())
}

基本的響應式完成了,我們測試一下:

怎麼手寫Vue3響應式系統

列印了兩次,第一次是1,第二次是3。 effect 會先執行一次傳入的回呼函數,觸發get 來收集依賴,這時候打印的obj.a 是1然後當obj.a 賦值為3 後,會觸發set,執行收集的依賴,這時候打印obj. a 是3

依賴也正確收集到了:

怎麼手寫Vue3響應式系統

#結果是對的,我們完成了基本的回應式!當然,響應式不會只有這麼點程式碼的,我們現在的實作還不完善,還有一些問題。例如,如果程式碼裡有分支切換,上次執行會依賴 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 的函數了,我們執行試一下:

怎麼手寫Vue3響應式系統

第一次打印2 是對的,也就是走到了第一個分支,打印obj.b

第二次打印nothing 也是對的,這時候走到第二個分支。但第三次印 nothing 就不對了,因為這時候 obj.b 已經沒有依賴函數了,但還是印了。

列印看下 deps,會發現 obj.b 的 deps 沒有清除

怎麼手寫Vue3響應式系統

所以解决方案就是每次添加依赖前清空下上次的 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 收集依赖的时候,也记录一份到这里:

怎麼手寫Vue3響應式系統

这样下次再执行这个 effect 函数的时候,就可以把这个 effect 函数从上次添加到的依赖集合里删掉:

怎麼手寫Vue3響應式系統

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 数组置空。

我们再来测试下:

怎麼手寫Vue3響應式系統

无限循环打印了,什么鬼?

问题出现在这里:

怎麼手寫Vue3響應式系統

set 的时候会执行所有的当前 key 的 deps 集合里的 effect 函数。

而我们执行 effect 函数之前会把它从之前的 deps 集合中清掉:

怎麼手寫Vue3響應式系統

执行的时候又被添加到了 deps 集合。这样 delete 又 add,delete 又 add,所以就无限循环了。

解决的方式就是创建第二个 Set,只用于遍历:

怎麼手寫Vue3響應式系統

这样就不会无限循环了。

再测试一次:

怎麼手寫Vue3響應式系統

现在当 obj.a 赋值为 undefined 之后,再次执行 effect 函数,obj.b 的 deps 集合就被清空了,所以需改 obj.b 也不会打印啥。

看下现在的响应式数据结构:

怎麼手寫Vue3響應式系統

确实,b 的 deps 集合被清空了。那现在的响应式实现是完善的了么?也不是,还有一个问题:

如果 effect 嵌套了,那依赖还能正确的收集么?

首先讲下为什么要支持 effect 嵌套,因为组件是可以嵌套的,而且组件里会写 effect,那也就是 effect 嵌套了,所以必须支持嵌套。

我们嵌套下试试:

effect(() => {
    console.log(&#39;effect1&#39;);
    effect(() => {
        console.log(&#39;effect2&#39;);
        obj.b;
    });
    obj.a;
});
obj.a = 3;

按理说会打印一次 effect1、一次 effect2,这是最开始的那次执行。然后 obj.a 修改为 3 后,会触发一次 effect1 的打印,执行内层 effect,又触发一次 effect2 的打印。也就是会打印 effect1、effect2、effect1、effect2。

我们测试下:

怎麼手寫Vue3響應式系統

打印了 effect1、effet2 这是对的,但第三次打印的是 effect2,这说明 obj.a 修改后并没有执行外层函数,而是执行的内层函数。为什么呢?

看下这段代码:

怎麼手寫Vue3響應式系統

我们执行 effect 的时候,会把它赋值给一个全局变量 activeEffect,然后后面收集依赖就用的这个。

当嵌套 effect 的时候,内层函数执行后会修改 activeEffect 这样收集到的依赖就不对了。

怎么办呢?嵌套的话加一个栈来记录 effect 不就行了?

也就是这样:

怎麼手寫Vue3響應式系統

执行 effect 函数前把当前 effectFn 入栈,执行完以后出栈,修改 activeEffect 为栈顶的 effectFn。

这样就保证了收集到的依赖是正确的。

这种思想的应用还是很多的,需要保存和恢复上下文的时候,都是这样加一个栈。

我们再测试一下:

怎麼手寫Vue3響應式系統

现在的打印就对了。至此,我们的响应式系统就算比较完善了。

全部代码如下:

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());
    }
})

总结

响应式就是数据变化的时候做一系列联动的处理。

核心是这样一个数据结构:

怎麼手寫Vue3響應式系統

最外层是 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響應式系統

以上是怎麼手寫Vue3響應式系統的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:yisu.com。如有侵權,請聯絡admin@php.cn刪除