一文搞懂 Vue3-0 爲什麼採用 Proxy

來自:掘金,作者:花椰菜菜

鏈接:https://juejin.cn/post/7069397770766909476

文章篇幅會比較長,但是看完一定會收穫滿滿~ 希望你堅持看下去呀~

Object.defineProperty()

作用:在一個對象上定義一個新屬性,或者修改一個對象的現有屬性,並返回這個對象。

  1. 基本使用

語法:Object.defineProperty(obj, prop, descriptor)

參數:

  1. 要添加屬性的對象

  2. 要定義或修改的屬性的名稱或 [Symbol]

  3. 要定義或修改的屬性描述符

看一個簡單的例子

let person = {}
let personName = 'lihua'

//在person對象上添加屬性namep,值爲personName
Object.defineProperty(person, 'namep'{
    //但是默認是不可枚舉的(for in打印打印不出來),可:enumerable: true
    //默認不可以修改,可:wirtable:true
    //默認不可以刪除,可:configurable:true
    get: function () {
        console.log('觸發了get方法')
        return personName
    },
    set: function (val) {
        console.log('觸發了set方法')
        personName = val
    }
})

//當讀取person對象的namp屬性時,觸發get方法
console.log(person.namep)

//當修改personName時,重新訪問person.namep發現修改成功
personName = 'liming'
console.log(person.namep)

// 對person.namep進行修改,觸發set方法
person.namep = 'huahua'
console.log(person.namep)

\

通過這種方法,我們成功監聽了 person 上的 name 屬性的變化。

  1. 監聽對象上的多個屬性

上面的使用中,我們只監聽了一個屬性的變化,但是在實際情況中,我們通常需要一次監聽多個屬性的變化。

這時我們需要配合 Object.keys(obj) 進行遍歷。這個方法可以返回 obj 對象身上的所有可枚舉屬性組成的字符數組。(其實用 for in 遍歷也可以)
下面是該 API 一個簡單的使用效果:

var obj = { 0: 'a', 1: 'b', 2: 'c' };
console.log(Object.keys(obj)); // console: ['0''1''2']

利用這個 API,我們就可以遍歷劫持對象的所有屬性 但是如果只是上面的思路與該 API 的簡單結合,我們就會發現並達不到效果,下面是我寫的一個錯誤的版本:

Object.keys(person).forEach(function (key) {
    Object.defineProperty(person, key, {
        enumerable: true,
        configurable: true,
        // 默認會傳入this
        get() {
            return person[key]
        },
        set(val) {
            console.log(`對person中的${key}屬性進行了修改`)
            person[key] = val
            // 修改之後可以執行渲染操作
        }
    })
})
console.log(person.age)

看起來感覺上面的代碼沒有什麼錯誤,但是試着運行一下吧~ 你會和我一樣棧溢出。這是爲什麼呢?讓我們聚焦在 get 方法裏,我們在訪問 person 身上的屬性時,就會觸發 get 方法,返回 person[key],但是訪問 person[key] 也會觸發 get 方法,導致遞歸調用,最終棧溢出。

這也引出了我們下面的方法,我們需要設置一箇中轉 Obsever,來讓 get 中 return 的值並不是直接訪問 obj[key]。

let person = {
    name: '',
    age: 0
}
// 實現一個響應式函數
function defineProperty(obj, key, val) {
    Object.defineProperty(obj, key, {
        get() {
            console.log(`訪問了${key}屬性`)
            return val
        },
        set(newVal) {
            console.log(`${key}屬性被修改爲${newVal}`)
            val = newVal
        }
    })
}
// 實現一個遍歷函數Observer
function Observer(obj) {
    Object.keys(obj).forEach((key) ={
        defineProperty(obj, key, obj[key])
    })
}
Observer(person)
console.log(person.age)
person.age = 18
console.log(person.age)
  1. 深度監聽一個對象

那麼我們如何解決對象中嵌套一個對對象的情況呢?其實在上述代碼的基礎上,加上一個遞歸,就可以輕鬆實現啦~

我們可以觀察到,其實 Obsever 就是我們想要實現的監聽函數,我們預期的目標是: 只要把對象傳入其中,就可以實現對這個對象的屬性監視,即使該對象的屬性也是一個對象。

我們在 defineProperty() 函數中,添加一個遞歸的情況:

function defineProperty(obj, key, val) {
    //如果某對象的屬性也是一個對象,遞歸進入該對象,進行監聽
    if(typeof val === 'object'){
    observer(val)
    }
    Object.defineProperty(obj, key, {
        get() {
            console.log(`訪問了${key}屬性`)
            return val
        },
        set(newVal) {
            console.log(`${key}屬性被修改爲${newVal}`)
            val = newVal
        }
    })
}

當然啦,我們也要在 observer 裏面加一個遞歸停止的條件:

function Observer(obj) {
    //如果傳入的不是一個對象,return
    if (typeof obj !== "object" || obj === null) {
        return
    }
    // for (key in obj) {
    Object.keys(obj).forEach((key) ={
        defineProperty(obj, key, obj[key])
    })
    // }

}

其實到這裏就差不多解決了,但是還有一個小問題,如果對某屬性進行修改時,如果原本的屬性值是一個字符串,但是我們重新賦值了一個對象,我們要如何監聽新添加的對象的所有屬性呢?其實也很簡單,只需要修改 set 函數:

set(newVal) {
    // 如果newVal是一個對象,遞歸進入該對象進行監聽
    if(typeof val === 'object'){
        observer(key)
    }
    console.log(`${key}屬性被修改爲${newVal}`)
    val = newVal
        }

到這裏我們就完成啦~

  1. 監聽數組

那麼如果對象的屬性是一個數組呢?我們要如何實現監聽?請看下面一段代碼:

let arr = [1, 2, 3]
let obj = {}
//把arr作爲obj的屬性監聽
Object.defineProperty(obj, 'arr'{
    get() {
        console.log('get arr')
        return arr
    },
    set(newVal) {
        console.log('set', newVal)
        arr = newVal
    }
})
console.log(obj.arr)//輸出get arr [1,2,3]  正常
obj.arr = [1, 2, 3, 4] //輸出set [1,2,3,4] 正常
obj.arr.push(3) //輸出get arr 不正常,監聽不到push

我們發現,通過push方法給數組增加的元素,set 方法是監聽不到的。

事實上,通過索引訪問或者修改數組中已經存在的元素,是可以出發 get 和 set 的,但是對於通過 push、unshift 增加的元素,會增加一個索引,這種情況需要手動初始化,新增加的元素才能被監聽到。另外,通過 pop 或 shift 刪除元素,會刪除並更新索引,也會觸發 setter 和 getter 方法。

在 Vue2.x 中,通過重寫 Array 原型上的方法解決了這個問題,此處就不展開說了,有興趣的 uu 可以再去了解下~

Proxy

是不是感覺有點複雜?事實上,在上面的講述中,我們還有問題沒有解決:那就是當我們要給對象新增加一個屬性時,也需要手動去監聽這個新增屬性。

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

可以看到,通過 Object.definePorperty() 進行數據監聽是比較麻煩的,需要大量的手動處理。這也是爲什麼在 Vue3.0 中尤雨溪轉而採用 Proxy。接下來讓我們一起看一下 Proxy 是怎麼解決這些問題的吧~

  1. 基本使用

語法:const p = new Proxy(target, handler)

參數:

  1. target: 要使用 Proxy 包裝的目標對象(可以是任何類型的對象,包括原生數組,函數,甚至另一個代理)

  2. handler: 一個通常以函數作爲屬性的對象,各屬性中的函數分別定義了在執行各種操作時代理 p 的行爲。

通過 Proxy,我們可以對設置代理的對象上的一些操作進行攔截,外界對這個對象的各種操作,都要先通過這層攔截。(和 defineProperty 差不多)

先看一個簡單例子

//定義一個需要代理的對象
let person = {
    age: 0,
    school: '西電'
}
//定義handler對象
let hander = {
    get(obj, key) {
        // 如果對象裏有這個屬性,就返回屬性值,如果沒有,就返回默認值66
        return key in obj ? obj[key] : 66
    },
    set(obj, key, val) {
        obj[key] = val
        return true
    }
}
//把handler對象傳入Proxy
let proxyObj = new Proxy(person, hander)

// 測試get能否攔截成功
console.log(proxyObj.age)//輸出0
console.log(proxyObj.school)//輸出西電
console.log(proxyObj.name)//輸出默認值66

// 測試set能否攔截成功
proxyObj.age = 18
console.log(proxyObj.age)//輸出18 修改成功

可以看出,Proxy 代理的是整個對象,而不是對象的某個特定屬性,不需要我們通過遍歷來逐個進行數據綁定。

值得注意的是: 之前我們在使用 Object.defineProperty() 給對象添加一個屬性之後,我們對對象屬性的讀寫操作仍然在對象本身。
但是一旦使用 Proxy,如果想要讀寫操作生效,我們就要對 Proxy 的實例對象proxyObj進行操作。

另外,MDN 上明確指出 set() 方法應該返回一個布爾值,否則會報錯TypeError

  1. 輕鬆解決 Object.defineProperty 中遇到的問題

在上面使用 Object.defineProperty 的時候,我們遇到的問題有:

  1. 一次只能對一個屬性進行監聽,需要遍歷來對所有屬性監聽。這個我們在上面已經解決了。
  2. 在遇到一個對象的屬性還是一個對象的情況下,需要遞歸監聽。
  3. 對於對象的新增屬性,需要手動監聽
  4. 對於數組通過 push、unshift 方法增加的元素,也無法監聽

這些問題在 Proxy 中都輕鬆得到了解決,讓我們看看以下代碼。

檢驗第二個問題

在上面代碼的基礎上,我們讓對象的結構變得更復雜一些。

let person = {
    age: 0,
    school: '西電',
    children: {
        name: '小明'
    }
}
let hander = {
    get(obj, key) {
        return key in obj ? obj[key] : 66
    }, set(obj, key, val) {
        obj[key] = val
        return true
    }
}
let proxyObj = new Proxy(person, hander)

// 測試get
console.log(proxyObj.children.name)//輸出:小明
console.log(proxyObj.children.height)//輸出:undefined
// 測試set
proxyObj.children.name = '菜菜'
console.log(proxyObj.children.name)//輸出: 菜菜

可以看到成功監聽到了 children 對象身上的 name 屬性(至於爲什麼 children.height 是 undefined,可以再討論一下)

檢驗第三個問題

這個其實在基本使用裏面已經提到了,訪問的 proxyObj.name 就是原本對象上不存在的屬性,但是我們訪問它的時候,仍然們可以被 get 攔截到。

檢驗第四個問題

let subject = ['高數']
let handler = {
    get(obj, key) {
        return key in obj ? obj[key] : '沒有這門學科'
    }, set(obj, key, val) {
        obj[key] = val
        //set方法成功時應該返回true,否則會報錯
        return true
    }
}

let proxyObj = new Proxy(subject, handler)

// 檢驗get和set
console.log(proxyObj)//輸出  [ '高數' ]
console.log(proxyObj[1])//輸出  沒有這門學科
proxyObj[0] = '大學物理'
console.log(proxyObj)//輸出  [ '大學物理' ]

// // 檢驗push增加的元素能否被監聽
proxyObj.push('線性代數')
console.log(proxyObj)//輸出 [ '大學物理''線性代數' ]

至此,我們之前的問題完美解決。

3.Proxy 支持 13 種攔截操作

除了 get 和 set 來攔截讀取和賦值操作之外,Proxy 還支持對其他多種行爲的攔截。下面是一個簡單介紹,想要深入瞭解的可以去 MDN 上看看。

  1. Proxy 中有關 this 的問題

雖然 Proxy 完成了對目標對象的代理,但是它不是透明代理, 也就是說:即使 handler 爲空對象(即不做任何代理),他所代理的對象中的 this 指向也不是該對象,而是 proxyObj 對象。讓我們來看一個例子:

let target = {
    m() {
        // 檢查this的指向是不是proxyObkj
        console.log(this === proxyObj)
    }
}
let handler = {}
let proxyObj = new Proxy(target, handler)

proxyObj.m()//輸出:true
target.m()//輸出:false

可以看到,被代理的對象 target 內部的 this 指向了 proxyObj。這種指向有時候就會導致問題出現,我們來看看下面一個例子:

const _name = new WeakMap();
class Person {
   //把person的name存儲到_name的name屬性上
  constructor(name) {
    _name.set(this, name);
  }
  //獲取person的name屬性時,返回_name的name
  get name() {
    return _name.get(this);
  }
}

const jane = new Person('Jane');
jane.name // 'Jane'

const proxyObj = new Proxy(jane, {});
proxyObj.name // undefined

在上面的例子中,由於 jane 對象的 name 屬性的獲取依靠 this 的指向,而 this 又指向 proxyObj,所以導致了無法正常代理。

除此之外,有的 js 內置對象的內部屬性,也依靠正確的 this 才能獲取,所以 Proxy 也無法代理這些原生對象的屬性。請看下面一個例子:

const target = new Date();
const handler = {};
const proxyObj = new Proxy(target, handler);

proxyObj.getDate();
// TypeError: this is not a Date object.

可以看到,通過 proxy 代理訪問 Date 對象中的 getDate 方法時拋出了一個錯誤,這是因爲 getDate 方法只能在 Date 對象實例上面拿到,如果 this 不是 Date 對象實例就會報錯。那麼我們要如何解決這個問題呢?只要手動把 this 綁定在 Date 對象實例上即可,請看下面一個例子:

const target = new Date('2015-01-01');
const handler = {
    get(target, prop) {
        if (prop === 'getDate') {
            return target.getDate.bind(target);
        }
        return Reflect.get(target, prop);
    }
};
const proxy = new Proxy(target, handler);
proxy.getDate() // 1

完結撒花

至此,我的總結就結束啦~ 文章不是很全面,還有很多地方沒有講到, 比如:

  1. Proxy 常常搭配Reflect使用

  2. 我們常Object.create()方法把 Proxy 實例對象添加到 Object 的原型對象上,這樣我們就可以直接 Object.proxyObj 了

  3. 有興趣的 uu 可以在 Proxy 的 get 和 set 里加上輸出試試,你會發現在我們調用 push 方法時,get和set會分別輸出兩次,這是爲什麼呢?

學無止境,讓我們一起努力叭~

參考文章:

1.Proxy 與 Object.defineProperty 介紹與對比

2.MDN Proxy

Web 開發 分享 Web 後端開發技術,分享 PHP、Ruby、Python 等用於後端網站、後臺系統等後端開發技術;還包含 ThinkPHP,WordPress 等 PHP 網站開發框架、Django,Flask 等 Python 網站開發框架。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bAM7d57Mmn7qP4GSpQ84Ow