Vue3 中的數據偵測

在10月05日凌晨Vue3的源代碼正式發佈了,來自官方的消息:vue

vue3.0-publish.png

目前的版本是 Pre-Alpha , 倉庫地址: Vue-next, 能夠經過 Composition API瞭解更多新版本的信息, 目前版本單元測試相關狀況 vue-next-coveragereact

文章大綱:git

outline.png

Vue 的核心之一就是響應式系統,經過偵測數據的變化,來驅動更新視圖。github

實現可響應對象的方式

經過可響應對象,實現對數據的偵測,從而告知外界數據變化。實現可響應對象的方式:typescript

  1. getter 和 setter
  2. defineProperty
  3. Proxy

關於前兩個 API 的使用方式很少贅述,單一的訪問器 getter/setter 功能相對簡單,而做爲 Vue2.x 實現可響應對象的 API - defineProperty , API 自己存在較多問題。api

Vue2.x 中,實現數據的可響應,須要對 ObjectArray 兩種類型採用不一樣的處理方式。 Object 類型經過 Object.defineProperty 將屬性轉換成 getter/setter ,這個過程須要遞歸偵測全部的對象 key,來實現深度的偵測。數組

爲了感知 Array 的變化,對 Array 原型上幾個改變數組自身的內容的方法作了攔截,雖然實現了對數組的可響應,但一樣存在一些問題,或者說不夠方便的狀況。 同時,defineProperty 經過遞歸實現 getter/setter 也存在必定的性能問題。函數

更好的實現方式是經過 ES6 提供的 Proxy API性能

Proxy API 的一些細節

Proxy API 具備更增強大的功能, 相比舊的 defineProperty API ,Proxy 能夠代理數組,而且 API 提供了多個 traps ,能夠實現諸多功能。單元測試

這裏主要說兩個trap: getset , 以及其中的一些比較容易被忽略的細節。

細節一:trap 默認行爲

let data = { foo: 'foo' }
let p = new Proxy(data, {
  get(target, key, receiver) {
    return target[key]
  },
  set(target, key, value, receiver) {
    console.log('set value')
    target[key] = value // ?
  }
})

p.foo = 123

// set value
複製代碼

經過 proxy 返回的對象 p 代理了對原始數據的操做,當對 p 設置時,即可以偵測到變化。可是這麼寫其實是有問題, 當代理的對象數據是數組時,會報錯。

let data = [1,2,3]
let p = new Proxy(data, {
  get(target, key, receiver) {
    return target[key]
  },
  set(target, key, value, receiver) {
    console.log('set value')
    target[key] = value
  }
})

p.push(4) // VM438:12 Uncaught TypeError: 'set' on proxy: trap returned falsish for property '3'
複製代碼

將代碼更改成:

let data = [1,2,3]
let p = new Proxy(data, {
  get(target, key, receiver) {
    return target[key]
  },
  set(target, key, value, receiver) {
    console.log('set value')
    target[key] = value
    return true
  }
})

p.push(4)

// set value // 打印2次
複製代碼

實際上,當代理對象是數組,經過 push 操做,並不僅是操做當前數據,push 操做還觸發數組自己其餘屬性更改。

let data = [1,2,3]
let p = new Proxy(data, {
  get(target, key, receiver) {
    console.log('get value:', key)
    return target[key]
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    target[key] = value
    return true
  }
})

p.push(1)

// get value: push
// get value: length
// set value: 3 1
// set value: length 4
複製代碼

先看 set 操做,從打印輸出能夠看出,push 操做除了給數組的第 3 位下標設置值 1 ,還給數組的 length 值更改成 4同時這個操做還觸發了 get 去獲取 pushlength 兩個屬性。

咱們能夠經過 Reflect 來返回 trap 相應的默認行爲,對於 set 操做相對簡單,可是一些比較複雜的默認行爲處理起來相對繁瑣得多,Reflect 的做用就顯現出來了。

let data = [1,2,3]
let p = new Proxy(data, {
  get(target, key, receiver) {
    console.log('get value:', key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    return Reflect.set(target, key, value, receiver)
  }
})

p.push(1)

// get value: push
// get value: length
// set value: 3 1
// set value: length 4
複製代碼

相比本身處理 set 的默認行爲,Reflect 就方便得多。

細節二:屢次觸發 set / get

從前面的例子中能夠看出,當代理對象是數組時,push 操做會觸發屢次 set 執行,同時,也引起 get 操做,這點很是重要,vue3 就很好的使用了這點。 咱們能夠從另外一個例子來看這個操做:

let data = [1,2,3]
let p = new Proxy(data, {
  get(target, key, receiver) {
    console.log('get value:', key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    return Reflect.set(target, key, value, receiver)
  }
})

p.unshift('a')

// get value: unshift
// get value: length
// get value: 2
// set value: 3 3
// get value: 1
// set value: 2 2
// get value: 0
// set value: 1 1
// set value: 0 a
// set value: length 4
複製代碼

能夠看到,在對數組作 unshift 操做時,會屢次觸發 getset 。 仔細觀察輸出,不難看出,get 先拿數組最末位下標,開闢新的下標 3 存放原有的末位數值,而後再將原數值都日後挪,將 0 下標設置爲了 unshift 的值 a ,由此引起了屢次 set 操做。

而這對於 通知外部操做 顯然是不利,咱們假設 set 中的 console 是觸發外界渲染的 render 函數,那麼這個 unshift 操做會引起 屢次 render

咱們後面會講述如何解決相應的這個問題,繼續。

細節三:proxy 只能代理一層

let data = { foo: 'foo', bar: { key: 1 }, ary: ['a', 'b'] }
let p = new Proxy(data, {
  get(target, key, receiver) {
	  console.log('get value:', key)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log('set value:', key, value)
    return Reflect.set(target, key, value, receiver)
  }
})

p.bar.key = 2

// get value: bar
複製代碼

執行代碼,能夠看到並無觸發 set 的輸出,反而是觸發了 get ,由於 set 的過程當中訪問了 bar 這個屬性。 因而可知,proxy 代理的對象只能代理到第一層,而對象內部的深度偵測,是須要開發者本身實現的。一樣的,對於對象內部的數組也是同樣。

p.ary.push('c')

// get value: ary
複製代碼

一樣只走了 get 操做,set 並不能感知到。

咱們注意到 get/set 還有一個參數:receiver ,對於 receiver ,其實接收的是一個代理對象:

let data = { a: {b: {c: 1 } } }
let p = new Proxy(data, {
  get(target, key, receiver) {
    console.log(receiver)
	  const res = Reflect.get(target, key, receiver)
    return res
  },
  set(target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  }
})

// Proxy {a: {…}}
複製代碼

這裏 receiver 輸出的是當前代理對象,注意,這是一個已經代理後的對象。

let data = { a: {b: {c: 1 } } }
let p = new Proxy(data, {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    console.log(res)
    return res
  },
  set(target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  }
})

// {b: {c: 1} }

複製代碼

當咱們嘗試輸出 Reflect.get 返回的值,會發現,當代理的對象是多層結構時,Reflect.get 會返回對象的內層結構。

記住這一點,Vue3 實現深度的proxy ,即是很好的使用了這點

解決 proxy 中的細節問題

前面提到了使用 Proxy 來偵測數據變化,有幾個細節問題,包括:

  1. 使用 Reflect 來返回 trap 默認行爲
  2. 對於 set 操做,可能會引起代理對象的屬性更改,致使 set 執行屢次
  3. proxy 只能代理對象中的一層,對於對象內部的操做 set 未能感知,可是 get 會被執行

接下來,咱們將先本身嘗試解決這些問題,後面再分析 Vue3 是如何解決這些細節的。

setTimeout 解決重複 trigger

function reactive(data, cb) {
  let timer = null
  return new Proxy(data, {
    get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        cb && cb()
      }, 0);
      return Reflect.set(target, key, value, receiver)
    }
  })
}

let ary = [1, 2]
let p = reactive(ary, () => {
  console.log('trigger')
})
p.push(3)

// trigger
複製代碼

程序輸出結果爲一個: trigger

這裏實現了 reactive 函數,接收兩個參數,第一個是被代理的數據 data ,還有一個回調函數 cb, 咱們這裏先簡單的在 cb 中打印 trigger 操做,來模擬通知外部數據的變化。

解決重複的 cb 調用有不少中方式,比方經過標誌,來決定是否調用。而這裏是使用了定時器 setTimeout , 每次調用 cb 以前,都清除定時器,來實現相似於 debounce 的操做,一樣能夠解決重複的 callback 問題。

解決數據深度偵測

目前還有一個問題,那即是深度的數據偵測,咱們可使用遞歸代理的方式來實現:

function reactive(data, cb) {
  let res = null
  let timer = null

  res = data instanceof Array ? []: {} 

  for (let key in data) {
    if (typeof data[key] === 'object') {
      res[key] = reactive(data[key], cb)
    } else {
      res[key] = data[key]
    }
  }

  return new Proxy(res, {
    get(target, key) {
      return Reflect.get(target, key)
    },
    set(target, key, val) {
      let res = Reflect.set(target, key, val)
      clearTimeout(timer)
      timer = setTimeout(() => {
        cb && cb()
      }, 0)
      return res
    }
  })
}

let data = { foo: 'foo', bar: [1, 2] }
let p = reactive(data, () => {
  console.log('trigger')
})
p.bar.push(3)

// trigger
複製代碼

對代理的對象進行遍歷,對每一個 key 都作一次 proxy,這是遞歸實現的方式。 同時,結合前面提到的 timer 避免重複 set 的問題。

這裏咱們能夠輸出代理後的對象 p

p.png

能夠看到深度代理後的對象,都攜帶 proxy 的標誌。

到這裏,咱們解決了使用 proxy 實現偵測的系列細節問題,雖然這些處理方式能夠解決問題,但彷佛並不夠優雅,尤爲是遞歸 proxy 是一個性能隱患, 當數據對象比較大時,遞歸的 proxy 會消耗比較大的性能,而且有些數據並不是須要偵測,咱們須要對數據偵測作更細的控制。

接下來咱們就看下 Vue3 是如何使用 Proxy 實現數據偵測的。

Vue3 中的 reactivity

Vue3 項目結構採用了 lernamonorepo 風格的代碼管理,目前比較多的開源項目切換到了 monorepo 的模式, 比較顯著的特徵是項目中會有個 packages/ 的文件夾。

Vue3 對功能作了很好的模塊劃分,同時使用 TS 。咱們直接在 packages 中找到響應式數據的模塊:

reactivity.png

其中,reactive.ts 文件提供了 reactive 函數,該函數是實現響應式的核心。 同時這個函數也掛載在了全局的 Vue 對象上。

這裏對源代碼作一點程度的簡化:

const rawToReactive = new WeakMap()
const reactiveToRaw = new WeakMap()

// utils
function isObject(val) {
  return typeof val === 'object'
}

function hasOwn(val, key) {
  const hasOwnProperty = Object.prototype.hasOwnProperty
  return hasOwnProperty.call(val, key)
}

// traps
function createGetter() {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    return isObject(res) ? reactive(res) : res
  }
}

function set(target, key, val, receiver) {
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]

  val = reactiveToRaw.get(val) || val
  const result = Reflect.set(target, key, val, receiver)

  if (!hadKey) {
    console.log('trigger ...')
  } else if(val !== oldValue) {
    console.log('trigger ...')
  }

  return result
}

// handler
const mutableHandlers = {
  get: createGetter(),
  set: set,
}

// entry
function reactive(target) {
  return createReactiveObject(
    target,
    rawToReactive,
    reactiveToRaw,
    mutableHandlers,
  )
}

function createReactiveObject(target, toProxy, toRaw, baseHandlers) {
  let observed = toProxy.get(target)
  // 原數據已經有相應的可響應數據, 返回可響應數據
  if (observed !== void 0) {
    return observed
  }
  // 原數據已是可響應數據
  if (toRaw.has(target)) {
    return target
  }
  observed = new Proxy(target, baseHandlers)
  toProxy.set(target, observed)
  toRaw.set(observed, target)
  return observed
}
複製代碼

rawToReactivereactiveToRaw 是兩個弱引用的 Map 結構,這兩個 Map 用來保存 原始數據可響應數據 ,在函數 createReactiveObject 中,toProxytoRaw 傳入的即是這兩個 Map

咱們能夠經過它們,找到任何代理過的數據是否存在,以及經過代理數據找到原始的數據。

除了保存了代理的數據和原始數據,createReactiveObject 函數僅僅是返回了 new Proxy 代理後的對象。 重點在 new Proxy 中傳入的handler參數 baseHandlers

還記得前面提到的 Proxy 實現數據偵測的細節問題吧,咱們嘗試輸入:

let data = { foo: 'foo', ary: [1, 2] }
let r = reactive(data)
r.ary.push(3)
複製代碼

打印結果:

console.png

能夠看到打印輸出了一次 trigger ...

問題一:如何作到深度的偵測數據的 ?

深度偵測數據是經過 createGetter 函數實現的,前面提到,當對多層級的對象操做時,set 並不能感知到,可是 get 會觸發, 於此同時,利用 Reflect.get() 返回的「多層級對象中內層」 ,再對「內層數據」作一次代理。

function createGetter() {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    return isObject(res) ? reactive(res) : res
  }
}
複製代碼

能夠看到這裏判斷了 Reflect 返回的數據是否仍是對象,若是是對象,則再走一次 proxy從而得到了對對象內部的偵測

而且,每一次的 proxy 數據,都會保存在 Map 中,訪問時會直接從中查找,從而提升性能。

當咱們打印代理後的對象時:

r.png

能夠看到這個代理後的對象內層並無代理的標誌,這裏僅僅是代理外層對象。

輸出其中一個存儲代理數據的 rawToReactive

rawToReactive.png

對於內層 ary: [1, 2] 的代理,已經被存儲在了 rawToReactive 中。

由此實現了深度的數據偵測。

問題二:如何避免屢次 trigger ?

function hasOwn(val, key) {
  const hasOwnProperty = Object.prototype.hasOwnProperty
  return hasOwnProperty.call(val, key)
}
function set(target, key, val, receiver) {
  console.log(target, key, val)
  const hadKey = hasOwn(target, key)
  const oldValue = target[key]
  
  val = reactiveToRaw.get(val) || val
  const result = Reflect.set(target, key, val, receiver)

  if (!hadKey) {
    console.log('trigger ... is a add OperationType')
  } else if(val !== oldValue) {
    console.log('trigger ... is a set OperationType')
  }

  return result
}
複製代碼

關於屢次 trigger 的問題,vue 處理得很巧妙。

set 函數中 hasOwn 前打印 console.log(target, key, val)

輸入:

let data = ['a', 'b']
let r = reactive(data)
r.push('c')
複製代碼

輸出結果:

hasOwn.png

r.push('c') 會觸發 set 執行兩次,一次是值自己 'c' ,一次是 length 屬性設置。

設置值 'c' 時,傳入的新增索引 key2target 是原始的代理對象 ['a', 'c']hasOwn(target, key) 顯然返回 false ,這是一個新增的操做,此時能夠執行 trigger ... is a add OperationType

當傳入 keylength 時,hasOwn(target, key)length 是自身屬性,返回 true,此時判斷 val !== oldValue , val3, 而 oldValue 即爲 target['length'] 也是 3,此時不執行 trigger 輸出語句。

因此經過 判斷 key 是否爲 target 自身屬性,以及設置val是否跟target[key]相等 能夠肯定 trigger 的類型,而且避免多餘的 trigger

總結

實際上本文主要集中講解 Vue3 中是如何使用 Proxy 來偵測數據的。 而在分析源碼以前,須要講清楚 Proxy 自己的一些特性,因此講了不少 Proxy 的前置知識。同時,咱們也經過本身的方式來解決這些問題。

最後,咱們對比了 Vue3 中, 是如何處理這些細節的。能夠看出,Vue3 並不是簡單的經過 Proxy 來遞歸偵測數據, 而是經過 get 操做來實現內部數據的代理,而且結合 WeakMap 來對數據保存,這將大大提升響應式數據的性能。

有興趣的小夥伴能夠針對 遞歸Proxy 和 這種Vue3的這種實現方式作相應的 benchmark , 這二者的性能差距比較大。

文章仍是對 reactive 作了很大程度的簡化,實際上要處理的細節要複雜得多。 更多的細節仍是須要查看源碼得到。

相關文章
相關標籤/搜索