2022年6月26日

ProxyとReflect

Proxyオブジェクトは、別のオブジェクトをラップし、プロパティの読み書きなどの操作をインターセプトし、必要に応じて自身で処理するか、またはオブジェクトに透過的に処理させることができます。

Proxyは多くのライブラリやブラウザフレームワークで使用されています。この記事では、多くの実用的なアプリケーションを見ていきます。

Proxy

構文

let proxy = new Proxy(target, handler)
  • target – ラップするオブジェクト。関数を含め、何でも可能です。
  • handler – プロキシ構成。"トラップ"、操作をインターセプトするメソッドを持つオブジェクトです。– 例:targetのプロパティを読み取るためのgetトラップ、targetにプロパティを書き込むためのsetトラップなど。

proxyに対する操作では、handlerに対応するトラップがある場合、それが実行され、プロキシがそれを処理する機会を得ます。そうでない場合、操作はtarget上で実行されます。

開始例として、トラップなしでプロキシを作成してみましょう。

let target = {};
let proxy = new Proxy(target, {}); // empty handler

proxy.test = 5; // writing to proxy (1)
alert(target.test); // 5, the property appeared in target!

alert(proxy.test); // 5, we can read it from proxy too (2)

for(let key in proxy) alert(key); // test, iteration works (3)

トラップがないため、proxyに対するすべての操作はtargetに転送されます。

  1. 書き込み操作 proxy.test=targetに値を設定します。
  2. 読み取り操作 proxy.testtargetから値を返します。
  3. proxyに対する反復処理は、targetから値を返します。

ご覧のように、トラップがない場合、proxytargetの透過的なラッパーです。

Proxyは特別な「エキゾチックオブジェクト」です。独自のプロパティを持っていません。空のhandlerを使用すると、操作を透過的にtargetに転送します。

より多くの機能をアクティブにするには、トラップを追加してみましょう。

トラップで何をインターセプトできるのでしょうか?

オブジェクトに対するほとんどの操作には、JavaScriptの仕様で、最低レベルでどのように動作するかを記述した「内部メソッド」と呼ばれるものがあります。たとえば、プロパティを読み取るための内部メソッド[[Get]]、プロパティを書き込むための内部メソッド[[Set]]などがあります。これらのメソッドは仕様でのみ使用され、名前で直接呼び出すことはできません。

Proxyトラップは、これらのメソッドの呼び出しをインターセプトします。これらは、Proxy仕様および下の表にリストされています。

すべての内部メソッドに対して、この表にトラップがあります。操作をインターセプトするために、new Proxyhandlerパラメーターに追加できるメソッドの名前です。

内部メソッド ハンドラーメソッド 次のときにトリガーされる
[[Get]] get プロパティを読み取る
[[Set]] set プロパティに書き込む
[[HasProperty]] has in演算子
[[Delete]] deleteProperty delete演算子
[[Call]] apply 関数呼び出し
[[Construct]] construct new演算子
[[GetPrototypeOf]] getPrototypeOf Object.getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf Object.setPrototypeOf
[[IsExtensible]] isExtensible Object.isExtensible
[[PreventExtensions]] preventExtensions Object.preventExtensions
[[DefineOwnProperty]] defineProperty Object.definePropertyObject.defineProperties
[[GetOwnProperty]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptorfor..inObject.keys/values/entries
[[OwnPropertyKeys]] ownKeys Object.getOwnPropertyNamesObject.getOwnPropertySymbolsfor..inObject.keys/values/entries
不変条件

JavaScriptは、不変条件と呼ばれる、内部メソッドとトラップによって満たされなければならない条件を強制します。

それらのほとんどは戻り値用です。

  • [[Set]]は、値が正常に書き込まれた場合はtrueを返し、そうでない場合はfalseを返す必要があります。
  • [[Delete]]は、値が正常に削除された場合はtrueを返し、そうでない場合はfalseを返す必要があります。
  • …以下に例を挙げます。

他にもいくつかの不変条件があります。

  • プロキシオブジェクトに適用される[[GetPrototypeOf]]は、プロキシオブジェクトのターゲットオブジェクトに適用される[[GetPrototypeOf]]と同じ値を返す必要があります。言い換えれば、プロキシのプロトタイプを読み取ることは、常にターゲットオブジェクトのプロトタイプを返す必要があります。

トラップはこれらの操作をインターセプトできますが、これらのルールに従う必要があります。

不変条件は、言語機能の正確で一貫した動作を保証します。完全な不変条件のリストは仕様にあります。何かおかしなことをしていない限り、おそらくそれらに違反することはないでしょう。

それが実際どのように機能するかを見てみましょう。

「get」トラップによるデフォルト値

最も一般的なトラップは、プロパティの読み取り/書き込み用です。

読み取りをインターセプトするには、handlerにメソッドget(target, property, receiver)が必要です。

プロパティが読み取られると、次の引数でトリガーされます。

  • target – ターゲットオブジェクト。new Proxyに最初の引数として渡されたオブジェクトです。
  • property – プロパティ名。
  • receiver – ターゲットプロパティがゲッターの場合、receiverは、その呼び出しでthisとして使用されるオブジェクトです。通常、それはproxyオブジェクト自体(またはプロキシから継承する場合はプロキシから継承したオブジェクト)です。今のところ、この引数は必要ないため、後で詳しく説明します。

getを使用して、オブジェクトのデフォルト値を実装してみましょう。

存在しない値に対して0を返す数値配列を作成します。

通常、存在しない配列項目を取得しようとすると、undefinedが取得されますが、読み取りをトラップし、そのようなプロパティがない場合は0を返すプロキシに通常の配列をラップします。

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // default value
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (no such item)

ご覧のように、getトラップを使用すると非常に簡単に行うことができます。

Proxyを使用して、「デフォルト」値の任意のロジックを実装できます。

フレーズとその翻訳を含む辞書があると想像してください。

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined

現時点では、フレーズがない場合、dictionaryからの読み取りはundefinedを返します。しかし実際には、フレーズを翻訳されていないままにすることは、通常、undefinedよりも優れています。したがって、undefinedの代わりに、その場合は翻訳されていないフレーズを返すようにします。

それを実現するために、読み取り操作をインターセプトするプロキシでdictionaryをラップします。

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

dictionary = new Proxy(dictionary, {
  get(target, phrase) { // intercept reading a property from dictionary
    if (phrase in target) { // if we have it in the dictionary
      return target[phrase]; // return the translation
    } else {
      // otherwise, return the non-translated phrase
      return phrase;
    }
  }
});

// Look up arbitrary phrases in the dictionary!
// At worst, they're not translated.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (no translation)
ご注意ください

プロキシが変数をどのように上書きしているかにご注意ください。

dictionary = new Proxy(dictionary, ...);

プロキシは、ターゲットオブジェクトを完全に置き換える必要があります。プロキシされた後、ターゲットオブジェクトを参照するべきではありません。そうしないと、簡単に混乱してしまう可能性があります。

「set」トラップによる検証

数値専用の配列が必要だとします。別の型の値が追加された場合は、エラーが発生する必要があります。

setトラップは、プロパティが書き込まれるときにトリガーされます。

set(target, property, value, receiver):

  • target – ターゲットオブジェクト。new Proxyに最初の引数として渡されたオブジェクトです。
  • property – プロパティ名。
  • value – プロパティ値。
  • receivergetトラップと同様に、セッタープロパティでのみ問題になります。

設定が成功した場合は、setトラップはtrueを返し、そうでない場合はfalse(TypeErrorをトリガーします)を返す必要があります。

それを使用して新しい値を検証してみましょう。

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // to intercept property writing
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // added successfully
numbers.push(2); // added successfully
alert("Length is: " + numbers.length); // 2

numbers.push("test"); // TypeError ('set' on proxy returned false)

alert("This line is never reached (error in the line above)");

ご注意ください。配列の組み込み機能はまだ機能しています。値はpushで追加されます。値が追加されると、lengthプロパティは自動的に増加します。プロキシは何も壊しません。

pushunshiftなどの値を追加する配列メソッドをオーバーライドして、そこにチェックを追加する必要はありません。なぜなら、内部的にはプロキシによってインターセプトされる[[Set]]操作を使用するからです。

そのため、コードはクリーンで簡潔です。

必ずtrueを返す

上記のように、保持される不変条件があります。

setの場合、正常な書き込みの場合はtrueを返す必要があります。

それを忘れたり、偽の値を返したりすると、操作によってTypeErrorが発生します。

「ownKeys」および「getOwnPropertyDescriptor」を使用した反復

Object.keysfor..inループ、およびオブジェクトプロパティを反復処理する他のほとんどのメソッドは、プロパティのリストを取得するために[[OwnPropertyKeys]]内部メソッド(ownKeysトラップによってインターセプトされる)を使用します。

このようなメソッドは詳細が異なります。

  • Object.getOwnPropertyNames(obj)は、シンボルではないキーを返します。
  • Object.getOwnPropertySymbols(obj)は、シンボルキーを返します。
  • Object.keys/values()は、enumerableフラグ付きのシンボルではないキー/値を返します(プロパティフラグは、記事プロパティフラグとディスクリプタで説明しました)。
  • for..inは、enumerableフラグ付きのシンボルではないキー、およびプロトタイプキーをループ処理します。

…しかし、すべてはそのリストから始まります。

以下の例では、ownKeysトラップを使用して、for..inループがuserを反復処理し、さらにObject.keysObject.valuesがアンダースコア_で始まるプロパティをスキップするようにします。

let user = {
  name: "John",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "ownKeys" filters out _password
for(let key in user) alert(key); // name, then: age

// same effect on these methods:
alert( Object.keys(user) ); // name,age
alert( Object.values(user) ); // John,30

今のところ、これは機能しています。

ただし、オブジェクトに存在しないキーを返した場合、Object.keysはそれをリストアップしません。

let user = { };

user = new Proxy(user, {
  ownKeys(target) {
    return ['a', 'b', 'c'];
  }
});

alert( Object.keys(user) ); // <empty>

なぜでしょうか?理由は簡単です。Object.keysは、enumerableフラグを持つプロパティのみを返します。これをチェックするために、すべてのプロパティに対して内部メソッド[[GetOwnProperty]]を呼び出し、その記述子を取得します。そしてここで、プロパティが存在しないため、その記述子は空であり、enumerableフラグがないため、スキップされます。

Object.keysがプロパティを返すには、プロパティがenumerableフラグ付きでオブジェクトに存在するか、[[GetOwnProperty]]への呼び出し(トラップgetOwnPropertyDescriptorがそれを行います)をインターセプトして、enumerable: trueを持つ記述子を返す必要があります。

以下はその例です。

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // called once to get a list of properties
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // called for every property
    return {
      enumerable: true,
      configurable: true
      /* ...other flags, probable "value:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c

もう一度注意しておきましょう。[[GetOwnProperty]]をインターセプトする必要があるのは、プロパティがオブジェクトに存在しない場合のみです。

「deleteProperty」およびその他のトラップによる保護されたプロパティ

アンダースコア_で始まるプロパティとメソッドは内部用であるという広範な慣習があります。これらはオブジェクトの外部からアクセスすべきではありません。

ただし、技術的には可能です。

let user = {
  name: "John",
  _password: "secret"
};

alert(user._password); // secret

プロキシを使用して、_で始まるプロパティへのアクセスをすべて防止しましょう。

以下のトラップが必要です。

  • そのようなプロパティを読み取る際にエラーをスローするget
  • 書き込み時にエラーをスローするset
  • 削除時にエラーをスローするdeleteProperty
  • for..inObject.keysのようなメソッドから_で始まるプロパティを除外するownKeys

以下にコードを示します。

let user = {
  name: "John",
  _password: "***"
};

user = new Proxy(user, {
  get(target, prop) {
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    }
    let value = target[prop];
    return (typeof value === 'function') ? value.bind(target) : value; // (*)
  },
  set(target, prop, val) { // to intercept property writing
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // to intercept property deletion
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // to intercept property list
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" doesn't allow to read _password
try {
  alert(user._password); // Error: Access denied
} catch(e) { alert(e.message); }

// "set" doesn't allow to write _password
try {
  user._password = "test"; // Error: Access denied
} catch(e) { alert(e.message); }

// "deleteProperty" doesn't allow to delete _password
try {
  delete user._password; // Error: Access denied
} catch(e) { alert(e.message); }

// "ownKeys" filters out _password
for(let key in user) alert(key); // name

getトラップの(*)の行にある重要な詳細に注意してください。

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

なぜvalue.bind(target)を呼び出す関数が必要なのでしょうか?

理由は、user.checkPassword()のようなオブジェクトメソッドが_passwordにアクセスできるようにする必要があるからです。

user = {
  // ...
  checkPassword(value) {
    // object method must be able to read _password
    return value === this._password;
  }
}

user.checkPassword()への呼び出しは、thisとしてプロキシ化されたuserを取得します(ドットの前にあるオブジェクトがthisになります)。したがって、this._passwordにアクセスしようとすると、getトラップがアクティブになり(任意のプロパティの読み取りでトリガーされます)、エラーがスローされます。

そこで、オブジェクトメソッドのコンテキストを元のオブジェクトtarget(*)の行でバインドします。その後、将来の呼び出しではトラップなしでthisとしてtargetが使用されます。

その解決策は通常は機能しますが、理想的ではありません。メソッドがプロキシ化されていないオブジェクトをどこか別の場所に渡してしまうと、元のオブジェクトがどこにあり、プロキシ化されたオブジェクトがどこにあるのかが混乱する可能性があります。

また、オブジェクトは複数回プロキシ化される可能性があり(複数のプロキシがオブジェクトに異なる「調整」を追加する可能性があります)、アンラップされたオブジェクトをメソッドに渡すと、予期しない結果が発生する可能性があります。

したがって、このようなプロキシはどこでも使用すべきではありません。

クラスのプライベートプロパティ

最新のJavaScriptエンジンは、クラスで#で始まるプライベートプロパティをネイティブにサポートしています。これらは、プライベートおよび保護されたプロパティとメソッドの記事で説明されています。プロキシは必要ありません。

ただし、このようなプロパティには独自の問題があります。特に、これらは継承されません。

「has」トラップを使用した「範囲内」

さらに例を見てみましょう。

範囲オブジェクトがあるとします。

let range = {
  start: 1,
  end: 10
};

in演算子を使用して、数値がrange内にあるかどうかを確認したいと思います。

hasトラップはin呼び出しをインターセプトします。

has(target, property)

  • targetnew Proxyに最初の引数として渡されたターゲットオブジェクトです。
  • property – プロパティ名

以下にデモを示します。

let range = {
  start: 1,
  end: 10
};

range = new Proxy(range, {
  has(target, prop) {
    return prop >= target.start && prop <= target.end;
  }
});

alert(5 in range); // true
alert(50 in range); // false

素晴らしい構文糖衣ですね。そして非常に簡単に実装できます。

関数のラップ:「apply」

関数をプロキシでラップすることもできます。

apply(target, thisArg, args)トラップは、関数としてプロキシを呼び出す処理を行います。

  • targetはターゲットオブジェクト(JavaScriptでは関数はオブジェクトです)です。
  • thisArgthisの値です。
  • argsは引数のリストです。

たとえば、デコレータと転送、call/applyの記事で行ったdelay(f, ms)デコレータを思い出してみましょう。

その記事では、プロキシを使用せずにそれを行いました。delay(f, ms)を呼び出すと、msミリ秒後にすべての呼び出しをfに転送する関数が返されました。

以下は以前の関数ベースの実装です。

function delay(f, ms) {
  // return a wrapper that passes the call to f after the timeout
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

// after this wrapping, calls to sayHi will be delayed for 3 seconds
sayHi = delay(sayHi, 3000);

sayHi("John"); // Hello, John! (after 3 seconds)

すでに見たように、これはほとんどの場合機能します。ラッパー関数(*)は、タイムアウト後に呼び出しを実行します。

ただし、ラッパー関数はプロパティの読み取り/書き込み操作やその他の操作を転送しません。ラッピング後、namelengthなどの元の関数のプロパティへのアクセスが失われます。

function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

alert(sayHi.length); // 1 (function length is the arguments count in its declaration)

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 0 (in the wrapper declaration, there are zero arguments)

Proxyは、すべての操作をターゲットオブジェクトに転送するため、はるかに強力です。

ラッパー関数の代わりにProxyを使用してみましょう。

function delay(f, ms) {
  return new Proxy(f, {
    apply(target, thisArg, args) {
      setTimeout(() => target.apply(thisArg, args), ms);
    }
  });
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 1 (*) proxy forwards "get length" operation to the target

sayHi("John"); // Hello, John! (after 3 seconds)

結果は同じですが、これで呼び出しだけでなく、プロキシに対するすべての操作が元の関数に転送されます。したがって、sayHi.length(*)の行のラッピング後も正しく返されます。

より「リッチ」なラッパーを取得しました。

他のトラップも存在します。完全なリストはこの記事の冒頭にあります。それらの使用パターンは上記のものと似ています。

Reflect

Reflectは、Proxyの作成を簡素化する組み込みオブジェクトです。

以前に、[[Get]][[Set]]などの内部メソッドは仕様のみであり、直接呼び出すことはできないと述べました。

Reflectオブジェクトは、それをいくらか可能にします。そのメソッドは、内部メソッドの最小限のラッパーです。

以下は、同じことを行う操作とReflect呼び出しの例です。

操作 Reflect呼び出し 内部メソッド
obj[prop] Reflect.get(obj, prop) [[Get]]
obj[prop] = value Reflect.set(obj, prop, value) [[Set]]
delete obj[prop] Reflect.deleteProperty(obj, prop) [[Delete]]
new F(value) Reflect.construct(F, value) [[Construct]]

例として

let user = {};

Reflect.set(user, 'name', 'John');

alert(user.name); // John

特に、Reflectを使用すると、演算子(newdelete…)を関数(Reflect.constructReflect.deleteProperty、…)として呼び出すことができます。これは興味深い機能ですが、ここでは別のことが重要です。

Proxyでトラップ可能なすべての内部メソッドに対して、Reflectには対応するメソッドがあり、Proxyトラップと同じ名前と引数を持っています。

したがって、Reflectを使用して操作を元のオブジェクトに転送できます。

この例では、両方のトラップgetsetが、メッセージを表示しながら、読み取り/書き込み操作をオブジェクトに透過的に(まるで存在しないかのように)転送します。

let user = {
  name: "John",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    alert(`GET ${prop}`);
    return Reflect.get(target, prop, receiver); // (1)
  },
  set(target, prop, val, receiver) {
    alert(`SET ${prop}=${val}`);
    return Reflect.set(target, prop, val, receiver); // (2)
  }
});

let name = user.name; // shows "GET name"
user.name = "Pete"; // shows "SET name=Pete"

ここでは

  • Reflect.getはオブジェクトプロパティを読み取ります。
  • Reflect.setはオブジェクトプロパティを書き込み、成功した場合はtrue、それ以外の場合はfalseを返します。

つまり、すべてがシンプルです。トラップが呼び出しをオブジェクトに転送したい場合は、同じ引数でReflect.<method>を呼び出すだけで十分です。

ほとんどの場合、Reflectなしで同じことができます。たとえば、プロパティReflect.get(target, prop, receiver)の読み取りをtarget[prop]で置き換えることができます。ただし、重要なニュアンスがあります。

ゲッターのプロキシ

Reflect.getが優れている理由を示す例を見てみましょう。また、以前は使用しなかったget/setが3番目の引数receiverを持っている理由も見てみましょう。

_nameプロパティとそのゲッターを持つオブジェクトuserがあるとします。

以下は、その周囲のプロキシです。

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop];
  }
});

alert(userProxy.name); // Guest

getトラップはここで「透過的」であり、元のプロパティを返し、他に何も実行しません。これは、私たちの例では十分です。

すべて問題ないように見えます。しかし、例をもう少し複雑にしてみましょう。

別のオブジェクトadminuserから継承した後、正しくない動作を確認できます。

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) {
    return target[prop]; // (*) target = user
  }
});

let admin = {
  __proto__: userProxy,
  _name: "Admin"
};

// Expected: Admin
alert(admin.name); // outputs: Guest (?!?)

admin.nameを読み取ると、"Guest"ではなく"Admin"が返されるはずです。

何が問題なのでしょうか?おそらく継承で何か間違ったことをしたのでしょうか?

ただし、プロキシを削除すると、すべてが期待どおりに機能します。

問題は実際にはプロキシ、つまり(*)の行にあります。

  1. admin.nameを読み取ると、adminオブジェクトにはそのような独自のプロパティがないため、検索はそのプロトタイプに移動します。

  2. プロトタイプはuserProxyです。

  3. プロキシからnameプロパティを読み取るとき、そのgetトラップがトリガーされ、(*)の行でtarget[prop]として元のオブジェクトからそれを返します。

    propがゲッターの場合、target[prop]を呼び出すと、コンテキストthis=targetでそのコードが実行されます。したがって、結果は元のオブジェクトtargetからのthis._name、つまりuserからのものです。

このような状況を修正するには、getトラップの3番目の引数であるreceiverが必要です。これは、ゲッターに渡される正しいthisを保持します。この例では、それがadminです。

ゲッターのコンテキストを渡すにはどうすればよいでしょうか?通常の関数の場合、call/applyを使用できますが、ゲッターは「呼び出される」のではなく、アクセスされるだけです。

Reflect.getはそれを行うことができます。それを使用すると、すべてが正しく機能します。

以下は修正されたバリアントです。

let user = {
  _name: "Guest",
  get name() {
    return this._name;
  }
};

let userProxy = new Proxy(user, {
  get(target, prop, receiver) { // receiver = admin
    return Reflect.get(target, prop, receiver); // (*)
  }
});


let admin = {
  __proto__: userProxy,
  _name: "Admin"
};

alert(admin.name); // Admin

これで、正しいthis(つまりadmin)への参照を保持するreceiverが、(*)の行でReflect.getを使用してゲッターに渡されます。

トラップをさらに短く書き直すことができます。

get(target, prop, receiver) {
  return Reflect.get(...arguments);
}

Reflect呼び出しはトラップとまったく同じように名前が付けられ、同じ引数を受け入れます。これらはこのように特別に設計されました。

したがって、return Reflect...は、操作を転送し、それに関連するものを忘れないようにするための、安全で簡単な方法を提供します。

プロキシの制限

プロキシは、既存のオブジェクトの動作を最下位レベルで変更または調整する独自の方法を提供します。それでも、完璧ではありません。制限事項があります。

組み込みオブジェクト:内部スロット

MapSetDatePromiseなどの多くの組み込みオブジェクトは、いわゆる「内部スロット」を使用します。

これらはプロパティのようなものですが、内部の、仕様のみの目的のために予約されています。たとえば、Mapは内部スロット[[MapData]]に項目を格納します。組み込みメソッドは、内部メソッド[[Get]]/[[Set]]を介してではなく、直接アクセスします。したがって、Proxyはそれをインターセプトできません。

なぜ気にするのですか?結局のところ、それらは内部的なものです!

さて、ここに問題があります。そのような組み込みオブジェクトがプロキシ化された後、プロキシにはこれらの内部スロットがないため、組み込みメソッドは失敗します。

例として

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('test', 1); // Error

内部的には、Mapはすべてのデータをその[[MapData]]内部スロットに格納します。プロキシにはそのようなスロットはありません。組み込みメソッドMap.prototype.setメソッドは、内部プロパティthis.[[MapData]]にアクセスしようとしますが、this=proxyであるため、proxy内で見つけることができず、失敗します。

幸いなことに、それを修正する方法があります。

let map = new Map();

let proxy = new Proxy(map, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

proxy.set('test', 1);
alert(proxy.get('test')); // 1 (works!)

これで、getトラップがmap.setなどの関数プロパティをターゲットオブジェクト(map)自体にバインドするため、正常に機能します。

前の例とは異なり、proxy.set(...)内のthisの値はproxyではなく、元のmapになります。したがって、setの内部実装がthis.[[MapData]]内部スロットにアクセスしようとすると、成功します。

Arrayには内部スロットがありません

注目すべき例外:組み込みのArrayは内部スロットを使用しません。これは、それが非常に昔に登場したため、歴史的な理由によります。

したがって、配列をプロキシ化するときには、そのような問題はありません。

プライベートフィールド

同様のことが、クラスのプライベートフィールドでも発生します。

例えば、getName() メソッドはプライベートな #name プロパティにアクセスしますが、プロキシ化の後で失敗します。

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {});

alert(user.getName()); // Error

その理由は、プライベートフィールドが内部スロットを使って実装されているためです。JavaScriptは、それらにアクセスする際に [[Get]]/[[Set]] を使用しません。

getName() の呼び出しにおいて、this の値はプロキシ化された user であり、プライベートフィールドを持つスロットを持っていません。

再度になりますが、メソッドをバインドする解決策はそれを機能させます。

class User {
  #name = "Guest";

  getName() {
    return this.#name;
  }
}

let user = new User();

user = new Proxy(user, {
  get(target, prop, receiver) {
    let value = Reflect.get(...arguments);
    return typeof value == 'function' ? value.bind(target) : value;
  }
});

alert(user.getName()); // Guest

ただし、前述したように、この解決策には欠点があります。それは、元のオブジェクトをメソッドに公開してしまい、それがさらに渡されて他のプロキシ化された機能を壊す可能性があるということです。

プロキシ != ターゲット

プロキシと元のオブジェクトは異なるオブジェクトです。それは自然なことですよね?

したがって、元のオブジェクトをキーとして使用し、それをプロキシ化した場合、プロキシは見つけることができません。

let allUsers = new Set();

class User {
  constructor(name) {
    this.name = name;
    allUsers.add(this);
  }
}

let user = new User("John");

alert(allUsers.has(user)); // true

user = new Proxy(user, {});

alert(allUsers.has(user)); // false

ご覧のとおり、プロキシ化後、user はセット allUsers の中で見つけることができません。なぜなら、プロキシは異なるオブジェクトだからです。

プロキシは厳密等価性テスト === をインターセプトできません。

プロキシは、new (construct を使用)、in (has を使用)、delete (deleteProperty を使用) など、多くの演算子をインターセプトできます。

しかし、オブジェクトの厳密等価性テストをインターセプトする方法はありません。オブジェクトは自分自身とのみ厳密に等しく、他のどの値とも等しくありません。

そのため、オブジェクトの等価性を比較するすべての操作と組み込みクラスは、オブジェクトとプロキシを区別します。ここには透過的な置き換えはありません。

取消可能プロキシ

取消可能 プロキシは、無効にできるプロキシです。

たとえば、リソースを持っていて、いつでもアクセスを閉じたいとしましょう。

私たちができることは、トラップなしでそれを取消可能プロキシにラップすることです。このようなプロキシは、操作をオブジェクトに転送し、いつでも無効にすることができます。

構文は次のとおりです。

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

この呼び出しは、プロキシを無効にするための proxyrevoke 関数を持つオブジェクトを返します。

以下に例を示します。

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

// pass the proxy somewhere instead of object...
alert(proxy.data); // Valuable data

// later in our code
revoke();

// the proxy isn't working any more (revoked)
alert(proxy.data); // Error

revoke() を呼び出すと、プロキシからターゲットオブジェクトへのすべての内部参照が削除されるため、それらはもはや接続されません。

当初、revokeproxy とは分離しているので、revoke を現在のスコープに残したまま proxy を渡すことができます。

proxy.revoke = revoke を設定することにより、revoke メソッドをプロキシにバインドすることもできます。

別のオプションは、proxy をキーとし、対応する revoke を値とする WeakMap を作成することです。これにより、プロキシの revoke を簡単に見つけることができます。

let revokes = new WeakMap();

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// ..somewhere else in our code..
revoke = revokes.get(proxy);
revoke();

alert(proxy.data); // Error (revoked)

ここでは、ガベージコレクションをブロックしないため、Map ではなく WeakMap を使用します。プロキシオブジェクトが「到達不能」になった場合(たとえば、どの変数もそれを参照しなくなった場合)、WeakMap を使用すると、もはや必要なくなった revoke とともにメモリから消去できます。

参考文献

概要

Proxy はオブジェクトの周りのラッパーであり、オブジェクトに対する操作をオブジェクトに転送し、オプションでそれらの一部をトラップします。

クラスや関数を含む、あらゆる種類のオブジェクトをラップできます。

構文は次のとおりです。

let proxy = new Proxy(target, {
  /* traps */
});

…それなら、target の代わりに常に proxy を使用する必要があります。プロキシには独自のプロパティやメソッドはありません。トラップが提供されている場合は操作をトラップし、それ以外の場合は target オブジェクトに転送します。

トラップできるもの

  • プロパティ(存在しないものも含む)の読み取り (get)、書き込み (set)、削除 (deleteProperty)。
  • 関数の呼び出し (apply トラップ)。
  • new 演算子 (construct トラップ)。
  • その他多くの操作(完全なリストは、記事の冒頭と ドキュメントにあります)。

これにより、「仮想」プロパティとメソッドを作成したり、デフォルト値、監視可能なオブジェクト、関数デコレーターなどを実装したりできます。

また、さまざまな機能の側面でデコレートするために、オブジェクトを異なるプロキシで複数回ラップすることもできます。

Reflect APIは、Proxy を補完するように設計されています。任意の Proxy トラップに対して、同じ引数を持つ Reflect の呼び出しがあります。ターゲットオブジェクトへの呼び出しを転送するために、それらを使用する必要があります。

プロキシにはいくつかの制限があります。

  • 組み込みオブジェクトには「内部スロット」があり、それらへのアクセスをプロキシ化することはできません。上記の回避策を参照してください。
  • プライベートクラスフィールドについても同様です。これらは内部的にスロットを使用して実装されるためです。したがって、プロキシ化されたメソッド呼び出しは、それらにアクセスするために this としてターゲットオブジェクトを持っている必要があります。
  • オブジェクトの等価性テスト === をインターセプトすることはできません。
  • パフォーマンス:ベンチマークはエンジンによって異なりますが、一般的に、最も単純なプロキシを使用してプロパティにアクセスすると、数倍長くなります。実際には、一部の「ボトルネック」オブジェクトでのみ問題になります。

課題

通常、存在しないプロパティを読み取ろうとすると undefined が返されます。

存在しないプロパティの読み取りを試みるとエラーをスローするプロキシを作成してください。

これは、プログラミングミスを早期に検出するのに役立ちます。

オブジェクト target を受け取り、この機能を追加するプロキシを返す関数 wrap(target) を記述してください。

それはこのように機能するはずです。

let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
      /* your code */
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"
let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
    get(target, prop, receiver) {
      if (prop in target) {
        return Reflect.get(target, prop, receiver);
      } else {
        throw new ReferenceError(`Property doesn't exist: "${prop}"`)
      }
    }
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"

一部のプログラミング言語では、末尾から数えた負のインデックスを使用して配列要素にアクセスできます。

こんな感じです。

let array = [1, 2, 3];

array[-1]; // 3, the last element
array[-2]; // 2, one step from the end
array[-3]; // 1, two steps from the end

言い換えれば、array[-N]array[array.length - N] と同じです。

その動作を実装するためのプロキシを作成してください。

それはこのように機能するはずです。

let array = [1, 2, 3];

array = new Proxy(array, {
  /* your code */
});

alert( array[-1] ); // 3
alert( array[-2] ); // 2

// Other array functionality should be kept "as is"
let array = [1, 2, 3];

array = new Proxy(array, {
  get(target, prop, receiver) {
    if (prop < 0) {
      // even if we access it like arr[1]
      // prop is a string, so need to convert it to number
      prop = +prop + target.length;
    }
    return Reflect.get(target, prop, receiver);
  }
});


alert(array[-1]); // 3
alert(array[-2]); // 2

プロキシを返すことによって「オブジェクトを監視可能にする」関数 makeObservable(target) を作成します。

それはこのように機能するはずです。

function makeObservable(target) {
  /* your code */
}

let user = {};
user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John"; // alerts: SET name=John

言い換えれば、makeObservable によって返されるオブジェクトは元のオブジェクトとまったく同じですが、プロパティの変更時に呼び出される handler 関数を設定するメソッド observe(handler) も備えています。

プロパティが変更されるたびに、handler(key, value) がプロパティの名前と値とともに呼び出されます。

追伸:このタスクでは、プロパティへの書き込みのみを考慮してください。他の操作は同様の方法で実装できます。

解決策は2つの部分で構成されています。

  1. .observe(handler) が呼び出されるたびに、後で呼び出すことができるように、ハンドラーをどこかに記憶する必要があります。プロパティキーとしてシンボルを使用して、ハンドラーをオブジェクトに直接保存できます。
  2. 変更があった場合にハンドラーを呼び出すための set トラップを持つプロキシが必要です。
let handlers = Symbol('handlers');

function makeObservable(target) {
  // 1. Initialize handlers store
  target[handlers] = [];

  // Store the handler function in array for future calls
  target.observe = function(handler) {
    this[handlers].push(handler);
  };

  // 2. Create a proxy to handle changes
  return new Proxy(target, {
    set(target, property, value, receiver) {
      let success = Reflect.set(...arguments); // forward the operation to object
      if (success) { // if there were no error while setting the property
        // call all handlers
        target[handlers].forEach(handler => handler(property, value));
      }
      return success;
    }
  });
}

let user = {};

user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John";
チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善するための提案がある場合は、コメントする代わりに GitHub issue を送信するか、プルリクエストを送信してください。
  • 記事の何かを理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには、<code> タグを使用し、数行の場合は <pre> タグで囲み、10行を超える場合はサンドボックス (plnkr, jsbin, codepen…) を使用してください。