深入探討 JavaScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

18 使用代理進行元編程



18.1 概觀

Proxy 能讓我們攔截並自訂在物件上執行的運算(例如取得屬性)。它們是元程式設計功能。

在以下範例中

我們只攔截一個運算 – get(取得屬性)

const logged = [];

const target = {size: 0};
const handler = {
  get(target, propKey, receiver) {
    logged.push('GET ' + propKey);
    return 123;
  }
};
const proxy = new Proxy(target, handler);

當我們取得屬性 proxy.size 時,handler 會攔截該運算

assert.equal(
  proxy.size, 123);

assert.deepEqual(
  logged, ['GET size']);

請參閱 完整 API 參考,以取得可攔截運算的清單。

18.2 程式設計與元程式設計

在我們深入探討 Proxy 是什麼以及它們為何有用的之前,我們首先需要了解什麼是元程式設計

在程式設計中,有幾個層級

基本層級和元層級可以是不同的語言。在以下元程式中,元程式設計語言是 JavaScript,而基本程式設計語言是 Java。

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');

元程式設計可以有不同的形式。在先前的範例中,我們已將 Java 程式碼印出到主控台。讓我們同時使用 JavaScript 作為元程式設計語言和基本程式設計語言。這方面的經典範例是 eval() 函式,它讓我們動態評估/編譯 JavaScript 程式碼。在以下互動中,我們使用它來評估運算式 5 + 2

> eval('5 + 2')
7

其他 JavaScript 運算可能看起來不像元程式設計,但如果我們仔細觀察,它們實際上是

// Base level
const obj = {
  hello() {
    console.log('Hello!');
  },
};

// Meta level
for (const key of Object.keys(obj)) {
  console.log(key);
}

程式在執行時會檢查自己的結構。這看起來不像元程式設計,因為在 JavaScript 中,程式設計建構和資料結構之間的區別很模糊。所有 Object.* 方法都可以視為元程式設計功能。

18.2.1 元程式設計的種類

反射式元程式設計表示程式會處理自己。 Kiczales 等人 [2] 區分出三種反射式元程式設計

我們來看一些範例。

範例:內省。 Object.keys() 執行內省(請參閱先前的範例)。

範例:自我修改。下列函式 moveProperty 會將一個屬性從來源移至目標。它透過屬性存取的方括號運算子、指定運算子以及 delete 運算子來執行自我修改。(在實際程式碼中,我們可能使用 屬性描述子 來執行這項工作。)

function moveProperty(source, propertyName, target) {
  target[propertyName] = source[propertyName];
  delete source[propertyName];
}

以下是 moveProperty() 的使用方式

const obj1 = { color: 'blue' };
const obj2 = {};

moveProperty(obj1, 'color', obj2);

assert.deepEqual(
  obj1, {});

assert.deepEqual(
  obj2, { color: 'blue' });

ECMAScript 5 不支援攔截;Proxy 是為了填補這個缺口而建立的。

18.3 Proxy 解釋

Proxy 為 JavaScript 帶來了攔截。它們的運作方式如下。我們可以在物件 obj 上執行許多操作,例如

Proxy 是特殊物件,讓我們可以自訂部分這些操作。Proxy 是使用兩個參數建立的

注意:「攔截」的動詞形式是「intercede」。攔截本質上是雙向的。攔截本質上是單向的。

18.3.1 範例

在以下範例中,處理函式會攔截 gethas 操作。

const logged = [];

const target = {};
const handler = {
  /** Intercepts: getting properties */
  get(target, propKey, receiver) {
    logged.push(`GET ${propKey}`);
    return 123;
  },

  /** Intercepts: checking whether properties exist */
  has(target, propKey) {
    logged.push(`HAS ${propKey}`);
    return true;
  }
};
const proxy = new Proxy(target, handler);

如果我們取得屬性(A 行)或使用 in 運算子(B 行),處理函式會攔截這些操作

assert.equal(proxy.age, 123); // (A)
assert.equal('hello' in proxy, true); // (B)

assert.deepEqual(
  logged, [
    'GET age',
    'HAS hello',
  ]);

處理函式沒有實作 set 陷阱(設定屬性)。因此,設定 proxy.age 會轉發至 target,並導致設定 target.age

proxy.age = 99;
assert.equal(target.age, 99);

18.3.2 函式特定陷阱

如果目標是函式,可以攔截兩個額外操作

僅針對函式目標啟用這些陷阱的原因很簡單:否則,我們將無法轉送運算 applyconstruct

18.3.3 攔截方法呼叫

如果我們想要透過 Proxy 攔截方法呼叫,我們會面臨一個挑戰:沒有方法呼叫的陷阱。相反地,方法呼叫被視為兩個運算的順序

因此,如果我們想要攔截方法呼叫,我們需要攔截兩個運算

以下程式碼示範如何執行此操作。

const traced = [];

function traceMethodCalls(obj) {
  const handler = {
    get(target, propKey, receiver) {
      const origMethod = target[propKey];
      return function (...args) { // implicit parameter `this`!
        const result = origMethod.apply(this, args);
        traced.push(propKey + JSON.stringify(args)
          + ' -> ' + JSON.stringify(result));
        return result;
      };
    }
  };
  return new Proxy(obj, handler);
}

我們沒有使用 Proxy 進行第二次攔截;我們只是將原始方法包裝在函式中。

讓我們使用以下物件來嘗試 traceMethodCalls()

const obj = {
  multiply(x, y) {
    return x * y;
  },
  squared(x) {
    return this.multiply(x, x);
  },
};

const tracedObj = traceMethodCalls(obj);
assert.equal(
  tracedObj.squared(9), 81);

assert.deepEqual(
  traced, [
    'multiply[9,9] -> 81',
    'squared[9] -> 81',
  ]);

甚至 obj.squared() 內部的呼叫 this.multiply() 也會被追蹤!這是因為 this 會持續參照 Proxy。

這不是最有效率的解決方案。例如,可以快取方法。此外,Proxy 本身會影響效能。

18.3.4 可撤銷的 Proxy

Proxy 可以撤銷(關閉)

const {proxy, revoke} = Proxy.revocable(target, handler);

我們第一次呼叫函式 revoke 之後,我們對 proxy 執行的任何運算都會導致 TypeError。後續呼叫 revoke 沒有進一步的效果。

const target = {}; // Start with an empty object
const handler = {}; // Don’t intercept anything
const {proxy, revoke} = Proxy.revocable(target, handler);

// `proxy` works as if it were the object `target`:
proxy.city = 'Paris';
assert.equal(proxy.city, 'Paris');

revoke();

assert.throws(
  () => proxy.prop,
  /^TypeError: Cannot perform 'get' on a proxy that has been revoked$/
);

18.3.5 Proxy 作為原型

Proxy proto 可以成為物件 obj 的原型。從 obj 開始的一些運算可能會繼續在 proto 中。其中一個運算是 get

const proto = new Proxy({}, {
  get(target, propertyKey, receiver) {
    console.log('GET '+propertyKey);
    return target[propertyKey];
  }
});

const obj = Object.create(proto);
obj.weight;

// Output:
// 'GET weight'

屬性 weight 無法在 obj 中找到,這就是為什麼搜尋會繼續在 proto 中,並且陷阱 get 會在那裡觸發。還有更多會影響原型的運算;它們列在本章節的結尾。

18.3.6 轉送攔截的運算

處理常式未實作其陷阱的運算會自動轉送至目標。有時,我們希望在轉送運算之外執行一些任務。例如,攔截並記錄所有運算,而不阻止它們到達目標

const handler = {
  deleteProperty(target, propKey) {
    console.log('DELETE ' + propKey);
    return delete target[propKey];
  },
  has(target, propKey) {
    console.log('HAS ' + propKey);
    return propKey in target;
  },
  // Other traps: similar
}
18.3.6.1 改進:使用 Reflect.*

對於每個陷阱,我們首先記錄操作的名稱,然後手動執行它並轉送它。JavaScript 有類似模組的物件 Reflect,有助於轉送。

對於每個陷阱

handler.trap(target, arg_1, ···, arg_n)

Reflect 有方法

Reflect.trap(target, arg_1, ···, arg_n)

如果我們使用 Reflect,先前的範例如下所示。

const handler = {
  deleteProperty(target, propKey) {
    console.log('DELETE ' + propKey);
    return Reflect.deleteProperty(target, propKey);
  },
  has(target, propKey) {
    console.log('HAS ' + propKey);
    return Reflect.has(target, propKey);
  },
  // Other traps: similar
}
18.3.6.2 改進:使用 Proxy 實作處理常式

現在每個陷阱所做的都非常相似,我們可以透過 Proxy 實作處理常式

const handler = new Proxy({}, {
  get(target, trapName, receiver) {
    // Return the handler method named trapName
    return (...args) => {
      console.log(trapName.toUpperCase() + ' ' + args[1]);
      // Forward the operation
      return Reflect[trapName](...args);
    };
  },
});

對於每個陷阱,Proxy 會透過 get 操作要求處理常式方法,而我們給它一個。也就是說,所有處理常式方法都可以透過單一元方法 get 實作。讓這種虛擬化變得簡單,是 Proxy API 的目標之一。

讓我們使用這個基於 Proxy 的處理常式

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

proxy.distance = 450; // set
assert.equal(proxy.distance, 450); // get

// Was `set` operation correctly forwarded to `target`?
assert.equal(
  target.distance, 450);

// Output:
// 'SET distance'
// 'GETOWNPROPERTYDESCRIPTOR distance'
// 'DEFINEPROPERTY distance'
// 'GET distance'

18.3.7 陷阱:並非所有物件都能由 Proxies 透明地包裝

Proxy 物件可以視為攔截對其目標物件執行的操作,Proxy 會包裝目標。Proxy 的處理常式物件就像 Proxy 的觀察者或監聽器。它會指定哪些操作應該透過實作對應的方法(例如 get 用於讀取屬性)來攔截。如果缺少操作的處理常式方法,則不會攔截該操作。它會直接轉送至目標。

因此,如果處理常式是空物件,Proxy 應該透明地包裝目標。唉,這並不總是有效。

18.3.7.1 包裝物件會影響 this

在我們深入探討之前,讓我們快速回顧包裝目標如何影響 this

const target = {
  myMethod() {
    return {
      thisIsTarget: this === target,
      thisIsProxy: this === proxy,
    };
  }
};
const handler = {};
const proxy = new Proxy(target, handler);

如果我們直接呼叫 target.myMethod()this 會指向 target

assert.deepEqual(
  target.myMethod(), {
    thisIsTarget: true,
    thisIsProxy: false,
  });

如果我們透過 Proxy 呼叫該方法,this 會指向 proxy

assert.deepEqual(
  proxy.myMethod(), {
    thisIsTarget: false,
    thisIsProxy: true,
  });

也就是說,如果 Proxy 將方法呼叫轉送至目標,this 就不會變更。因此,如果目標使用 this(例如呼叫方法),Proxy 會繼續在迴圈中。

18.3.7.2 無法透明包裝的物件

通常,具有空處理常式的 Proxies 會透明地包裝目標:我們不會注意到它們的存在,而且它們不會變更目標的行為。

不過,如果目標透過不受 Proxy 控制的機制將資訊與 this 關聯起來,我們就會遇到問題:因為根據目標是否被包裝,關聯的資訊不同,所以事情會失敗。

例如,以下類別 Person 會將私人資訊儲存在 WeakMap _name 中(有關此技術的更多資訊,請參閱 JavaScript for impatient programmers

const _name = new WeakMap();
class Person {
  constructor(name) {
    _name.set(this, name);
  }
  get name() {
    return _name.get(this);
  }
}

Person 的執行個體無法透明地包裝

const jane = new Person('Jane');
assert.equal(jane.name, 'Jane');

const proxy = new Proxy(jane, {});
assert.equal(proxy.name, undefined);

jane.name 與包裝的 proxy.name 不同。以下實作沒有這個問題

class Person2 {
  constructor(name) {
    this._name = name;
  }
  get name() {
    return this._name;
  }
}

const jane = new Person2('Jane');
assert.equal(jane.name, 'Jane');

const proxy = new Proxy(jane, {});
assert.equal(proxy.name, 'Jane');
18.3.7.3 包裝內建建構函式的執行個體

大多數內建建構函式的執行個體也會使用不受 Proxy 攔截的機制。因此,它們也無法透明地包裝。如果我們使用 Date 的執行個體,就可以看到這一點

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

assert.throws(
  () => proxy.getFullYear(),
  /^TypeError: this is not a Date object\.$/
);

不受 Proxy 影響的機制稱為內部插槽。這些插槽是與執行個體關聯的類似屬性的儲存空間。規範將這些插槽視為名稱以方括號表示的屬性。例如,以下方法是內部的,可以在所有物件 O 上呼叫

O.[[GetPrototypeOf]]()

與屬性不同,存取內部插槽並非透過一般的「取得」和「設定」操作來完成。如果透過 Proxy 呼叫 .getFullYear(),它無法在 this 上找到需要的內部插槽,並會透過 TypeError 抱怨。

對於 Date 方法,語言規範說明

除非另有明確定義,否則以下定義的 Date 原型物件的方法並非一般方法,傳遞給它們的 this 值必須是具有已初始化為時間值的 [[DateValue]] 內部插槽的物件。

18.3.7.4 解決方法

作為解決方法,我們可以變更處理常式轉送方法呼叫的方式,並選擇性地將 this 設定為目標,而不是 Proxy

const handler = {
  get(target, propKey, receiver) {
    if (propKey === 'getFullYear') {
      return target.getFullYear.bind(target);
    }
    return Reflect.get(target, propKey, receiver);
  },
};
const proxy = new Proxy(new Date('2030-12-24'), handler);
assert.equal(proxy.getFullYear(), 2030);

這種方法的缺點是方法在 this 上執行的操作都不會經過 Proxy。

18.3.7.5 陣列可以透明地包裝

與其他內建函式不同,陣列可以透明地包裝

const p = new Proxy(new Array(), {});

p.push('a');
assert.equal(p.length, 1);

p.length = 0;
assert.equal(p.length, 0);

陣列可以包裝的原因是,即使屬性存取已自訂為讓 .length 運作,陣列方法並不會依賴內部插槽,它們是一般方法。

18.4 Proxy 的使用案例

本節說明 Proxy 的用途。這將讓我們有機會看到 API 的實際運作。

18.4.1 追蹤屬性存取(getset

假設我們有一個函式 tracePropertyAccesses(obj, propKeys),只要 obj 的屬性(其鍵在陣列 propKeys 中)被設定或取得,就會記錄下來。在以下程式碼中,我們將該函式套用至 Point 類別的實例

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() {
    return `Point(${this.x}, ${this.y})`;
  }
}

// Trace accesses to properties `x` and `y`
const point = new Point(5, 7);
const tracedPoint = tracePropertyAccesses(point, ['x', 'y']);

取得和設定追蹤物件 p 的屬性會產生以下效果

assert.equal(tracedPoint.x, 5);
tracedPoint.x = 21;

// Output:
// 'GET x'
// 'SET x=21'

有趣的是,只要 Point 存取屬性,追蹤也會運作,因為 this 現在是指向追蹤物件,而不是 Point 的實例

assert.equal(
  tracedPoint.toString(),
  'Point(21, 7)');

// Output:
// 'GET x'
// 'GET y'
18.4.1.1 不使用 Proxy 實作 tracePropertyAccesses()

如果不使用 Proxy,我們會以以下方式實作 tracePropertyAccesses()。我們將每個屬性替換為一個追蹤存取的 getter 和 setter。setter 和 getter 使用一個額外的物件 propData 來儲存屬性的資料。請注意,我們會破壞性地變更原始實作,這表示我們正在進行元程式設計。

function tracePropertyAccesses(obj, propKeys, log=console.log) {
  // Store the property data here
  const propData = Object.create(null);

  // Replace each property with a getter and a setter
  propKeys.forEach(function (propKey) {
    propData[propKey] = obj[propKey];
    Object.defineProperty(obj, propKey, {
      get: function () {
        log('GET '+propKey);
        return propData[propKey];
      },
      set: function (value) {
        log('SET '+propKey+'='+value);
        propData[propKey] = value;
      },
    });
  });
  return obj;
}

參數 log 可以更輕鬆地對此函式進行單元測試

const obj = {};
const logged = [];
tracePropertyAccesses(obj, ['a', 'b'], x => logged.push(x));

obj.a = 1;
assert.equal(obj.a, 1);

obj.c = 3;
assert.equal(obj.c, 3);

assert.deepEqual(
  logged, [
    'SET a=1',
    'GET a',
  ]);
18.4.1.2 使用 Proxy 實作 tracePropertyAccesses()

Proxy 為我們提供了更簡單的解決方案。我們攔截屬性的取得和設定,而不必變更實作。

function tracePropertyAccesses(obj, propKeys, log=console.log) {
  const propKeySet = new Set(propKeys);
  return new Proxy(obj, {
    get(target, propKey, receiver) {
      if (propKeySet.has(propKey)) {
        log('GET '+propKey);
      }
      return Reflect.get(target, propKey, receiver);
    },
    set(target, propKey, value, receiver) {
      if (propKeySet.has(propKey)) {
        log('SET '+propKey+'='+value);
      }
      return Reflect.set(target, propKey, value, receiver);
    },
  });
}

18.4.2 警告未知屬性(getset

在存取屬性時,JavaScript 非常寬容。例如,如果我們嘗試讀取屬性但拼錯名稱,我們不會收到例外狀況,而是會取得結果 undefined

我們可以使用 Proxy 在這種情況下取得例外狀況。運作方式如下。我們讓 Proxy 成為物件的原型。如果在物件中找不到屬性,則會觸發 Proxy 的 get 陷阱

以下是此方法的實作

const propertyCheckerHandler = {
  get(target, propKey, receiver) {
    // Only check string property keys
    if (typeof propKey === 'string' && !(propKey in target)) {
      throw new ReferenceError('Unknown property: ' + propKey);
    }
    return Reflect.get(target, propKey, receiver);
  }
};
const PropertyChecker = new Proxy({}, propertyCheckerHandler);

讓我們對物件使用 PropertyChecker

const jane = {
  __proto__: PropertyChecker,
  name: 'Jane',
};

// Own property:
assert.equal(
  jane.name,
  'Jane');

// Typo:
assert.throws(
  () => jane.nmae,
  /^ReferenceError: Unknown property: nmae$/);

// Inherited property:
assert.equal(
  jane.toString(),
  '[object Object]');
18.4.2.1 PropertyChecker 作為類別

如果我們將 PropertyChecker 變成建構函式,我們可以使用 extends 將其用於類別

// We can’t change .prototype of classes, so we are using a function
function PropertyChecker2() {}
PropertyChecker2.prototype = new Proxy({}, propertyCheckerHandler);

class Point extends PropertyChecker2 {
  constructor(x, y) {
    super();
    this.x = x;
    this.y = y;
  }
}

const point = new Point(5, 7);
assert.equal(point.x, 5);
assert.throws(
  () => point.z,
  /^ReferenceError: Unknown property: z/);

以下是 point 的原型鏈

const p = Object.getPrototypeOf.bind(Object);
assert.equal(p(point), Point.prototype);
assert.equal(p(p(point)), PropertyChecker2.prototype);
assert.equal(p(p(p(point))), Object.prototype);
18.4.2.2 防止意外建立屬性

如果我們擔心意外建立屬性,我們有兩個選項

18.4.3 負陣列索引(get

有些陣列方法允許我們透過 -1 參照最後一個元素,透過 -2 參照倒數第二個元素,依此類推。例如

> ['a', 'b', 'c'].slice(-1)
[ 'c' ]

唉,當透過方括號運算子([])存取元素時,這項功能無法運作。不過,我們可以使用 Proxy 來新增該功能。以下函式 createArray() 會建立支援負索引的陣列。它透過將 Proxy 包裝在陣列實例中來執行此動作。Proxy 會攔截由方括號運算子觸發的 get 作業。

function createArray(...elements) {
  const handler = {
    get(target, propKey, receiver) {
      if (typeof propKey === 'string') {
        const index = Number(propKey);
        if (index < 0) {
          propKey = String(target.length + index);
        }
      }
      return Reflect.get(target, propKey, receiver);
    }
  };
  // Wrap a proxy around the Array
  return new Proxy(elements, handler);
}
const arr = createArray('a', 'b', 'c');
assert.equal(
  arr[-1], 'c');
assert.equal(
  arr[0], 'a');
assert.equal(
  arr.length, 3);

18.4.4 資料繫結 (set)

資料繫結是關於在物件之間同步資料。一個常見的用例是基於 MVC (Model View Controller) 模式的小工具:透過資料繫結,如果我們變更模型 (小工具視覺化的資料),則檢視 (小工具) 會保持最新狀態。

若要實作資料繫結,我們必須觀察並對物件所做的變更做出反應。下列程式碼片段是觀察變更如何對陣列發揮作用的草圖。

function createObservedArray(callback) {
  const array = [];
  return new Proxy(array, {
    set(target, propertyKey, value, receiver) {
      callback(propertyKey, value);
      return Reflect.set(target, propertyKey, value, receiver);
    }
  });
}
const observedArray = createObservedArray(
  (key, value) => console.log(
    `${JSON.stringify(key)} = ${JSON.stringify(value)}`));
observedArray.push('a');

// Output:
// '"0" = "a"'
// '"length" = 1'

18.4.5 存取 restful 網路服務 (方法呼叫)

可以透過 Proxy 建立一個物件,可以在其上呼叫任意方法。在下列範例中,函式 createWebService() 會建立一個這樣的物件 service。呼叫 service 的方法會擷取與同名網路服務資源的內容。擷取是透過 Promise 處理。

const service = createWebService('http://example.com/data');
// Read JSON data in http://example.com/data/employees
service.employees().then((jsonStr) => {
  const employees = JSON.parse(jsonStr);
  // ···
});

下列程式碼是 createWebService 的快速且簡便實作,不使用 Proxy。我們需要事先知道將在 service 上呼叫哪些方法。參數 propKeys 會提供我們這些資訊;它包含一個包含方法名稱的陣列。

function createWebService(baseUrl, propKeys) {
  const service = {};
  for (const propKey of propKeys) {
    service[propKey] = () => {
      return httpGet(baseUrl + '/' + propKey);
    };
  }
  return service;
}

使用 Proxy 時,createWebService() 會更簡單

function createWebService(baseUrl) {
  return new Proxy({}, {
    get(target, propKey, receiver) {
      // Return the method to be called
      return () => httpGet(baseUrl + '/' + propKey);
    }
  });
}

這兩個實作都使用下列函式來建立 HTTP GET 要求 (其運作方式說明於 JavaScript for impatient programmers 中)。

function httpGet(url) {
  return new Promise(
    (resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(xhr.responseText); // (A)
        } else {
          // Something went wrong (404, etc.)
          reject(new Error(xhr.statusText)); // (B)
        }
      }
      xhr.onerror = () => {
        reject(new Error('Network error')); // (C)
      };
      xhr.open('GET', url);
      xhr.send();
    });
}

18.4.6 可撤銷參考

可撤銷參考 的運作方式如下:不允許用戶端直接存取重要資源 (一個物件),只能透過參考 (一個中間物件,資源的包裝器) 存取。通常,套用至參考的每個操作都會轉送至資源。在用戶端完成後,會透過關閉參考來撤銷參考,以保護資源。從此以後,對參考套用操作會擲回例外,而且不再轉送任何內容。

在下列範例中,我們為資源建立一個可撤銷參考。然後,我們透過參考讀取資源的一個屬性。這會運作,因為參考授予我們存取權。接下來,我們撤銷參考。現在,參考不再讓我們讀取屬性。

const resource = { x: 11, y: 8 };
const {reference, revoke} = createRevocableReference(resource);

// Access granted
assert.equal(reference.x, 11);

revoke();

// Access denied
assert.throws(
  () => reference.x,
  /^TypeError: Cannot perform 'get' on a proxy that has been revoked/
);

Proxy 非常適合實作可撤銷參考,因為它們可以攔截並轉送操作。這是 createRevocableReference 的一個簡單 Proxy-based 實作

function createRevocableReference(target) {
  let enabled = true;
  return {
    reference: new Proxy(target, {
      get(target, propKey, receiver) {
        if (!enabled) {
          throw new TypeError(
            `Cannot perform 'get' on a proxy that has been revoked`);
        }
        return Reflect.get(target, propKey, receiver);
      },
      has(target, propKey) {
        if (!enabled) {
          throw new TypeError(
            `Cannot perform 'has' on a proxy that has been revoked`);
        }
        return Reflect.has(target, propKey);
      },
      // (Remaining methods omitted)
    }),
    revoke: () => {
      enabled = false;
    },
  };
}

可以透過前一節的 Proxy-as-handler 技術簡化程式碼。這次,處理常式基本上是 Reflect 物件。因此,get 陷阱通常會傳回適當的 Reflect 方法。如果參考已被撤銷,則會擲回 TypeError

function createRevocableReference(target) {
  let enabled = true;
  const handler = new Proxy({}, {
    get(_handlerTarget, trapName, receiver) {
      if (!enabled) {
        throw new TypeError(
          `Cannot perform '${trapName}' on a proxy`
          + ` that has been revoked`);
      }
      return Reflect[trapName];
    }
  });
  return {
    reference: new Proxy(target, handler),
    revoke: () => {
      enabled = false;
    },
  };
}

但是,我們不必自己實作可撤銷參考,因為可以撤銷 Proxy。這次,撤銷會在 Proxy 中發生,而不是在處理常式中。處理常式必須做的就是將每個操作轉送至目標。正如我們所見,如果處理常式未實作任何陷阱,就會自動發生。

function createRevocableReference(target) {
  const handler = {}; // forward everything
  const { proxy, revoke } = Proxy.revocable(target, handler);
  return { reference: proxy, revoke };
}
18.4.6.1 膜

建立在可撤銷參考的概念上:安全執行不受信任程式碼的函式庫會在該程式碼周圍包裝一個膜,以隔離程式碼並確保系統的其餘部分安全。物件會在兩個方向傳遞膜

在這兩種情況下,可撤銷的參考都會包覆在物件周圍。由包覆函式或方法傳回的物件也會被包覆。此外,如果將包覆的濕物件傳回隔離層,它將會被解開包覆。

一旦不受信任的程式碼完成,所有可撤銷的參考都會被撤銷。因此,它在外部的程式碼都無法再執行,而它所引用的外部物件也將停止運作。 Caja 編譯器 是「一個讓第三方 HTML、CSS 和 JavaScript 能安全嵌入到您的網站中的工具」。它使用隔離層來達成這個目標。

18.4.7 在 JavaScript 中實作 DOM

瀏覽器的文件物件模型 (DOM) 通常實作為 JavaScript 和 C++ 的混合。在純 JavaScript 中實作它對於下列用途很有用

唉,標準 DOM 可以執行一些在 JavaScript 中不易複製的事情。例如,大多數 DOM 集合都是對 DOM 當前狀態的即時檢視,只要 DOM 發生變更,它們就會動態變更。因此,DOM 的純 JavaScript 實作並非非常有效率。將 Proxy 加入 JavaScript 的原因之一就是為了啟用更有效率的 DOM 實作。

18.4.8 更多使用案例

Proxy 有更多使用案例。例如

18.4.9 使用 Proxy 的函式庫

18.5 Proxy API 的設計

在這個區段,我們將更深入探討 Proxy 的運作方式以及它們為何如此運作。

18.5.1 分層:保持基礎層級和元層級的分離

Firefox 曾經支援一段時間的受限介入式元程式設計:如果物件 O 有名為 __noSuchMethod__ 的方法,則每當呼叫 O 上不存在的方法時,就會通知它。以下程式碼示範其運作方式

const calc = {
  __noSuchMethod__: function (methodName, args) {
    switch (methodName) {
      case 'plus':
        return args.reduce((a, b) => a + b);
      case 'times':
        return args.reduce((a, b) => a * b);
      default:
        throw new TypeError('Unsupported: ' + methodName);
    }
  }
};

// All of the following method calls are implemented via
// .__noSuchMethod__().
assert.equal(
  calc.plus(3, 5, 2), 10);
assert.equal(
  calc.times(2, 3, 4), 24);

assert.equal(
  calc.plus('Parts', ' of ', 'a', ' string'),
  'Parts of a string');

因此,__noSuchMethod__ 的運作方式類似 Proxy 陷阱。與 Proxy 相比,陷阱是我們想要攔截其運作的物件的自身方法或繼承方法。這種方法的問題在於基礎層級(一般方法)和元層級(__noSuchMethod__)會混在一起。基礎層級程式碼可能會意外呼叫或看到元層級方法,而且有意外定義元層級方法的可能性。

即使在標準 ECMAScript 中,基礎層級和元層級有時也會混在一起。例如,以下元程式設計機制可能會失敗,因為它們存在於基礎層級

現在,應該很明顯地知道讓(基礎層級)屬性金鑰變特殊是有問題的。因此,Proxy 是分層的:基礎層級(Proxy 物件)和元層級(處理常式物件)是分開的。

18.5.2 虛擬物件與包裝器

Proxy 用於兩個角色

Proxy API 的早期設計將 Proxy 視為純粹的虛擬物件。然而,事實證明,即使在該角色中,目標也很有用,用於強制不變式(稍後說明)和作為處理常式未實作的陷阱的後備。

18.5.3 透明虛擬化和處理常式封裝

Proxy 有兩種防護方式

這兩個原則賦予 Proxy 相當大的能力,可用於偽裝為其他物件。強制執行不變式(稍後說明)的原因之一是為了控制這種能力。

如果我們確實需要一種方法來區分 Proxy 和非 Proxy,我們必須自己實作。下列程式碼是一個模組 lib.mjs,它匯出兩個函式:其中一個建立 Proxy,另一個判斷物件是否為其中一個 Proxy。

// lib.mjs
const proxies = new WeakSet();

export function createProxy(obj) {
  const handler = {};
  const proxy = new Proxy(obj, handler);
  proxies.add(proxy);
  return proxy;
}

export function isProxy(obj) {
  return proxies.has(obj);
}

此模組使用資料結構 WeakSet 來追蹤 Proxy。WeakSet 非常適合此目的,因為它不會阻止其元素被垃圾回收。

下一個範例顯示如何使用 lib.mjs

// main.mjs
import { createProxy, isProxy } from './lib.mjs';

const proxy = createProxy({});
assert.equal(isProxy(proxy), true);
assert.equal(isProxy({}), false);

18.5.4 元物件協定和 Proxy 陷阱

在本節中,我們將探討 JavaScript 的內部結構以及 Proxy 陷阱的選擇方式。

在程式語言和 API 設計的背景下,協定是一組介面加上使用它們的規則。ECMAScript 規範說明如何執行 JavaScript 程式碼。它包含一個處理物件的協定。此協定在元層級運作,有時稱為元物件協定 (MOP)。JavaScript MOP 包含所有物件都具有的內部方法。“內部”表示它們只存在於規範中(JavaScript 引擎可能具有或不具有它們),且無法從 JavaScript 存取。內部方法的名稱以雙中括號撰寫。

用於取得屬性的內部方法稱為 .[[Get]]()。如果我們使用雙底線而不是雙中括號,此方法將大致在 JavaScript 中實作如下。

// Method definition
__Get__(propKey, receiver) {
  const desc = this.__GetOwnProperty__(propKey);
  if (desc === undefined) {
    const parent = this.__GetPrototypeOf__();
    if (parent === null) return undefined;
    return parent.__Get__(propKey, receiver); // (A)
  }
  if ('value' in desc) {
    return desc.value;
  }
  const getter = desc.get;
  if (getter === undefined) return undefined;
  return getter.__Call__(receiver, []);
}

此程式碼中呼叫的 MOP 方法為

在 A 行中,我們可以看到為什麼原型鏈中的 Proxy 會找出 get,如果在「較早」的物件中找不到屬性:如果沒有金鑰為 propKey 的自有屬性,則會在 this 的原型 parent 中繼續搜尋。

基本運算與衍生運算。我們可以看到 .[[Get]]() 呼叫其他 MOP 運算。執行此動作的運算稱為衍生運算。不依賴其他運算的運算稱為基本運算。

18.5.4.1 Proxy 的物件元協定

Proxy 的物件元協定與一般物件不同。對於一般物件,衍生運算會呼叫其他運算。對於 Proxy,每個運算(不論是基本或衍生)都會被處理常式攔截或轉送至目標。

哪些運算應該可以透過 Proxy 攔截?

後者的優點是能提升效能且更方便。例如,如果沒有 get 的陷阱,我們必須透過 getOwnPropertyDescriptor 來實作其功能。

包含衍生陷阱的缺點是可能會導致 Proxy 行為不一致。例如,get 可能會傳回與 getOwnPropertyDescriptor 傳回的描述符中值不同的值。

18.5.4.2 選擇性攔截:哪些運算應該可以攔截?

Proxy 的攔截是選擇性的:我們無法攔截每個語言運算。為什麼有些運算會被排除?我們來看兩個原因。

首先,穩定運算不適合攔截。如果運算總是對相同的引數產生相同的結果,則該運算是穩定的。如果 Proxy 可以攔截穩定的運算,則它可能會變得不穩定,因此不可靠。嚴格相等===)就是一種穩定的運算。它無法被攔截,且其結果是透過將 Proxy 本身視為另一個物件來計算的。維持穩定的另一種方式是將運算套用至目標,而非 Proxy。如後續說明,當我們檢視如何對 Proxy 執行不變式時,這會發生在 Object.getPrototypeOf() 套用至目標不可擴充的 Proxy 時。

不讓更多運算可以攔截的第二個原因是,攔截表示在通常不可能的情況下執行自訂程式碼。這種程式碼交錯發生的次數越多,就越難理解和除錯程式。它也會對效能產生負面影響。

18.5.4.3 陷阱:getinvoke

如果我們想要透過 Proxy 建立虛擬方法,我們必須從 get 陷阱傳回函式。這引發了一個問題:為什麼不為方法呼叫(例如 invoke)引入額外的陷阱?這將使我們能夠區分

有兩個原因不這麼做。

首先,並非所有實作都區分 getinvoke。例如,Apple 的 JavaScriptCore 沒有區分

其次,提取方法並透過 .call().apply() 在稍後呼叫它,應該與透過派送呼叫方法具有相同效果。換句話說,下列兩個變體應該等效運作。如果有一個額外的陷阱 invoke,那麼這種等效性將更難以維持。

// Variant 1: call via dynamic dispatch
const result1 = obj.m();

// Variant 2: extract and call directly
const m = obj.m;
const result2 = m.call(obj);
18.5.4.3.1 invoke 的使用案例

有些事情只有在我們能夠區分 getinvoke 時才能執行。因此,這些事情使用目前的 Proxy API 是不可能的。兩個範例是:自動繫結和攔截遺失的方法。讓我們探討如果 Proxy 支援 invoke,要如何實作它們。

自動繫結。透過讓 Proxy 成為物件 obj 的原型,我們可以自動繫結方法

自動繫結有助於使用方法作為回呼。例如,前一個範例的變體 2 會變得更簡單

const boundMethod = obj.m;
const result = boundMethod();

攔截遺失的方法。invoke 讓 Proxy 模擬先前提到的 __noSuchMethod__ 機制。Proxy 將再次成為物件 obj 的原型。它會根據未知屬性 prop 的存取方式做出不同的反應

18.5.5 強制 Proxy 的不變式

在我們探討不變式是什麼,以及它們如何對 Proxy 強制執行之前,讓我們回顧如何透過不可擴充性和不可組態性來保護物件。

18.5.5.1 保護物件

有兩種保護物件的方式

有關此主題的更多資訊,請參閱 §10「保護物件不被變更」

18.5.5.2 強制不變式

傳統上,不可擴充性和不可組態性是

這些以及其他在語言操作中保持不變的特徵稱為不變式。透過 Proxy 很容易違反不變式,因為它們本質上不受不可擴充性等約束。Proxy API 透過檢查目標物件和處理常式方法的結果來防止這種情況發生。

接下來的兩個小節描述四個不變式。本章節的結尾提供了不變式的完整清單。

18.5.5.3 透過目標物件強制執行的兩個不變式

以下兩個不變式涉及不可擴充性和不可設定性。這些不變式透過使用目標物件進行簿記來強制執行:處理常式傳回的結果必須大多與目標物件同步。

18.5.5.4 透過檢查回傳值強制執行的兩個不變式

以下兩個不變式透過檢查回傳值來強制執行

18.5.5.5 不變式的優點

強制執行不變式具有以下優點

接下來的兩個區段提供強制執行不變式的範例。

18.5.5.6 範例:不可擴充目標的原型必須忠實表示

針對 getPrototypeOf 陷阱,如果目標不可擴充,則 Proxy 必須傳回目標的原型。

為了展示此不變式,我們來建立一個處理常式,傳回與目標原型不同的原型

const fakeProto = {};
const handler = {
  getPrototypeOf(t) {
    return fakeProto;
  }
};

如果目標可擴充,則偽造原型會起作用

const extensibleTarget = {};
const extProxy = new Proxy(extensibleTarget, handler);

assert.equal(
  Object.getPrototypeOf(extProxy), fakeProto);

然而,如果我們偽造非可延伸物件的原型,我們會收到錯誤。

const nonExtensibleTarget = {};
Object.preventExtensions(nonExtensibleTarget);
const nonExtProxy = new Proxy(nonExtensibleTarget, handler);

assert.throws(
  () => Object.getPrototypeOf(nonExtProxy),
  {
    name: 'TypeError',
    message: "'getPrototypeOf' on proxy: proxy target is"
      + " non-extensible but the trap did not return its"
      + " actual prototype",
  });
18.5.5.7 範例:不可寫入且不可設定的目標屬性必須忠實地表示

如果目標具有不可寫入且不可設定的屬性,則處理常式必須在回應get陷阱時傳回該屬性的值。為了展示此不變式,讓我們建立一個處理常式,它總是為屬性傳回相同的值。

const handler = {
  get(target, propKey) {
    return 'abc';
  }
};
const target = Object.defineProperties(
  {}, {
    manufacturer: {
      value: 'Iso Autoveicoli',
      writable: true,
      configurable: true
    },
    model: {
      value: 'Isetta',
      writable: false,
      configurable: false
    },
  });
const proxy = new Proxy(target, handler);

屬性target.manufacturer既不可寫入也不可設定,這表示處理常式可以假裝它具有不同的值

assert.equal(
  proxy.manufacturer, 'abc');

然而,屬性target.model既不可寫入也不可設定。因此,我們無法偽造它的值

assert.throws(
  () => proxy.model,
  {
    name: 'TypeError',
    message: "'get' on proxy: property 'model' is a read-only and"
      + " non-configurable data property on the proxy target but"
      + " the proxy did not return its actual value (expected"
      + " 'Isetta' but got 'abc')",
  });

18.6 常見問題:代理

18.6.1 enumerate陷阱在哪裡?

ECMAScript 6 原本有一個陷阱enumerate,它會由for-in迴圈觸發。但它最近被移除,以簡化代理。Reflect.enumerate()也已移除。(來源:TC39 備忘錄

18.7 參考:代理 API

本節是代理 API 的快速參考

參考使用下列自訂類型

type PropertyKey = string | symbol;

18.7.1 建立代理

有兩種建立代理的方法

18.7.2 處理常式方法

本小節說明處理常式可以實作哪些陷阱,以及哪些操作會觸發這些陷阱。幾個陷阱會傳回布林值。對於陷阱hasisExtensible,布林值是操作的結果。對於所有其他陷阱,布林值表示操作是否成功。

所有物件的陷阱

函數的陷阱(如果 target 是函數,則可用)

18.7.2.1 基本操作與衍生操作

下列操作為基本操作,它們不使用其他操作來執行工作:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

所有其他操作為衍生操作,它們可透過基本操作來實作。例如,get 可透過 getPrototypeOf 遍歷原型鏈,並為每個鏈成員呼叫 getOwnPropertyDescriptor,直到找到自有屬性或鏈結束為止。

18.7.3 處理常式方法的不變性

不變性是處理常式的安全約束。本小節說明 Proxy API 如何強制執行不變性,以及如何執行。每當我們在下方讀到「處理常式必須執行 X」時,表示如果沒有執行,就會擲出 TypeError。有些不變性限制回傳值,有些則限制參數。陷阱回傳值的正確性以兩種方式確保

以下是強制執行的完整不變性清單

  ECMAScript 規範中的不變式

在規範中,不變式列於章節 “Proxy 物件內部方法和內部插槽” 中。

18.7.4 影響原型鏈的運算

一般物件的下列運算會對原型鏈中的物件執行運算。因此,如果該鏈中的其中一個物件是 Proxy,則會觸發其陷阱。規範會將運算實作為內部自身方法(JavaScript 程式碼無法看到)。但在本節中,我們假設它們是一般方法,其名稱與陷阱相同。參數 target 會成為方法呼叫的接收者。

所有其他運算只會影響自身屬性,它們不會對原型鏈造成影響。

  ECMAScript 規範中的內部運算

在規範中,這些(和其他)運算會在章節 “一般物件內部方法和內部插槽” 中描述。

18.7.5 Reflect

全域物件 Reflect 會將 JavaScript 元物件協定的所有可攔截操作實作為方法。這些方法的名稱與處理方法的名稱相同,如我們所見,這有助於將處理方法的操作轉送至目標。

多種方法有布林值結果。對於 .has().isExtensible(),它們是運算的結果。對於其餘方法,它們表示運算是否成功。

18.7.5.1 除了轉發之外,Reflect 的使用案例

除了轉發運算之外,Reflect 有什麼用 [4]

18.7.5.2 Object.*Reflect.*

未來,Object 將承載對一般應用程式有用的運算,而 Reflect 將承載較低層級的運算。

18.8 結論

這結束了我們對 Proxy API 的深入探討。需要注意的一件事是,Proxy 會讓程式碼變慢。如果效能至關重要,這可能會很重要。

另一方面,效能通常不是至關重要的,而 Proxy 提供給我們的元程式設計能力是很棒的。


致謝

18.9 進一步閱讀