【ES6】改變 JS 內建行為的代理與反射

JennyTong發表於2019-02-16

代理(Proxy)可以攔截並改變 JS 引擎的底層操作,如資料讀取、屬性定義、函式構造等一系列操作。ES6 通過對這些底層內建物件的代理陷阱和反射函式,讓開發者能進一步接近 JS 引擎的能力。

一、代理與反射的基本概念

什麼是代理和反射呢?
代理是用來替代另一個物件(target),JS 通過new Proxy()建立一個目標物件的代理,該代理與該目標物件表面上可以被當作同一個物件來對待

當目標物件上的進行一些特定的底層操作時,代理允許你攔截這些操作並且覆寫它,而這原本只是 JS 引擎的內部能力。

如果你對些代理&反射的概念比較困惑的話,可以直接看後面的應用示例,最後再重新看這些定義就會更清晰!

攔截行為使用了一個能夠響應特定操作的函式( 被稱為陷阱),每個代理陷阱對應一個反射(Reflect)方法。

ES6 的反射 API 以 Reflect 物件的形式出現,物件每個方法都與對應的陷阱函式同名,並且接收的引數也與之一致。以下是 Reflect 物件的一些方法:

代理陷阱 覆寫的特性 方法
get 讀取一個屬性的值 Reflect.get()
set 寫入一個屬性 Reflect.set()
has in 運算子 Reflect.has()
deleteProperty delete 運算子 Reflect.deleteProperty()
getPrototypeOf Object.getPrototypeOf() Reflect.getPrototypeOf()
isExtensible Object.isExtensible() Reflect.isExtensible()
defineProperty Object.defineProperty() Reflect.defineProperty
apply 呼叫一個函式 Reflect.apply()
construct 使用 new 呼叫一個函式 Reflect.construct()

每個陷阱函式都可以重寫 JS 物件的一個特定內建行為,允許你攔截並修改它。

綜合來說,想要控制或改變JS的一些底層操作,可以先建立一個代理物件,在這個代理物件上掛載一些陷阱函式,陷阱函式裡面有反射方法。通過接下來的應用示例可以更清晰的明白代理的過程。

二、開始一個簡單的代理

當你使用 Proxy 構造器來建立一個代理時,需要傳遞兩個引數:目標物件(target)以及一個處理器( handler),

建立一個僅進行傳遞的代理如下:

// 目標物件
let target = {}; 
// 代理物件
let proxy = new Proxy(target, {});

proxy.name = "hello";
console.log(proxy.name); // "hello"
console.log(target.name); // "hello"

target.name = "world";
console.log(proxy.name); // "world"
console.log(target.name); // "world

上例中的 proxy 代理物件將所有操作直接傳遞給 target 目標物件,代理物件 proxy 自身並沒有儲存該屬性,它只是簡單將值傳遞給 target 物件,proxy.name 與 target.name 的屬性值總是相等,因為它們都指向 target.name。

此時代理陷阱的處理器為空物件,當然處理器可以定義了一個或多個陷阱函式。

2.1 set 驗證物件屬性的儲存

假設你想要建立一個物件,並要求其屬性值只能是數值,這就意味著該物件的每個新增屬性
都要被驗證,並且在屬性值不為數值型別時應當丟擲錯誤。

這時需要使用 set 陷阱函式來攔截傳入的 value,該陷阱函式能接受四個引數:

  • trapTarget :將接收屬性的物件( 即代理的目標物件)
  • key :需要寫入的屬性的鍵( 字串型別或符號型別)
  • value :將被寫入屬性的值;
  • receiver :操作發生的物件( 通常是代理物件)

set 陷阱對應的反射方法和預設特性是Reflect.set(),和陷阱函式一樣接受這四個引數,並會基於操作是否成功而返回相應的結果:

let targetObj = {};
let proxyObj = new Proxy(targetObj, {
  set: set
});

/* 定義 set 陷阱函式 */
function set (trapTarget, key, value, receiver) {
  if (isNaN(value)) {
     throw new TypeError("Property " + key + " must be a number.");
  }
  return Reflect.set(trapTarget, key, value, receiver);
}

/* 測試 */
proxyObj.count = 123;
console.log(proxyObj.count); // 123
console.log(targetObj.count); // 123

proxyObj.anotherName = "proxy" // TypeError: Property anotherName must be a number.

示例中set 陷阱函式成功攔截傳入的 value 值,你可以嘗試一下,如果註釋或不return Reflect.set()會發生什麼?,答案是攔截陷阱就不會有反射響應。

需要注意的是,直接給 targetObj 目標物件賦值時是不會觸發 set 代理陷阱的,需要通過給代理物件賦值才會觸發 set 代理陷阱與反射。

2.2 get 驗證物件屬性的讀取

JS 非常有趣的特性之一,是讀取不存在的屬性時並不會丟擲錯誤,而會把undefined當作該屬性的值。

對於大型的程式碼庫,當屬性名稱存在書寫錯誤時(不會拋錯)會導致嚴重的問題。這時使用 get 代理陷阱驗證物件結構(Object Shape),訪問不存在的屬性時就丟擲錯誤,使物件結構驗證變得簡單。

get 陷阱函式會在讀取屬性時被呼叫,即使該屬性在物件中並不存在,它能接受三個引數:

  • trapTarget :將會被讀取屬性的物件( 即代理的目標物件)
  • key :需要讀取的屬性的鍵( 字串型別或符號型別)
  • receiver :操作發生的物件( 通常是代理物件)

Reflect.get()方法接受與之相同的引數,並返回預設屬性的預設值。

let proxyObj = new Proxy(targetObj, {
  set: set,
  get: get
});

/* 定義 get 陷阱函式 */
function get(trapTarget, key, receiver) {
  if (!(key in receiver)) {
    throw new TypeError("Property " + key + " doesn`t exist.");
  }
  return Reflect.get(trapTarget, key, receiver);
}

console.log(proxyObj.count); // 123
console.log(proxyObj.newcount) // TypeError: Property newcount doesn`t exist.

這段程式碼允許新增新的屬性,並且此後可以正常讀取該屬性的值,但當讀取的屬性並
不存在時,程式丟擲了一個錯誤,而不是將其預設為undefined

還可以使用 has 陷阱驗證in運算子,使用 deleteProperty 陷阱函式避免屬性被delete刪除。

注:in運算子用於判斷物件中是否存在某個屬性,如果自有屬性或原型屬性匹配這個名稱字串或Symbol,那麼in運算子返回 true。

targetObj = {
  name: `targetObject`
};
console.log("name" in targetObj); // true
console.log("toString" in targetObj); // true

其中 name 是物件自身的屬性,而 toString 則是原型屬性( 從 Object 物件上繼承而來),所以檢測結果都為 true。

has 陷阱函式會在使用in運算子時被呼叫,並且會傳入兩個引數(同名反射Reflect.has()方法也一樣):

  • trapTarget :需要讀取屬性的物件( 代理的目標物件)
  • key :需要檢查的屬性的鍵( 字串型別或 Symbol符號型別)

deleteProperty 陷阱函式會在使用delete運算子去刪除物件屬性時下被呼叫,並且也會被傳入兩個引數(Reflect.deleteProperty() 方法也接受這兩個引數):

  • trapTarget :需要刪除屬性的物件( 即代理的目標物件) ;
  • key :需要刪除的屬性的鍵( 字串型別或符號型別) 。

一些思考:分析過 Vue 原始碼的都瞭解過,給一個 Vue 例項中掛載的 data,是通過Object.defineProperty代理 vm._data 中的物件屬性,實現雙向繫結…… 同理可以考慮使用 ES6 的 Proxy 的 get 和 set 陷阱實現這個代理。

三、物件屬性陷阱

3.1 資料屬性與訪問器屬性

ES5 最重要的特徵之一就是引入了 Object.defineProperty() 方法定義屬性的特性。屬性的特性是為了實現javascript引擎用的,屬於內部值,因此不能直接訪問他們。

屬性分為資料屬性和訪問器屬性。使用Object.defineProperty()方法修改資料屬性的特性值的示例如下:

let obj1 = {
  name: `myobj`,
}
/* 資料屬性*/
Object.defineProperty(obj1,`name`,{
  configurable: false, // default true
  writable: false,     // default true
  enumerable: true,    // default true
  value: `jenny`       // default undefined
})
console.log(obj1.name) // `jenny`

其中[[Configurable]] 表示能否通過 delete 刪除屬性從而重新定義為訪問器屬性;[[Enumerable]] 表示能否通過for-in迴圈返回屬性;[[Writable]] 表示能否修改屬性的值; [[Value]] 包含這個屬性的資料值。

對於訪問器屬性,該屬性不包含資料值,包含一對getter和setter函式,定義訪問器屬性必須使用Object.defineProperty()方法:

let obj2 = {
  age: 18
}
/* 訪問器屬性 */
Object.defineProperty(obj2,`_age`,{
  configurable: false, // default true
  enumerable: false,   // default true
  get () {             // default undefined
    return this.age
  },
  set (num) {          // default undefined
    this.age = num
  }
})
/* 修改訪問器屬性呼叫 getter */
obj2._age = 20  
console.log(obj2.age)  // 20

/* 輸出訪問器屬性 */
console.log(Object.getOwnPropertyDescriptor(obj2,`_age`)) 
// { get: [Function: get],
//   set: [Function: set],
//   enumerable: false,
//   configurable: false }

[[Get]] 在讀取屬性時呼叫的函式, [[Set]] 再寫入屬性時呼叫的函式。使用訪問器屬性的常用方式,是設定一個屬性的值導致其他屬性發生變化。

3.2 檢查屬性的修改

代理允許你使用 defineProperty 同名函式陷阱函式攔截Object.defineProperty()的呼叫,defineProperty 陷阱函式接受下列三個引數:

  • trapTarget :需要被定義屬性的物件( 即代理的目標物件);
  • key :屬性的鍵( 字串型別或符號型別);
  • descriptor :為該屬性準備的描述符物件。

defineProperty 陷阱函式要求在操作後返回一個布林值用於判斷操作是否成功,如果返回了 false 則丟擲錯誤,故可以使用該功能來限制哪些屬性可以被Object.defineProperty() 方法定義。

例如,如果想阻止定義Symbol符號型別的屬性,你可以檢查傳入的屬性值,若是則返回 false:

/* 定義代理 */
let proxy = new Proxy({}, {
  defineProperty(trapTarget, key, descriptor) {
    if (typeof key === "symbol") {
      return false;
    }
    return Reflect.defineProperty(trapTarget, key, descriptor);
  }
});

Object.defineProperty(proxy, "name", {
  value: "proxy"
});
console.log(proxy.name); // "proxy"

let nameSymbol = Symbol("name");
// 丟擲錯誤
Object.defineProperty(proxy, nameSymbol, {
  value: "proxy"
})

四、函式代理

4.1 建構函式 & 立即執行

函式的兩個內部方法:[[Call]][[Construct]]會在函式被呼叫時呼叫,通過代理函式來為這兩個內部方法設定陷阱,從而控制函式的行為。

[[Construct]]會在函式被使用new運算子呼叫時執行,代理觸發construct()陷阱函式,並和Reflect.construct()一樣接收到下列兩個引數:

  • trapTarget :被執行的函式( 即代理的目標物件) ;
  • argumentsList :被傳遞給函式的引數陣列。

[[Call]]會在函式被直接呼叫時執行,代理觸發apply()陷阱函式,它和Reflect.apply()都接收三個引數:

  • trapTarget :被執行的函式( 代理的目標函式) ;
  • thisArg :呼叫過程中函式內部的 this 值;
  • argumentsList :被傳遞給函式的引數陣列。

每個函式都包含call()apply()方法,用於重置函式執行的作用域即 this 指向,區別只是接收引數的方式不同:call()的引數需要逐個列舉、apply()是引數陣列。

顯然,apply 與 construct 要求代理目標物件必須是一個函式,這兩個代理陷阱在函式的執行方式上開啟了很多的可能性,結合使用就可以完全控制任意的代理目標函式的行為。

4.2 驗證函式的引數

看到apply()construct()陷阱的引數都有被傳遞給函式的引數陣列argumentsList,所以可以用來驗證函式的引數。

例如需要保證所有引數都是某個特定型別的,並且不能通過 new 構造使用,示例如下:

/* 定義 sum 目標函式 */
function sum(...values) {
  return values.reduce((previous, current) => previous + current, 0);
}
/* 定義 apply 陷阱函式 */
function applyRef (trapTarget, thisArg, argumentList) {
  argumentList.forEach((arg) => {
    if (typeof arg !== "number") {
      throw new TypeError("All arguments must be numbers.");
    }
  });
  return Reflect.apply(trapTarget, thisArg, argumentList);
}
/* 定義 construct 陷阱函式 */
function constructRef () {
  throw new TypeError("This function can`t be called with new.");
}
/* 定義 sumProxy 代理函式 */
let sumProxy = new Proxy(sum, {
  apply: applyRef,
  construct: constructRef
});

console.log(sumProxy(1, 2, 3, 4)); // 10

// console.log(sumProxy(1, "2", 3, 4)); // TypeError: All arguments must be numbers.
// let result = new sumProxy() // TypeError: This function can`t be called with new.

sum() 函式會將所有傳遞進來的引數值相加,此程式碼通過將 sum() 函式封裝在 sumProxy() 代理中,如果傳入引數的值不是數值型別,該函式仍然會嘗試加法操作,但在函式執行之前攔截了函式呼叫,觸發apply陷阱函式以保證每個引數都是數值。

出於安全的考慮,這段程式碼使用 construct 陷阱丟擲錯誤,以確保該函式不會被使用 new 運算子呼叫

例項物件 instance 物件會被同時判定為 proxy 與 target 物件的例項,是因為 instanceof 運算子使用了原型鏈來進行推斷,而原型鏈查詢並沒有受到這個代理的影響,因此 proxy 物件與 target 物件對於 JS 引擎來說就有同一個原型。

4.3 呼叫類的建構函式

ES6 中新引入了class類的概念,類使用constructor建構函式封裝資料,並規定必須始終使用 new 來呼叫,原因是類構造器的內部方法 [[Call]] 被明
確要求丟擲錯誤。

代理可以攔截對於 [[Call]] 方法的呼叫,你可以藉助代理呼叫的類構造器。例如在缺少 new 的情況下建立一個新例項,就使用 apply 陷阱函式實現:

class Person {
  constructor(name) {
    this.name = name;
  }
}
let PersonProxy = new Proxy(Person, {
  apply: function(trapTarget, thisArg, argumentList) {
    return new trapTarget(...argumentList);
  }
});
let me = PersonProxy("Jenny");
console.log(me.name); // "Jenny"
console.log(me instanceof Person); // true
console.log(me instanceof PersonProxy); // true

類構造器即類的建構函式,使用代理時它的行為就像函式一樣,apply陷阱函式重寫了預設的構造行為。

關於類的更多有趣的用法,可參考 【ES6】更易於繼承的類語法

總結來說,代理的用途非常廣泛,因為它提供了修改 JS 內建物件的所有行為的入口。上述例子只是簡單的一些應用入門,還有更多複雜的示例,推薦閱讀《深入理解ES6》。

繼續加油鴨少年!!!

相關文章