巧妙實現一個Vue3的響應式系統,深入理解核心問題

語言: CN / TW / HK

highlight: a11y-light theme: channing-cyan


目標

看完之後你能明白Vue3響應式的底層,以及手動實現一個簡單版本的響應式系統。

Vue3 和 Vue2的響應式對比

1.在以往的Vue2設計中Object.defineProperty監聽數據中整個對象時,對象的嵌套是需要不斷Object.keys去遍歷來解決響應問題,而proxy可以在嵌套對象中遞歸到對應的區域直接使用Reflect.get獲取。

2.其次Object.defineProperty是無法對數組增刪改查,對象的屬性修改,增加屬性等,在實際開發中非常容易遇到,Vue2的設計者通過變異方法重寫了常見的數組使其能夠監聽數組,通過$set來監聽對象操作。proxy就不存在這個問題。

3.同時Proxy的handler內部的api多達十幾種,拓展性更強,但是不兼容IE。

基本實現

首先我們需要監聽一個數據,我們平常開發會經常改變數據裏面的屬性,我們需要將所有被這些屬性依賴的副作用函數都收集在一個容器中,那麼只要數據改變時,就要將其中所有的副作用函數執行。同時這也能幫助我們在拆分組件不同依賴,也能關聯到對應的數據產生影響。像vue3的很多api基於Proxy的代理對象實現,如compoted,watch等都是以此為基礎擴展。

const obj = {text: 'hello'} let a = null function effect() { a = obj.text } obj就是我們要響應式的數據,effect就是我們的監聽屬性改變後執行的業務代碼,目標:我們需要的就是一旦我obj.text的值改變,那麼就調用effect。

基於proxy的簡易響應式

``` // 你需要收集所有依賴的副作用函數的集合 const bucket = new Set()

const data = {text: 'hello world'} const obj = new Proxy(data, { get(target, key) { bucket.add(effect) return target[key] }, set(target, key, newVal) { target[key] = newVal bucket.forEach(fn => fn()) return true } })

let a = null const effect = () => { a = obj.text } effect()

setTimeout(() => { obj.text = 'change' console.log(a); // 一旦你改變text,對應的effect會執行改變a為change }, 1000); ``` 本質就是通過proxy代理的obj對象來實現get,set的監聽,並將函數名為effect對應的依賴放入Set數據結構的容器中。

問題1: 如何解決effect副作用函數的命名衝突?

let activeEffect //全局依賴的副作用函數 function effect(fn) { activeEffect = fn fn() } 那麼我們改寫下effect就可以了,將依賴都傳遞統一收集到這個effect函數,本質就是一個閉包,此時我們只需要關注activeEffect,根本不需要關注命名問題。

問題2:改變不存在的屬性,effect竟然也執行了

這個問題就涉及到數據結構的問題了,Set結構作為收集依賴的容器,沒有處理以下問題: 1. 同個對象的同個屬性依賴多個函數 2. 同個對象的不同屬性依賴同個函數 3. 不同對象的不同屬性依賴同個函數 4. ....... 那麼其實我們只需要一個樹狀的結構,給每個屬性設立單獨的effectFnList,任何數據的變化只需要遍歷對應屬性內部的函數執行就可以了。所以對應的數據結構關係是 不同的target-----多個key-----多個effect集合

完善的響應式

``` const bucket = new WeakMap()

let activeEffect function effect (fn) { activeEffect = fn fn() } const data = { text: 'hello world' } const obj = new Proxy(data, { //獲取數據時 get (target, key) { if (!activeEffect) return

//獲取對應的數據結構  taraget------key--------effectFn
let depsMap = bucket.get(target)
if (!depsMap) {
  bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
  depsMap.set(key, (deps = new Set()))
}
//放入依賴
deps.add(activeEffect)
return target[key]

}, //修改數據時 set (target, key, newVal) { target[key] = newVal let depsMap = bucket.get(target) if (!depsMap) return let effects = depsMap.get(key) effects && effects.forEach(fn => fn()) } }) let a = null const effectFn = () => { a = obj.text } effect(effectFn)

setTimeout(() => { obj.text2 = 'change' //修改不存在屬性,不會影響 console.log(a); }, 1000);
```

知識點: 容器為什麼要用WeakMap而不是Map數據結構呢?

先下結論:WeakMap的最大作用是避免了內存溢出,即插即拔,用完了就讓自動被垃圾回收器回收。而map如果不手動清除,那麼會一直佔用內存

關於WeakMap你需要知道的:

  1. WeakMap通過弱引用解決內存佔用,不用手動釋放。
  2. WeakMap是map的實例,map有的api,它也有
  3. WeakMap一旦執行完退出作用域他是會被回收的,以這個特性可以用在dom卸載、定時器、閉包等中使用,要注意此時外部環境無法引用

關於Map你需要知道的:

  1. Map字典集合出現是為了應為不同類型的數據存儲的問題,symbol,set,函數都可以作為其key值,當然在大量處理屬性的時候其性能會高很多

其實這裏容器用WeakMap,本質其實為了性能,減少不必要的釋放內存操作。同時也能使用更多不同數據結構充當key值。

問題3: 如果在effect函數有分支語句,依賴的集合竟然全都收集了!

``` const bucket = new WeakMap()

let activeEffect //解決重命名, 將函數 function effect (fn) { //effectFn對應每個屬性的諾幹個依賴 const effectFn = () => { //當這個函數執行,將此為依賴的副作用函數 cleanup(effectFn) //完成清除工作 activeEffect = effectFn fn() } effectFn.deps = [] //直接在函數上掛載deps數組 console.log(effectFn); effectFn() } const data = { text: 'hello world', ok: true } const obj = new Proxy(data, { //獲取數據時 get (target, key) { if (!activeEffect) return

//獲取對應的數據  taraget------key--------effectFn
let depsMap = bucket.get(target)
if (!depsMap) {
  bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
  depsMap.set(key, (deps = new Set()))
}
//放入依賴
deps.add(activeEffect)

activeEffect.deps.push(deps)  //將對應的依賴放到全局的activeEffect的effect收集起來
return target[key]

}, //修改數據時 set (target, key, newVal) { console.log("好像互不影響了"); target[key] = newVal let depsMap = bucket.get(target) if (!depsMap) return let effects = depsMap.get(key) //effects && effects.forEach(fn => fn()) 需要注意這裏的effect執行會響應式死循環 //我們需要再創建一個深拷貝的數據 const effectsToRun = new Set(effects) effectsToRun.forEach(fn => fn()) } }) let a = null const effectFn = () => { a = obj.ok ? obj.text : 'not' //分支語句影響 } effect(effectFn)

setTimeout(() => { obj.text2 = 'change' console.log(a); }, 1000);

//每次執行前將對應依賴清空 function cleanup (effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] //這裏的每一項是一個set類型 deps.delete(effectFn) } effectFn.deps.length = 0 } ``` 當我們在effectFn副作用函數中,內部的三元表達式執行obj.ok和obj.text其實都會觸發get讀取,直接導致後期我如果改變了值,那麼就會導致兩個effectFn觸發。這裏主要的解決方案就是每次我get監聽到數據的變化時,我用一個deps數組去記錄所有的依賴,只要在被其他干擾的副作用函數執行的時候清空對應的deps,這裏的清空用cleanup函數執行,那麼就不會影響到了。

問題4 組件嵌套時的響應式為什麼跟我想的渲染不一樣?

組件渲染的多級嵌套,在render函數渲染的初始化中,我們一旦某個層級內部某個屬性修改,我們只會觸發對應依賴各個層級的effect函數集合去執行,但是當我修改外部層級的屬性,反而讓最深處的effect執行了。

effect(() => { Foo1.render() //當我修改Foo組件內的屬性,反而內部Foo2組件的effect重新執行了 effect(() => { Foo2.render() }) }) 問題的本質就是activeEffect這個變量一直是全局變量,一旦內部響應式觸發收集依賴那麼activeEffect就被覆蓋了,所以這裏一定通過一個棧的結構,先進後出。剛進入函數執行推入棧中,一旦執行完畢就從棧頂移除,同時將activeEffect改成當前棧頂元素。下面是代碼實現:

``` let activeEffect let effectStack = [] // 這裏用一個棧結構

function effect (fn) { function effectFn () { cleanup(effectFn) activeEffect = effectFn //將依賴掛載到全局 effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] }

effectFn.deps = [] //將對應的依賴收集到deps數組中 effectFn() } ```

最終實現的響應式

```

const bucket = new WeakMap() //存儲被註冊的副作用函數(全局狀態下) let activeEffect //解決重命名, 將函數 function effect (fn) { //effectFn對應每個屬性的諾幹個依賴 const effectFn = () => { //當這個函數執行,將此為依賴的副作用函數 cleanup(effectFn) //完成清除工作 activeEffect = effectFn fn() } effectFn.deps = [] //依賴函數掛載deps數組 effectFn() } const data = { text: 1, ok: true } const obj = new Proxy(data, { //獲取數據時 get (target, key) { track(target, key) return target[key] }, //修改數據時 set (target, key, newVal) { target[key] = newVal trigger(target, key) } })

//每次執行前將對應依賴清空 function cleanup (effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] //這裏的每一項是一個set類型 deps.delete(effectFn) } effectFn.deps.length = 0 }

//get攔截函數內調用track函數追蹤變化 function track (target, key) { if (!activeEffect) return //獲取對應的數據 taraget------key--------effectFn let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } //放入依賴 deps.add(activeEffect) activeEffect.deps.push(deps) //將對應的依賴放到全局的activeEffect的effect收集起來 }

//set攔截函數內調用trigger函數追蹤變化 function trigger (target, key) { let depsMap = bucket.get(target) if (!depsMap) return let effects = depsMap.get(key)

const effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { //防止棧溢出 effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => effectFn()) }

const effectFn = () => { let a = obj.text, b = obj.text obj.text = obj.text + 1 //最終執行代碼後,這裏deps會存儲3個effectFn } effect(effectFn) ```

總結

最終核心的響應式中,將trigger和track的函數抽離,單獨處理數據的存儲和執行。 在effectFn函數執行中我們能發現,讀取了3次的get操作,set執行一次。最終會連續執行3次。到目前為止完成的響應式系統還有很多欠缺的地方,比如如何使其給用户對應的option配置修改依賴的執行順序,次數等等,如果多次重複執行的依賴函數那麼我如何使其只執行最後一次的變更.....