首頁 > 軟體

defineProperty和Proxy基礎功能及效能對比

2022-08-05 14:00:20

前言

最近公司專案從vue2遷移到vue3,感覺自己對Object.defineProperty和Proxy的瞭解還是在淺嘗輒止的地步,所以今天抽空整體對二者進行了深入(基礎)的瞭解,主要是二者的基礎用法,效能對比,在vue中的應用進行了探索,希望能夠幫助到想了解的小夥伴。

Object.defineProperty簡介

首先,來看看MDN上的定義:

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回此物件。

備註: 應當直接在 Object 構造器物件上呼叫此方法,而不是在任意一個 Object 型別的範例上呼叫。

語法

Object.defineProperty(obj, prop, descriptor)

引數說明:

obj:要定義屬性的物件。

prop:要定義或修改的屬性的名稱或Symbol

descriptor:要定義或修改的屬性描述符。

簡單範例

let person = {};
let name = 'yuanwill';
Object.defineProperty(person, 'name', {
    get() {
        return name === 'yuanwill' ? 'zhangsan' : 'lisi'
    },
    set(newVal) {
        name = newVal
    }
});
console.log(person.name); // zhangsan
person.name = 'haha';
console.log(person.name); // lisi
  • 讀取personname屬性時,存取了get方法,第一次nameyuanwill,所以返回了zhangsan
  • 修改personname屬性時,存取了set方法,修改了name變數的值
  • 第二次讀取personname屬性,同理,返回了lisi

仿vue使用

在vue2中,使用了Object.defineProperty來實現資料雙向繫結的基礎(具體的observe,watcher,dep等等balabala就不細說了),我們主要仿造vue來看看怎麼通過Object.defineProperty來實現一個物件或陣列(不扯對陣列方法的攔截AOP)的屬性攔截和監聽。

物件的攔截

準備一個物件如下:

let person = {
    name: 'yuanwill',
    age: 26,
    address: {
        home: 'guangzhou',
        now: 'shenzhen'
    }
};

很容易想到,我們需要遍歷person中的key,然後對每一個key進行轉換即可,於是很自然的寫出了下面的錯誤範例:

Object.keys(person).forEach(key => {
    Object.defineProperty(person, key, {
        get() {
            console.log('攔截到正在獲取屬性:' + key);
            return person[key]; // ①
        },
        set(val) {
            console.log('攔截到正在修改屬性:' + key);
            person[key] = val; // ②
        }
    })
})
console.log(person.name)

執行程式碼發現棧溢位了,錯誤有兩處,程式碼已經標明:

  • get中,直接使用person[key] 會繼續呼叫get,導致死迴圈
  • set中同理。

所以,需要使用一個方法,來傳遞person[key] 的值。

const defineReactive = (obj, key, val) => {
    Object.defineProperty(obj, key, {
        get() {
            console.log('攔截到正在獲取屬性:' + key);
            return val;
        },
        set(newVal) {
            console.log('攔截到正在修改屬性:' + key);
            val = newVal;
        }
    })
}
const observer = obj => {
    // 如果obj不是一個物件,就沒必要包裝了
    if(typeof obj !== 'object' || !obj) {
        return;
    }
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
    })
}

實驗一下:

observer(person);
person.name = 'haha'; // 攔截到正在修改屬性:name
console.log(person.name); // 攔截到正在獲取屬性:name, haha

可是,還有瑕疵,比如:

person.name = {
    firstName: 'yuan',
    lastName: 'will'
}; // 攔截到正在修改屬性:name
person.name.firstName = 'haha'; // 攔截到正在獲取屬性:name
console.log(person.name); // 攔截到正在獲取屬性:name

可以看到,person.name.firstName並沒有攔截到正在修改firstName屬性。原因是我們在set的時候,newVal可能也是一個object,所以也需要進行observer

修改set如下:

set(newVal) {
            if(typeof newVal === 'object') {
                observer(newVal);
            }
            console.log('攔截到正在修改屬性:' + key);
            val = newVal;
        }

當然,還有瑕疵,比如存取深層物件:

console.log(person.address.home) // 攔截到正在獲取屬性:address

並沒有攔截到存取屬性home,所以我們還需要判斷val如果是物件也應該再一次observer。優化後的完整程式碼如下:

const defineReactive = (obj, key, val) => {
    if(typeof val === 'object') {
        observer(val);
    }
    Object.defineProperty(obj, key, {
        get() {
            console.log('攔截到正在獲取屬性:' + key);
            return val;
        },
        set(newVal) {
            if(typeof newVal === 'object') {
                observer(newVal);
            }
            console.log('攔截到正在修改屬性:' + key);
            val = newVal;
        }
    })
}
const observer = obj => {
    // 如果obj不是一個物件,就沒必要包裝了
    if(typeof obj !== 'object' || !obj) {
        return;
    }
    Object.keys(obj).forEach(key => {
        defineReactive(obj, key, obj[key])
    })
}

陣列的攔截

我們總說,Object.defineProperty不能攔截陣列,這種說法不太準確,看範例:

let list = [1,2,3,4];
observer(list);
console.log(list[0]) // 攔截到正在獲取屬性:0
list[0] = 2; // 攔截到正在修改屬性:0
list[6] = 6; // 無法攔截...
list.push(3); // 無法攔截...

可以看到,通過索引去存取或修改已經存在的元素,是可以攔截到的。如果是不存在的元素,或者是通過push等方法去修改陣列,則無法攔截。

正因為如此,vue2在實現的時候,通過重寫了陣列原型上的七個方法(push、pop、shift、unshift、splice、sort、reverse)來解決(具體可以看vue/src/core/observer/array.js),就不展開了。

Proxy簡介

同樣,來看看MDN上的定義:

Proxy 物件用於建立一個物件的代理,從而實現基本操作的攔截和自定義(如屬性查詢、賦值、列舉、函數呼叫等)。

語法

const p = new Proxy(target, handler)

引數說明:

  • target:要使用 Proxy 包裝的目標物件(可以是任何型別的物件,包括原生陣列,函數,甚至另一個代理)。
  • handler:一個通常以函數作為屬性的物件,各屬性中的函數分別定義了在執行各種操作時代理 p 的行為。

handler物件總共有13個屬性方法,具體的可以參考MDN,就不一一列舉了。

簡單範例

let person = {
    name: 'yuanwill'
}
let personProxy = new Proxy(person, {
    get(target, key) {
        return target[key] === 'yuanwill' ? 'zhangsan': 'lisi'
    },
    set(target, key, val) {
        target[key] = val;
        return true;
    }
});
console.log(personProxy.name); // zhangsan
personProxy.name = 'haha';
console.log(personProxy.name); // lisi

案例及其簡單,就不介紹了。

攔截的本質

proxy的攔截,並不是“萬事萬物”都攔截,看MDN上面的定義,是對基本操作的攔截和自定義,那麼何為基本操作呢,看下面的例子:

const person = {
    name: 'yuanwill',
    say() {
        console.log('你好呀')
    }
}
let personProxy = new Proxy(person, {
    get(target, key) {
        console.log('攔截到正在獲取屬性:' + key);
        return target[key]
    },
    set(target, key, val) {
        console.log('攔截到正在修改屬性:' + key);
        target[key] = val;
    },
    apply(target, thisArg, arguments) {
        console.log('攔截到了正在執行的方法:' + target);
        return target.call(thisArg, ...arguments)
    }
})
console.log(personProxy.name); // 攔截到正在獲取屬性:name
personProxy.name = 'haha'; // 攔截到正在修改屬性:name
personProxy.say(); // 攔截到正在獲取屬性:say

重點在最後一句程式碼,發現personProxy.say()並沒有走入apply方法中,原因就在於只攔截基本操作。

那麼到底什麼是基本操作呢?像上面的personProxy.name這種屬性的讀取,personProxy.name = 'haha'這種屬性的賦值就是基本操作,而personProxy.say()是由兩個基本操作(personProxy.say的讀取以及函數的呼叫)組成的複合操作,我們代理的物件是person,而不是person.say,所以,我們只攔截到了person.say的讀取操作。

孿生兄弟Reflect

來看看MDN上的定義:

Reflect 是一個內建的物件,它提供攔截 JavaScript 操作的方法。這些方法與proxy handlers (en-US)的方法相同。Reflect不是一個函數物件,因此它是不可構造的。

換句話說,Reflet物件的方法和proxy的攔截器(第二個入參handler)的方法完全一致,因此也有13個方法,就不一一列舉了。

Reflect的作用也及其簡單,可以參考MDN上。

那麼,為什麼我們需要Reflect呢,來看下面的例子:

const person = {
    name: 'yuanwill',
    get firstName() {
        return this.name;
    }
};
const personProxy = new Proxy(person, {
    get(target, key) {
        console.log('攔截到正在獲取屬性:' + key);
        return target[key]
    },
    set(target, key, val) {
        console.log('攔截到正在修改屬性:' + key);
        target[key] = val;
    }
});
console.log(personProxy.firstName); // 攔截到正在獲取屬性:firstName

按照我們的理解,應該還需要攔截到name屬性,因為我們在firstName中返回的是name屬性,那麼為什麼沒有攔截到呢?關鍵在於this指向問題,personProxy.firstName會被get攔截,然後返回target[key],這裡的target就是personkey就是firstName,所以這個時候的this.name就是person.name,而我們的代理物件是personProxy,所以存取name屬性就不會被攔截了。

那這個時候,Reflect就派上用場了:

const personProxy = new Proxy(person, {
    get(target, key, receiver) {
        console.log('攔截到正在獲取屬性:' + key);
        return Reflect.get(target, key, receiver);
    },
    set(target, key, val, receiver) {
        console.log('攔截到正在修改屬性:' + key);
        return Reflect.set(target, key, val, receiver);
    }
});

這個時候,就能攔截到了。原因在於,Reflect.get中的第三個引數receiver作用就是改變this的指向,MDN描述如下:

如果target物件中指定了getter,receiver則為getter呼叫時的this值。

仿vue使用

物件的攔截

還是使用上面的物件:

let person = {
    name: 'yuanwill',
    age: 26,
    address: {
        home: 'guangzhou',
        now: 'shenzhen'
    }
};

我們很自然就能寫出如下程式碼:

const observer = obj => {
    // 如果obj不是一個物件,就沒必要包裝了
    if(typeof obj !== 'object' || !obj) {
        return obj;
    }
    const proxyConfig = {
        get(target, key, receiver) {
            console.log('攔截到正在獲取屬性:' + key);
            return Reflect.get(target, key, receiver)
        },
        set(target, key, val, receiver) {
            console.log('攔截到正在修改屬性:' + key);
            return Reflect.set(target, key, val, receiver);;
        }
    };
    const observed = new Proxy(obj, proxyConfig);
    return observed;
}

測試一下:

const personProxy = observer(person);
personProxy.name = 'haha'; // 攔截到正在修改屬性:name
console.log(personProxy.name); // 攔截到正在獲取屬性:name

當然,也有瑕疵:

personProxy.name = {
    firstName: 'yuan',
    lastName: 'will'
}; // 攔截到正在修改屬性:name
personProxy.name.firstName = 'haha'; // 攔截到正在獲取屬性:name
console.log(personProxy.name); // 攔截到正在獲取屬性:name

可以看到,person.name.firstName依然沒有攔截到正在修改firstName屬性。原因在於,get返回的可能是個物件,我們需要對這個物件再次代理,所以修改如下:

const observer = obj => {
    // 如果obj不是一個物件,就沒必要包裝了
    if(typeof obj !== 'object' || !obj) {
        return obj;
    }
    const proxyConfig = {
        get(target, key, receiver) {
            console.log('攔截到正在獲取屬性:' + key);
            const result = Reflect.get(target, key, receiver);
            return observer(result);
        },
        set(target, key, val, receiver) {
            console.log('攔截到正在修改屬性:' + key);
            return Reflect.set(target, key, val, receiver);;
        }
    };
    const observed = new Proxy(obj, proxyConfig);
    return observed;
}

仔細分析上面的程式碼,我們在get的時候,才去判斷了獲取的值是不是一個物件,而Object.defineProperty是最開始就回圈遍歷,對每個屬性進行代理,所以,這樣效能就提升了。同時,我們獲取personProxy.address.home也能攔截到home屬性了(想想就知道為啥了)。

陣列的攔截

let list = [1,2,3,4];
let listProxy = observer(list);
console.log(listProxy[0]) // 攔截到正在獲取屬性:0
listProxy[0] = 2; // 攔截到正在修改屬性:0
listProxy[6] = 6; // 攔截到正在修改屬性:6
/**
 * 攔截到正在獲取屬性:push
 * 攔截到正在獲取屬性:length
 * 攔截到正在修改屬性:7
 * 攔截到正在修改屬性:length
 */
listProxy.push(3);

可以看到,proxy天然的解決了陣列的相關問題。

最後

Object.definePropertyProxy的相關基礎就介紹完了,文章只是講解了比較基礎的功能,學無止境,沒辦法了,慢慢來把~~~

更多關於defineProperty Proxy基礎功能的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com