Categories
程式開發

為什麼Vue3.0不再使用defineProperty實現數據監聽?


Vue 3.0中,響應式數據部分棄用了 Object.defineProperty,使用Proxy來代替它。本文將主要通過以下方面來分析為什麼Vue選擇棄用 Object.defineProperty

  1. Object.defineProperty 真的無法監測數組下標的變化嗎?
  2. 分析Vue2.x中對數組 Observe 部分源碼。
  3. 對比Object.definePropertyProxy

無法監控到數組下標的變化?

在一些技術博客上,我看到過這樣一種說法,認為 Object.defineProperty 有一個缺陷是無法監聽數組變化:

無法監控到數組下標的變化,導致直接通過數組的下標給數組設置值,不能實時響應。所以Vue才設置了7個變異數組(push、pop、shift、unshift、splice、sort、reverse)的 hack 方法來解決問題。

Object.defineProperty的第一個缺陷,無法監聽數組變化。然而Vue的文檔提到了Vue是可以檢測到數組變化的,但是只有以下八種方法,vm.items(indexOfItem) = newValue這種是無法檢測的。

這種說法是有問題的,事實上,Object.defineProperty 本身是可以監控到數組下標的變化的,只是在 Vue 的實現中,從性能/體驗的性價比考慮,放棄了這個特性。

下面我們通過一個例子來為Object.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) {
    defineReactive(data, key, data(key))
  })
}
let arr = (1, 2, 3)
observe(arr)

上面的代碼對數組arr的每個屬性通過 Object.defineProperty 進行劫持。下面我們對數組arr進行操作,看看哪些行為會觸發數組的 gettersetter 方法。

1. 通過下標獲取某個元素和修改某個元素的值

為什麼Vue3.0不再使用defineProperty實現數據監聽? 1

可以看到,通過下標獲取某個元素會觸發 getter 方法, 設置某個值會觸發 setter 方法。

接下來,我們再試一下數組的一些操作方法,看看是否會觸發。

2. 數組的 push 方法

為什麼Vue3.0不再使用defineProperty實現數據監聽? 2

push 並未觸發 settergetter方法,數組的下標可以看做是對像中的 key ,這裡push 之後相當於增加了下索引為3的元素,但是並未對新的下標進行 observe ,所以不會觸發。

3.數組的 unshift 方法

為什麼Vue3.0不再使用defineProperty實現數據監聽? 3

我擦,發生了什麼?

unshift 操作會導致原來索引為0、1、2、3的值發生變化,這就需要將原來索引為0、1、2、3的值取出來,然後重新賦值,所以取值的過程觸發了 getter ,賦值時觸發了 setter 。

下面我們嘗試通過索引獲取一下對應的元素:

為什麼Vue3.0不再使用defineProperty實現數據監聽? 4

只有索引為0、1、2的屬性才會觸發 getter

這裡我們可以對比對象來看,arr數組初始值為(1, 2, 3),即只對索引為0、1、2執行了observe 方法,所以無論後來數組的長度發生怎樣的變化,依然只有索引為0、1、2的元素發生變化才會觸發,其他的新增索引,就相當於對像中新增的屬性,需要再手動observe 才可以。

4. 數組的 pop 方法

為什麼Vue3.0不再使用defineProperty實現數據監聽? 5

當移除的元素為引用為2的元素時,會觸發 getter

為什麼Vue3.0不再使用defineProperty實現數據監聽? 6

刪除了索引為2的元素後,再去修改或獲取它的值時,不會再觸發 settergetter

這和對象的處理是同樣的,數組的索引被刪除後,就相當於對象的屬性被刪除一樣,不會再去觸發 observe。

到這裡,我們可以簡單地總結一下結論。

Object.defineProperty 在數組中的表現和在對像中的表現是一致的,數組的索引就可以看做是對像中的 key

  1. 通過索引訪問或設置對應元素的值時,可以觸發 gettersetter 方法。

  2. 通過 pushunshift 會增加索引,對於新增加的屬性,需要再手動初始化才能被observe。

  3. 通過 pop 或 shift 刪除元素,會刪除並更新索引,也會觸發 settergetter 方法。

所以,Object.defineProperty是有監控數組下標變化的能力的,只是Vue2.x放棄了這個特性。

Vue對數組的observe做了哪些處理?

Vue的 Observer 類定義在 core/observer/index.js 中。

為什麼Vue3.0不再使用defineProperty實現數據監聽? 7

可以看到,Vue的 Observer 對數組做了單獨的處理。

為什麼Vue3.0不再使用defineProperty實現數據監聽? 8

hasProto 是判斷數組的實例是否有 proto 屬性,如果有 proto 屬性就會執行 protoAugment 方法,將 arrayMethods 重寫到原型上。 hasProto 定義如下。

為什麼Vue3.0不再使用defineProperty實現數據監聽? 9

arrayMethods 是對數組的方法進行重寫,定義在 core/observer/array.js 中, 下面是這部分源碼的分析。

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */
import { def } from '../util/index'
// 复制数组构造函数的原型,Array.prototype也是一个数组。
const arrayProto = Array.prototype
// 创建对象,对象的__proto__指向arrayProto,所以arrayMethods的__proto__包含数组的所有方法。
export const arrayMethods = Object.create(arrayProto)
// 下面的数组是要进行重写的方法
const methodsToPatch = (
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
)
/**
 * Intercept mutating methods and emit events
 */
// 遍历methodsToPatch数组,对其中的方法进行重写
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto(method)
  // def方法定义在lang.js文件中,是通过object.defineProperty对属性进行重新定义。
  // 即在arrayMethods中找到我们要重写的方法,对其进行重新定义
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      // 上面已经分析过,对于push,unshift会新增索引,所以需要手动observe
      case 'push':
      case 'unshift':
        inserted = args
        break
      // splice方法,如果传入了第三个参数,也会有新增索引,所以也需要手动observe
      case 'splice':
        inserted = args.slice(2)
        break
    }
    // push,unshift,splice三个方法触发后,在这里手动observe,其他方法的变更会在当前的索引上进行更新,所以不需要再执行ob.observeArray
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

Object.defineProperty VS Proxy

上面已經知道 Object.defineProperty 對數組和對象的表現是一致的,那麼它和 Proxy 對比存在哪些優缺點呢?

1. Object.defineProperty只能劫持對象的屬性,而Proxy是直接代理對象。

由於 Object.defineProperty 只能對屬性進行劫持,需要遍歷對象的每個屬性,如果屬性值也是對象,則需要深度遍歷。而 Proxy 直接代理對象,不需要遍歷操作。

2. Object.defineProperty對新增屬性需要手動進行Observe。

由於 Object.defineProperty劫持的是對象的屬性,所以新增屬性時,需要重新遍歷對象,對其新增屬性再使用 Object.defineProperty 進行劫持。

也正是因為這個原因,使用Vue給 data 中的數組或對像新增屬性時,需要使用 vm.$set 才能保證新增的屬性也是響應式的。

下面看一下Vue的 set 方法是如何實現的,set方法定義在 core/observer/index.js ,下面是核心代碼。

/**
 * Set a property on an object. Adds the new property and
 * triggers change notification if the property doesn't
 * already exist.
 */
export function set (target: Array | Object, key: any, val: any): any {
  // 如果target是数组,且key是有效的数组索引,会调用数组的splice方法,
  // 我们上面说过,数组的splice方法会被重写,重写的方法中会手动Observe
  // 所以vue的set方法,对于数组,就是直接调用重写splice方法
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  // 对于对象,如果key本来就是对象中的属性,直接修改值就可以触发更新
  if (key in target && !(key in Object.prototype)) {
    target(key) = val
    return val
  }
  // vue的响应式对象中都会添加了__ob__属性,所以可以根据是否有__ob__属性判断是否为响应式对象
  const ob = (target: any).__ob__
  // 如果不是响应式对象,直接赋值
  if (!ob) {
    target(key) = val
    return val
  }
  // 调用defineReactive给数据添加了 getter 和 setter,
  // 所以vue的set方法,对于响应式的对象,就会调用defineReactive重新定义响应式对象,defineReactive 函数
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

在 set 方法中,對 target 是數組和對像做了分別的處理,target 是數組時,會調用重寫過的 splice 方法進行手動 Observe 。

對於對象,如果 key 本來就是對象的屬性,則直接修改值觸發更新,否則調用 defineReactive 方法重新定義響應式對象。

如果採用 proxy 實現,Proxy 通過 set(target, propKey, value, receiver) 攔截對象屬性的設置,是可以攔截到對象的新增屬性的。

為什麼Vue3.0不再使用defineProperty實現數據監聽? 10

不止如此,Proxy 對數組的方法也可以監測到,不需要像上面Vue2.x源碼中那樣進行 hack。

為什麼Vue3.0不再使用defineProperty實現數據監聽? 11

完美! ! !

3. Proxy支持13種攔截操作,這是defineProperty所不具有的。

  • get(target, propKey, receiver):攔截對象屬性的讀取,比如 proxy.fooproxy('foo')

  • set(target, propKey, value, receiver):攔截對象屬性的設置,比如proxy.foo = vproxy('foo') = v,返回一個布爾值。

  • has(target, propKey):攔截 propKey in proxy 的操作,返回一個布爾值。

  • deleteProperty(target, propKey):攔截 delete proxy(propKey) 的操作,返回一個布爾值。

  • ownKeys(target):攔截Object.getOwnPropertyNames(proxy)、Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in循環,返回一個數組。該方法返回目標對象所有自身的屬性的屬性名,而 Object.keys() 的返回結果僅包括目標對象自身的可遍歷屬性。

  • getOwnPropertyDescriptor(target, propKey):攔截Object.getOwnPropertyDescriptor(proxy, propKey),返回屬性的描述對象。

  • defineProperty(target, propKey, propDesc):攔截Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs),返回一個布爾值。

  • preventExtensions(target):攔截 Object.preventExtensions(proxy),返回一個布爾值。

  • getPrototypeOf(target):攔截 Object.getPrototypeOf(proxy),返回一個對象。

  • isExtensible(target):攔截 Object.isExtensible(proxy),返回一個布爾值。

  • setPrototypeOf(target, proto):攔截 Object.setPrototypeOf(proxy, proto),返回一個布爾值。如果目標對像是函數,那麼還有兩種額外操作可以攔截。

  • apply(target, object, args):攔截 Proxy 實例作為函數調用的操作,比如proxy(...args)、proxy.call(object, ...args)、proxy.apply(...)

  • construct(target, args):攔截 Proxy 實例作為構造函數調用的操作,比如new proxy(...args)

4. 新標準性能紅利

Proxy 作為新標準,從長遠來看,JS引擎會繼續優化 Proxy,但 gettersetter 基本不會再有針對性優化。

5. Proxy 兼容性差

為什麼Vue3.0不再使用defineProperty實現數據監聽? 12

可以看到,Proxy 對於IE瀏覽器來說簡直是災難。

並且目前並沒有一個完整支持 Proxy 所有攔截方法的Polyfill方案,有一個google編寫的 proxy-polyfill 也只支持了 get、set、apply、construct 四種攔截,可以支持到IE9+和Safari 6+。

總結

  1. Object.defineProperty 並非不能監控數組下標的變化,Vue2.x中無法通過數組索引來實現響應式數據的自動更新是Vue本身的設計導致的,不是 defineProperty 的鍋。

  2. Object.definePropertyProxy 本質差別是,defineProperty 只能對屬性進行劫持,所以出現了需要遞歸遍歷,新增屬性需要手動 Observe 的問題。

  3. Proxy 作為新標準,瀏覽器廠商勢必會對其進行持續優化,但它的兼容性也是塊硬傷,並且目前還沒有完整的polyfill方案。

參考鏈接:

https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy

https://segmentfault.com/a/1190000015783546

https://zhuanlan.zhihu.com/p/35080324

http://es6.ruanyifeng.com/#docs/proxy

本文轉載自微信公眾號:前端小苑