為什麼Vue3.0 不再使用defineProperty實現資料監聽

高壓郭 發表於 2021-09-25
Vue

其實這個問題很多文章都有寫,也是面試的高頻題目,這裡僅僅是記錄下自己的理解。

ProxyObject.defineproperty的區別

  1. Object.defineProperty只能劫持物件的屬性,對於巢狀的物件還需要進行深度的遍歷;而Proxy是直接代理整個物件
  2. Object.defineProperty對新增的屬性需要手動的Observe(使用$set);Proxy可以攔截到物件新增的屬性,陣列的pushshiftsplice也能攔截到
  3. Proxy具有13種攔截操作,這是defineProperty不具有的
  4. Proxy 相容性差 IE瀏覽器不支援很多種Proxy的方法 目前還沒有完整的polyfill方案

defineProperty寫法;

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
     get: function defineGet() {
      console.log(`get key: ${key} value: ${value}`)
      return value
    },
     set: function defineSet(newVal) {
      console.log(`set key: ${key} value: ${newVal}`)
      value = newVal
    }
  })
}
function observe(data) {
  Object.keys(data).forEach(function(key) {
    // 遞迴的getter setter
    defineReactive(data, key, data[key])
  })
}

Proxy的寫法:

let proxyObj = new Proxy(data, {
    get(key) {
        return data[key]
    },
    set(key, value) {
        data[key] = value
    }
})

當然還有其他的屬性,這裡寫最簡單的。

這兩個方法的區別讓我想到了事件代理

<ul id="ul">
    <li>111</li>
    <li>222</li>
    <li>333</li>
    <li>444</li>
</ul>

如果沒有使用事件代理,那麼它會給ul下的每個li繫結事件,這樣寫有個問題就是,新增的li是沒有事件的,事件沒有一起新增進去。
如果是使用事件代理,那麼新新增的子節點也會有事件響應,因為它是通過觸發代理節點(父節點 冒泡)來觸發事件的
非常類似,這裡想要說明的是:defineProperty是在本身自己的物件屬性上做getter/setter, 而Proxy返回的是一個代理物件,只有修改代理物件才會發生響應式,如果修改原來的物件屬性,並不會產生響應式更新.

Object.defineProperty對陣列的處理

查閱vue官方文件 我們能看到:

Vue 不能檢測以下陣列的變動:

1、當你利用索引直接設定一個陣列項時,例如:vm.items[indexOfItem] = newValue
2、當你修改陣列的長度時,例如:vm.items.length = newLength

對於第一點:
有一些文章直接寫

Object.defineProperty有一個缺陷是無法監聽到陣列的變化,導致直接通過陣列的下標給陣列設定值,不能實時響應

這種說法是錯誤的,事實上Object.defineProperty是可以監聽到陣列下標的變化,只是在Vue的實現中,從效能/體驗的價效比考慮,放棄了這個特性.
對於陣列下的索引是可以用getter/setter 的,

image.png

但是vue為什麼沒這麼做?如果監聽索引值,通過pushunshift新增進來的元素的索引還沒被劫持,也不是響應式的,需要手動的進行observe,通過popshift刪除元素,會刪除並更新索引,也能觸發響應式,但是陣列經常會被遍歷,會觸發很多次索引的getter 效能不是很好。

對於第二點:
MDN:

陣列的 length 屬性重定義是可能的,但是會受到一般的重定義限制。(length 屬性初始為 non-configurable,non-enumerable 以及 writable。對於一個內容不變的陣列,改變其 length 屬性的值或者使它變為 non-writable 是可能的。但是改變其可列舉性和可配置性或者當它是 non-writable 時嘗試改變它的值或是可寫性,這兩者都是不允許的。)然而,並不是所有的瀏覽器都允許 Array.length 的重定義。

image.png

image.png

所以對於陣列的length,無法對它的訪問器屬性進行getset,所以沒法進行響應式的更新.

這裡注意下有兩個概念:索引 和 下標
陣列有下標,但是對應的下標可能沒有索引值!

arr = [1,2]
arr.length = 5
arr[4] // empty 下標為4,值為empty,索引值不存在。 for..in 不會遍歷出索引值不存在的元素

手動賦值length為一個更大的值,此時長度會更新,但是對應的索引不會被賦值,也就是物件的屬性沒有,defineProperty無法處理對未知屬性的監聽,舉個例子:length = 5的陣列,未必索引就有4,這個索引(屬性)不存在,就沒法setter了。

陣列的索引跟物件的鍵表現其實是一致的.

vue對陣列進行了單獨處理, 對其進行劫持重寫,
看一個陣列劫持的demo:

const arrayProto = Array.prototype

// 以arrayProto為原型的空物件
const arrayMethods = Object.create(arrayProto)

const methodToPatch = ['push', 'splice']

methodToPatch.forEach(function (method) {
    const original = arrayProto[method]
    
    def(arrayMethods, method, function mutator(...args) {
        const result = original.apply(this, args)
        console.log('劫持hh')
        return result
    })
})

function def(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        configurable: true,
        writable: true
    })
}

let arr = [1,2,3]
arr.__proto__ = arrayMethods

arr.push(4)
// 輸出
// 劫持hh
// 4

我們以陣列為原型建立了一個空物件arrayMethods, 並在其上面定義了要劫持的陣列,我們這個只是簡單的列印了一句。改變arr的原型指向(給__proto__賦值),在arr操作push,splice時會走劫持的方法。 vue的陣列劫持實際上是在劫持方法裡面新增了響應式的邏輯.

function mutator(...args) {
    // cache original method
  const original = arrayProto[method]
  // obj key, val, enumerable
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        //eg: push(a) inserted = [a] // 為push的值新增Oberserve響應監聽
        inserted = args
        break
      case 'splice':
        // eg: splice(start,deleteCount,...items)  inserted = [items] //  為新新增的值新增Oberserve響應監聽
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
}
/**
 * Observe a list of Array items.
 */
observeArray (items: Array<any>) {
  for (let i = 0, l = items.length; i < l; i++) {
    observe(items[i])
  }
}

$set 手動新增響應式 原理

對於物件新增屬性/陣列新增元素,無法觸發響應式,我們可以用vue $set進行處理

vm.$set(obj,key,value)

對於陣列還能使用splice方法:

vm.items.splice(indexOfItem, 1, newValue)

但是它們本質是一樣的!

set的實現核心就是:

  1. 如果是陣列,會使用splice對元素進行手動observe
  2. 如果是物件
    如果是修改存在的key,直接賦值就會觸發響應式更新
    如果是新增的key, 就對key進行手動observe
  3. 如果不是響應式的物件(響應式物件有__ob__ 屬性) 就直接賦值

set的內部實現:

export function set (target: Array<any> | Object, key: any, val: any): any {
  // 如果 set 函式的第一個引數是 undefined 或 null 或者是原始型別值,那麼在非生產環境下會列印警告資訊
  // 這個api本來就是給物件與陣列使用的
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    // 類似$vm.set(vm.$data.arr, 0, 3)
    // 修改陣列的長度, 避免索引>陣列長度導致splcie()執行有誤
    target.length = Math.max(target.length, key)
    // 利用陣列的splice變異方法觸發響應式, 這個前面講過
    target.splice(key, 1, val)
    return val
  }
  // target為物件, key在target或者target.prototype上。
  // 同時必須不能在 Object.prototype 上
  // 直接修改即可, 有興趣可以看issue: https://github.com/vuejs/vue/issues/6845
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  // 以上都不成立, 即開始給target建立一個全新的屬性
  // 獲取Observer例項
  const ob = (target: any).__ob__
  // Vue 例項物件擁有 _isVue 屬性, 即不允許給Vue 例項物件新增屬性
  // 也不允許Vue.set/$set 函式為根資料物件(vm.$data)新增屬性
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // target本身就不是響應式資料, 直接賦值
  if (!ob) {
    target[key] = val
    return val
  }
  // ---->進行響應式處理
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

參考:
https://www.zhihu.com/questio...
https://www.javascriptc.com/3...
https://juejin.cn/post/684490...