Vue3響應式對象是如何實現的(2)

前言

在Vue3響應式對象是如何實現的(1)中,我們已經從功能上實現瞭一個響應式對象。如果僅僅滿足於功能實現,我們就可以止步於此瞭。但在上篇中,我們僅考慮瞭最簡單的情況,想要完成一個完整可用的響應式,需要我們繼續對細節深入思考。在特定場景下,是否存在BUG?是否還能繼續優化?

分支切換的優化

在上篇中,收集副作用函數是利用get自動收集。那麼被get自動收集的副作用函數,是否有可能會產生多餘的觸發呢?或者說,我們其實進行瞭多餘的收集呢?同樣,還是從一個例子入手。

let activeEffect
function effect(fn) {
  activeEffect = fn
  fn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true } // (1)
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})
function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  fns && fns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...' // (2)
  console.log('Done!')
}
effect(fn)

這段代碼中,我們做瞭(1)(2)兩處更改。我們在(1)處給響應式對象新增加瞭一個boolean類型的屬性ok,在(2)處我們利用ok的真值,來選擇將誰賦值給document.body.innerText。現在,我們將obj.ok的值置為false,這就意味著,document.body.innerText的值不再依賴於obj.text,而直接取字符串'ops...'

此時,我們要能夠註意到一件事,雖然document.body.innerText的值不再依賴於obj.text瞭,但由於ok的初值是true,也就意味著在ok的值沒有改變時,document.body.innerText的值依賴於obj.text,更進一步說,這個函數已經被obj.text當作自己的副作用函數收集瞭。這會導致什麼呢?

我們更改瞭obj.text的值,這會觸發副作用函數。但此時由於ok的值為false,界面上顯示的內容沒有發生任何改變。也就是說,此時修改obj.text觸發的副作用函數的更新是不必要的。

這部分有些繞,讓我們通過畫圖來嘗試說明。當oktrue時,數據結構的狀態如圖所示:

從圖中可以看到,obj.textobj.ok都收集瞭同一個副作用函數fn。這也解釋瞭為什麼即使我們將obj.ok的值為false,更改obj.text仍然會觸發副作用函數fn

我們希望的理想狀況是,當okfalse時,副作用函數fn被從obj.text的副作用函數收集器中刪除,數據結構的狀態能改變為如下狀態。

這就要求我們能夠在每次執行副作用函數前,將該副作用函數從相關的副作用函數收集器中刪除,再重新建立聯系。為瞭實現這一點,就要求我們記錄哪些副作用函數收集器收集瞭該副作用函數。

let activeEffect
function cleanup(effectFn) { // (3)
  for(let i = 0; i < effectFn.deps.length; i++) {
    const fns = effectFn.deps[i]
    fns.delete(effectFn)
  }
  effectFn.deps.length = 0
}
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = [] // (1)
  effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
  activeEffect.deps.push(fns) // (2)
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  fns && fns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...'
  console.log('Done!')
}
effect(fn)

在這段代碼中,我們增加瞭3處改動。為瞭記錄副作用函數被哪些副作用函數收集器收集,我們在(1)處給每個副作用函數掛載瞭一個deps,用於記錄該副作用函數被誰收集。在(2)處,副作用函數被收集時,我們記錄副作用函數收集器。在(3)處,我們新增瞭cleanup函數,從含有該副作用函數的副作用函數收集器中,刪除該副作用函數。

看上去好像沒啥問題瞭,但是運行代碼會發現產生瞭死循環。問題出在哪呢?

以下面這段代碼為例:

const set = new Set([1])
set.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('Done!')
})

是的,這段代碼會產生死循環。原因是ECMAScript對Set.prototype.forEach的規范中明確,使用forEach遍歷Set時,如果有值被直接添加到該Set上,則forEach會再次訪問該值。

  const effectFn = () => {
    cleanup(effectFn) // (1)
    activeEffect = effectFn
    fn() // (2)
  }

同理,我們的代碼中,當effectFn被執行時,(1)處的cleanup清除副作用函數,就相當於set.delete;而(2)處執行副作用函數fn時,會觸發依賴收集,將副作用函數又加入到瞭副作用函數收集器中,相當於set.add,從而造成死循環。

解決的方法也很簡單,我們隻需要避免在原Set上直接進行遍歷即可。

const set = new Set([1])
const otherSet = new Set(set)
otherSet.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('Done!')
})

在上例中,我們復制瞭setotherset中,otherset僅會執行set.length次。按照這個思路,修改我們的代碼。

let activeEffect

function cleanup(effectFn) { 
  for(let i = 0; i < effectFn.deps.length; i++) {
    const fns = effectFn.deps[i]
    fns.delete(effectFn)
  }
  effectFn.deps.length = 0
}

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn()
  }
  effectFn.deps = [] 
  effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
  get(target, key) {
    track(target, key)
    return target[key]
  },
  set(target, key, newValue) {
    target[key] = newValue
    trigger(target, key)
    return true
  }
})

function track(target, key) {
  if(!activeEffect) return
  let propsMap = objsMap.get(target)
  if(!propsMap) {
    objsMap.set(target, (propsMap = new Map()))
  }
  let fns = propsMap.get(key)
  if(!fns) {
    propsMap.set(key, (fns = new Set()))
  }
  fns.add(activeEffect)
  activeEffect.deps.push(fns) 
}

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  const otherFns = new Set(fns) // (1)
  otherFns.forEach(fn => fn())
}

function fn() {
  document.body.innerText = obj.ok ? obj.text : 'ops...'
  console.log('Done!')
}
effect(fn)

在(1)處我們新增瞭一個otherFns,復制瞭fns用來遍歷。讓我們再來看看結果。

①處,更改obj.ok的值為false,改變瞭頁面的顯示,沒有導致死循環。②處,當obj.okfalse時,副作用函數沒有執行。至此,我們完成瞭針對分支切換場景下的優化。

副作用函數嵌套產生的BUG

我們繼續從功能角度考慮,前面我們的副作用函數還是不夠復雜,實際應用中(如組件嵌套渲染),副作用函數是可以發生嵌套的。

我們舉個簡單的嵌套示例:

let t1, t2
effect(function effectFn1() {
  console.log('effectFn1')
  effect(function effectFn2() {
    console.log('effectFn2')
    t2 = obj.bar
  })
  t1 = obj.foo
})

這段代碼中,我們將effectFn2嵌入瞭effectFn1中,將obj.foo賦值給t1,obj.bar賦值給t2。從響應式的功能上看,如果我們修改obj.foo的值,應該會觸發effectFn1的執行,且間接觸發effectFn2執行。

修改obj.foo的值僅觸發瞭effectFn2的更新,這與我們的預期不符。既然是effect這裡出瞭問題,讓我們再來過一遍effect部分的代碼,看看能不能發現點什麼。

let activeEffect // (1)

function cleanup(effectFn) { 
  for(let i = 0; i < effectFn.deps.length; i++) {
    const fns = effectFn.deps[i]
    fns.delete(effectFn)
  }
  effectFn.deps.length = 0
}

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn
    fn() // (2)
  }
  effectFn.deps = [] 
  effectFn()
}

仔細思考後,不難發現問題所在。我們在(1)處定義瞭一個全局變量activeEffect用於副作用函數註冊,這意味著同一時刻,我們僅能註冊一個副作用函數。在(2)處執行瞭fn,此時註意,在我們給出的副作用函數嵌套示例中,effectFn1是先執行effectFn2,再執行t1 = obj.foo。也就是說,此時activeEffect註冊的副作用函數已經由effectFn1變為瞭effectFn2。因此,當執行到t1 = obj.foo時,track收集的activeEffect已經是被effectFn2覆蓋過的。所以,修改obj.footrigger觸發的就是effectFn2瞭。

要解決這個問題也很簡單,既然後出現的要先被收集,後進先出,用棧解決就好瞭。

let activeEffect
const effectStack = [] // (1)

function cleanup(effectFn) { 
  for(let i = 0; i < effectFn.deps.length; i++) {
    const fns = effectFn.deps[i]
    fns.delete(effectFn)
  }
  effectFn.deps.length = 0
}

function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    activeEffect = effectFn 
    effectStack.push(effectFn)
    fn() // (2)
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  effectFn.deps = [] 
  effectFn()
}

這段代碼中,我們在(1)處定義瞭一個棧effectStack。不管(2)處如何更改activeEffect的內容,都會被effectStack[effectStack.length - 1]回滾到原先正確的副作用函數上。

運行的結果和我們的預期一致,到此為止,我們已經完成瞭對嵌套副作用函數的處理。

自增/自減操作產生的BUG

這裡還存在一個隱蔽的BUG,還和之前一樣,我們修改effect

effect(() => obj.foo++)

很簡單的副作用函數,這會有什麼問題呢?執行一下看看。

很不幸,棧溢出瞭。這個副作用函數僅包含一個obj.foo++,所以可以確定,棧溢出就是由這個自增運算引起的。接下來的問題就是,這麼簡單的自增操作,怎麼會引起棧溢出呢?為瞭更好的說明問題,讓我們先來拆解問題。

effect(() => obj.foo = obj.foo + 1)

這段代碼中obj.foo = obj.foo + 1就等價於obj.foo++。這樣拆開之後問題一下就清楚瞭。這裡同時進行瞭obj.foogetset操作。先讀取obj.foo,收集瞭副作用函數,再設置obj.foo,觸發瞭副作用函數,而這個副作用函數中obj.foo又要被讀取,如此往復,產生瞭死循環。為瞭驗證這一點,我們打印執行的副作用函數。

上面的打印結果印證瞭我們的想法。造成這個BUG的主要原因是,當getset操作同時存在時,我們收集和觸發的都是同一個副作用函數。這裡我們隻需要添加一個守衛條件:當觸發的副作用函數正在被執行時,該副作用函數則不必再被執行。

function trigger(target, key) {
  const propsMap = objsMap.get(target)
  if(!propsMap) return
  const fns = propsMap.get(key)
  const otherFns = new Set()
  fns && fns.forEach(fn => {
    if(fn !== activeEffect) { // (1)
      otherFns.add(fn)
    }
  })
  otherFns.forEach(fn => fn())
}

如此一來,相同的副作用函數僅會被觸發一次,避免瞭產生死循環。最後,我們驗證一下即可。

到此這篇關於Vue3響應式對象是如何實現的的文章就介紹到這瞭,更多相關Vue3響應式對象內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: