Deep JavaScript
この本のサポートをお願いします: 購入 または 寄付
(広告です。ブロックしないでください。)

18 プロキシによるメタプログラミング



18.1 概要

プロキシを使用すると、オブジェクトに対して実行される操作 (プロパティの取得など) をインターセプトしてカスタマイズできます。プロキシは*メタプログラミング*機能です。

次の例では

ここでは、1 つの操作 (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 を取得すると、ハンドラはその操作をインターセプトします。

assert.equal(
  proxy.size, 123);

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

インターセプト可能な操作のリストについては、完全な API のリファレンスを参照してください。

18.2 プログラミングとメタプログラミング

プロキシとは何か、なぜ便利なのかを理解するには、まず*メタプログラミング*とは何かを理解する必要があります。

プログラミングにはレベルがあります。

基底レベルとメタレベルは異なる言語にすることができます。次のメタプログラムでは、メタプログラミング言語は JavaScript で、基底プログラミング言語は Java です。

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

メタプログラミングはさまざまな形式をとることができます。前の例では、Java コードをコンソールに出力しました。JavaScript をメタプログラミング言語と基底プログラミング言語の両方として使用してみましょう。この古典的な例は、JavaScript コードをオンザフライで評価/コンパイルできる eval() 関数 です。以下のインタラクションでは、式 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] は、3 種類の反射的メタプログラミングを区別しています。

例を見てみましょう。

**例: イントロスペクション。** 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 はインターセッションをサポートしていません。プロキシはそのギャップを埋めるために作成されました。

18.3 プロキシの説明

プロキシは JavaScript にインターセッションをもたらします。プロキシは次のように機能します。オブジェクト obj に対して実行できる操作はたくさんあります。たとえば、

プロキシは、これらの操作の一部をカスタマイズできる特別なオブジェクトです。プロキシは 2 つのパラメータで作成されます。

注: 「インターセッション」の動詞形は「仲裁する」です。仲裁は本質的に双方向です。インターセプトは本質的に一方向です。

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 関数固有のトラップ

ターゲットが関数の場合、さらに 2 つの操作をインターセプトできます。

これらのトラップを関数ターゲットに対してのみ有効にする理由は簡単です。そうでなければ、操作 applyconstruct を転送できません。

18.3.3 メソッド呼び出しのインターセプト

プロキシを介してメソッド呼び出しをインターセプトする場合、課題に直面します。メソッド呼び出し用のトラップはありません。代わりに、メソッド呼び出しは 2 つの操作のシーケンスとして表示されます。

したがって、メソッド呼び出しをインターセプトする場合、2 つの操作をインターセプトする必要があります。

次のコードは、その方法を示しています。

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);
}

2 番目のインターセプトにはプロキシを使用していません。元のメソッドを関数でラップしているだけです。

次のオブジェクトを使用して 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 がプロキシを参照し続けているためです。

これは最も効率的な解決策ではありません。たとえば、メソッドをキャッシュすることができます。さらに、プロキシ自体がパフォーマンスに影響を与えます。

18.3.4 取消可能なプロキシ

プロキシは*取消* (オフにする) ことができます。

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 プロトタイプとしてのプロキシ

プロキシ proto はオブジェクト obj のプロトタイプになることができます。obj で始まる一部の操作は proto で続行される場合があります。そのような操作の 1 つは 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'

プロパティ weightobj には見つからないため、検索は 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の目標の1つでした。

この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 落とし穴:すべてのオブジェクトがProxyによって透過的にラップできるわけではない

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()を直接呼び出すと、thistargetを指します

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

Proxyを介してそのメソッドを呼び出すと、thisproxyを指します

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

つまり、Proxyがメソッド呼び出しをターゲットに転送する場合、thisは変更されません。結果として、ターゲットがメソッド呼び出しを行うためにthisを使用する場合など、Proxyはループ内に留まります。

18.3.7.2 透過的にラップできないオブジェクト

通常、空のハンドラを持つProxyは、ターゲットを透過的にラップします。つまり、Proxyが存在することは気付かず、ターゲットの動作も変更されません。

ただし、ターゲットが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]]()

プロパティとは対照的に、内部スロットへのアクセスは通常の「get」および「set」操作を介して行われません。 .getFullYear()がProxyを介して呼び出された場合、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

配列propKeysにあるキーを持つobjのプロパティが設定または取得されるたびにログを記録する関数tracePropertyAccesses(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'

興味深いことに、thisPointのインスタンスではなく、トレースされたオブジェクトを参照するため、Pointがプロパティにアクセスするたびにトレースも機能します

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

// Output:
// 'GET x'
// 'GET y'
18.4.1.1 ProxyなしでtracePropertyAccesses()を実装する

Proxyがなければ、tracePropertyAccesses()は次のように実装します。各プロパティを、アクセスをトレースするゲッターとセッターに置き換えます。セッターとゲッターは、追加のオブジェクトpropDataを使用してプロパティのデータを格納します。元の implementation を破壊的に変更していることに注意してください。これは、メタプログラミングを行っていることを意味します。

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 プロパティの偶発的な作成の防止

プロパティを誤って*作成*してしまうことを心配している場合は、2つの選択肢があります

18.4.3 負の配列インデックス(get

一部の配列メソッドでは、-1を介して最後の要素、-2を介して最後から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

データバインディングとは、オブジェクト間のデータを同期することです。一般的なユースケースの1つは、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 Webサービスへのアクセス(メソッド呼び出し)

Proxyを使用して、任意のメソッドを呼び出すことができるオブジェクトを作成できます。次の例では、関数createWebService()は、そのようなオブジェクトの1つであるserviceを作成します。 serviceでメソッドを呼び出すと、同じ名前のWebサー

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);
  // ···
});

次のコードは、ProxyなしでcreateWebServiceを迅速かつ大雑把に実装したものです。 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 失効可能な参照

*失効可能な参照*は次のように機能します。クライアントは、重要なリソース(オブジェクト)に直接アクセスすることは許可されておらず、参照(中間オブジェクト、リソースのラッパー)を介してのみアクセスできます。通常、参照に適用されるすべての操作はリソースに転送されます。クライアントが完了した後、リソースは参照を*失効*させること、つまりスイッチをオフにすることによって保護されます。以降、参照に操作を適用すると例外がスローされ、何も転送されなくなります。

次の例では、リソースの失効可能な参照を作成します。次に、参照を介してリソースのプロパティの1つを読み取ります。参照がアクセスを許可するため、これは機能します。次に、参照を失効させます。これで、参照はプロパティを読み取ることができなくなります。

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は、操作をインターセプトして転送できるため、失効可能な参照の実装に最適です。これは、ProxyベースのcreateRevocableReferenceの簡単な実装です

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 Compiler は、「Webサイトに埋め込むサードパーティのHTML、CSS、JavaScriptを安全にするためのツール」です。この目標を達成するためにメンブレンを使用します。

18.4.7 JavaScriptでのDOMの実装

ブラウザのドキュメントオブジェクトモデル(DOM)は、通常、JavaScriptとC ++の組み合わせとして実装されます。純粋なJavaScriptで実装すると、以下のことが可能です。

残念ながら、標準のDOMはJavaScriptでは簡単に複製できないことができます。たとえば、ほとんどのDOMコレクションは、DOMが変更されるたびに動的に変化するDOMの現在の状態のライブビューです。そのため、DOMの純粋なJavaScript実装はあまり効率的ではありません。JavaScriptにProxyを追加した理由の1つは、より効率的な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は、2つの役割で使用されます

Proxy APIの初期の設計では、Proxyは純粋に仮想オブジェクトとして考えられていました。ただし、その役割でも、ターゲットは、不変条件(後で説明します)を適用するため、およびハンドラーが実装していないトラップのフォールバックとして役立つことがわかりました。

18.5.3 透過的な仮想化とハンドラーのカプセル化

Proxyは、2つの方法で保護されています

どちらの原則も、Proxyに他のオブジェクトを偽装するためのかなりの力を与えます。*不変条件*を適用する理由の1つ(後で説明します)は、その力を抑制することです。

Proxyと非Proxyを区別する方法が必要な場合は、自分で実装する必要があります。次のコードは、2つの関数をエクスポートするモジュール `lib.mjs` です。1つはProxyを作成し、もう1つはオブジェクトがそれらのProxyの1つであるかどうかを判断します。

// 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);
}

このモジュールは、Proxyを追跡するためにデータ構造 `WeakSet` を使用します。`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によるインターセプトは*選択的*です。すべての言語操作をインターセプトすることはできません。一部の操作が除外されたのはなぜですか?2つの理由を見てみましょう。

まず、*安定した*操作はインターセプトには適していません。操作は、同じ引数に対して常に同じ結果を生成する場合、*安定*しています。Proxyが安定した操作をトラップできる場合、不安定になり、信頼性が低下する可能性があります。厳密な等価性(`===`)は、そのような安定した操作の1つです。トラップすることはできず、結果はProxy自体を別のオブジェクトとして扱うことによって計算されます。安定性を維持する別の方法は、Proxyではなくターゲットに操作を適用することです。後で説明するように、Proxyの不変条件がどのように適用されるかを見ると、ターゲットが拡張不可能なProxyに `Object.getPrototypeOf()` が適用された場合にこれが発生します。

より多くの操作をインターセプト可能にしない2番目の理由は、インターセプトは通常不可能な状況でカスタムコードを実行することを意味するためです。このコードのインターリーブが多ければ多いほど、プログラムの理解とデバッグが難しくなります。また、パフォーマンスにも悪影響を及ぼします。

18.5.4.3 トラップ:`get` 対 `invoke`

Proxyを介して仮想メソッドを作成する場合、`get` トラップから関数を返す必要があります。ここで疑問が生じます。メソッド呼び出しの追加トラップ(例:`invoke`)を導入しないのはなぜですか?これにより、次のことを区別できます。

そうしない理由は2つあります。

まず、すべての実装が `get` と `invoke` を区別しているわけではありません。たとえば、AppleのJavaScriptCoreは区別していません

次に、メソッドを抽出して `.call()` または `.apply()` を介して後で呼び出すと、ディスパッチを介してメソッドを呼び出すのと同じ効果が得られるはずです。つまり、次の2つのバリアントは同等に機能するはずです。追加のトラップ `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` のユースケース

`get` と `invoke` を区別できる場合にのみ実行できることがいくつかあります。したがって、これらのことは、現在のProxy APIでは不可能です。2つの例は、自動バインディングと欠落しているメソッドのインターセプトです。Proxyが `invoke` をサポートしている場合、どのように実装するかを見てみましょう。

自動バインディング。 オブジェクト obj のプロトタイプを Proxy にすることで、メソッドを自動的にバインドできます。

自動バインディングは、メソッドをコールバックとして使用する場合に役立ちます。たとえば、前の例の変形 2 はよりシンプルになります。

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

存在しないメソッドのインターセプト。 invoke を使用すると、Proxy は前述の __noSuchMethod__ メカニズムをエミュレートできます。 Proxy は再びオブジェクト obj のプロトタイプになります。不明なプロパティ prop へのアクセス方法に応じて、異なる反応を示します。

18.5.5 Proxy の不変条件の強制

不変条件とは何か、そして Proxy に対してどのように強制されるかを見ていく前に、オブジェクトを拡張不可と設定不可によってどのように保護できるかを復習しましょう。

18.5.5.1 オブジェクトの保護

オブジェクトを保護するには2つの方法があります。

このトピックの詳細については、§10「オブジェクトの変更からの保護」を参照してください。

18.5.5.2 不変条件の強制

従来、拡張不可と設定不可は

言語操作に直面しても変化しないこれらの特性やその他の特性は、*不変条件*と呼ばれます。 Proxy は拡張不可などに本質的に束縛されていないため、Proxy を介して不変条件に違反するのは簡単です。 Proxy API は、ターゲットオブジェクトとハンドラーメソッドの結果をチェックすることにより、それが発生するのを防ぎます。

次の2つのサブセクションでは、4つの不変条件について説明します。不変条件の完全なリストは、この章の最後に示されています。

18.5.5.3 ターゲットオブジェクトを介して強制される2つの不変条件

次の2つの不変条件は、拡張不可と設定不可に関係しています。これらは、ターゲットオブジェクトをブックキーピングに使用することで強制されます。ハンドラーメソッドによって返される結果は、ターゲットオブジェクトとほぼ同期している必要があります。

18.5.5.4 戻り値をチェックすることで強制される2つの不変条件

次の2つの不変条件は、戻り値をチェックすることで強制されます。

18.5.5.5 不変条件の利点

不変条件を強制することには、次の利点があります。

次の2つのセクションでは、不変条件が強制される例を示します。

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 FAQ:Proxy

18.6.1 enumerate トラップはどこにありますか?

ECMAScript 6 には元々、for-in ループによってトリガーされるトラップ enumerate がありました。しかし、Proxy を簡素化するために、最近削除されました。 Reflect.enumerate() も削除されました。(出典:TC39 ノート

18.7 リファレンス:Proxy API

このセクションは、Proxy API のクイックリファレンスです。

リファレンスは、次のカスタムタイプを使用します。

type PropertyKey = string | symbol;

18.7.1 Proxy の作成

Proxy を作成するには2つの方法があります。

18.7.2 ハンドラーメソッド

このサブセクションでは、ハンドラーによって実装できるトラップと、それらをトリガーする操作について説明します。いくつかのトラップはブール値を返します。トラップ hasisExtensible の場合、ブール値は操作の結果です。他のすべてのトラップの場合、ブール値は操作が成功したかどうかを示します。

すべてのオブジェクトのトラップ

関数のトラップ(ターゲットが関数の場合は使用可能)

18.7.2.1 基本操作と派生操作

次の操作は*基本操作*であり、他の操作を使用して作業を行うことはありません:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

他のすべての操作は*派生操作*であり、基本操作を介して実装できます。たとえば、get は、getPrototypeOf を介してプロトタイプチェーンを反復処理し、各チェーンメンバーに対して getOwnPropertyDescriptor を呼び出すことによって、独自のプロパティが見つかるかチェーンが終了するまで実装できます。

18.7.3 ハンドラーメソッドの不変条件

不変条件は、ハンドラーの安全制約です。このサブセクションでは、Proxy API によってどのような不変条件がどのように強制されるかについて説明します。以下で「ハンドラーはXを実行する必要がある」と読むたびに、それが行われない場合は TypeError がスローされることを意味します。一部の不変条件は戻り値を制限し、他の不変条件はパラメーターを制限します。トラップの戻り値の正確性は、2つの方法で保証されます。

これは、強制される不変条件の完全なリストです。

  ECMAScript 仕様における不変条件

仕様書では、不変条件はセクション「プロキシオブジェクトの内部メソッドと内部スロット」に記載されています。

18.7.4 プロトタイプチェーンに影響を与える操作

通常のオブジェクトの以下の操作は、プロトタイプチェーン内のオブジェクトに対して操作を実行します。そのため、そのチェーン内のオブジェクトの1つが 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 の詳細な説明は終わりです。注意すべき点の1つは、Proxy がコードを遅くすることです。パフォーマンスが重要な場合は、それが問題になる可能性があります。

一方、パフォーマンスはしばしば重要ではなく、Proxy が私たちに与えるメタプログラミング能力を持っていることは素晴らしいことです。


謝辞

18.9 参考文献