1. 程式人生 > 實用技巧 >為什麼Vue3.0使用Proxy實現資料監聽(defineProperty表示不背這個鍋)

為什麼Vue3.0使用Proxy實現資料監聽(defineProperty表示不背這個鍋)

導 讀

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

  • Object.defineProperty 真的無法監測陣列下標的變化嗎?
  • 分析vue2.x中對陣列 Observe 部分原始碼
  • 對比 Object.definePropertyProxy

一、無法監控到陣列下標的變化?

在一些技術部落格上看到過這樣一種說法,認為 Object.defineProperty 有一個缺陷是無法監聽陣列變化:

無法監控到陣列下標的變化,導致直接通過陣列的下標給陣列設定值,不能實時響應。所以vue才設定了7個變異陣列( push

popshiftunshiftsplicesortreverse )的 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. 通過下標獲取某個元素和修改某個元素的值

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

方法。

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

2. 陣列的 push 方法

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

3. 陣列的 unshift 方法

我擦,發生了什麼?

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

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

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

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

4. 陣列的 pop 方法

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

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

這和物件的處理是同樣的,陣列的索引被刪除後,就相當於物件的屬性被刪除一樣,不會再去觸發 observe

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

Object.defineProperty 在陣列中的表現和在物件中的表現是一致的,陣列的索引就可以看做是物件中的 key

  • 通過索引訪問或設定對應元素的值時,可以觸發 gettersetter 方法
  • 通過 pushunshift 會增加索引,對於新增加的屬性,需要再手動初始化才能被 observe
  • 通過 popshift 刪除元素,會刪除並更新索引,也會觸發 settergetter 方法。

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

二、vue對陣列的observe做了哪些處理?

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

可以看到,vue的 Observer 對陣列做了單獨的處理。

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

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<any> | 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) 攔截物件屬性的設定,是可以攔截到物件的新增屬性的。

不止如此, Proxy 對陣列的方法也可以監測到,不需要像上面vue2.x原始碼中那樣進行 hack

完美!!!

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相容性差

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

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

四 總結

  • Object.defineProperty 對陣列和物件的表現一直,並非不能監控陣列下標的變化,vue2.x中無法通過陣列索引來實現響應式資料的自動更新是vue本身的設計導致的,不是 defineProperty 的鍋。
  • Object.defineProperty 和 Proxy 本質差別是,defineProperty 只能對屬性進行劫持,所以出現了需要遞迴遍歷,新增屬性需要手動 Observe 的問題。
  • Proxy 作為新標準,瀏覽器廠商勢必會對其進行持續優化,但它的相容性也是塊硬傷,並且目前還沒有完整的polifill方案。

參考

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

https://www.jb51.net/article/171872.htm

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

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

以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支援碼農教程。