🗳️

JavaScriptのProxyを使うときに気をつけること

2024/10/14に公開

本稿では、Proxyを使う際はオブジェクトの原理をよく理解した上で、PreventExtensions時の挙動とPrivate Identifierに気をつけましょうという話をします。

そもそもProxyは何なのか

ProxyReflect と対をなすプリミティブAPIで、オブジェクトの低レベルプロトコルの操作を提供します。

  • Proxyは、オブジェクトの低レベルプロトコルをユーザーが実装できるようにします。
  • Reflectは、オブジェクトの低レベルプロトコルをユーザーが利用できるようにします。

そのため、Proxyを理解するにはまずオブジェクトの低レベルプロトコルを理解する必要があります。

オブジェクトとは何か

{ foo: "bar" }[1, 2, 3], () => 42 がオブジェクトであることはすでに知っていると思いますが、ここでは定義に戻って確認をします。

オブジェクトとは、JavaScriptの言語レベルの値のうち、プリミティブ以外の全てです。オブジェクトは以下の特徴を持ちます。

  • 参照の同一性によって区別される[1]
  • 内部に可変な状態を持つことができる。

特に重要なのは、オブジェクトは Object のインスタンスであるとは限らないという点です。代表的な反例として以下があります。

  • Object.create(null) によって作られたオブジェクトは Object のインスタンスではありません。
  • import * as ...await import(...) によって得られる名前空間オブジェクトは Object のインスタンスではありません。
  • 異なるRealmの Object のインスタンスとして作られたオブジェクトは、現在のRealmの Object のインスタンスではありません。これはたとえば、 window.open() で開いた先のページで生成されたオブジェクトを何らかの形で受け取ったときに発生します。

これは、全てのオブジェクトがjava.lang.ObjectのインスタンスであるJavaや、全てのオブジェクトがBasicObjectのインスタンスであるRubyとは異なる特徴です。

java.lang.ObjectやBasicObjectと異なり、JavaScriptの Object クラス[2]はオブジェクトの共通インターフェースを定義したものではないということになります。ではオブジェクトの共通インターフェースはどこに定義されているのかというと、それがオブジェクトの低レベルプロトコルであり、§6.1.7.2§6.1.7.3に定義されています。

普通のオブジェクトのプロトコル実装

プロトコルの一般的な規約を見る前に、馴染みの深い普通のオブジェクトを例にしながらプロトコルを見ていきましょう。

「普通のオブジェクト」は、本節で説明するような実装を持つオブジェクトのことですが、具体的には以下のようなものを指します。

  • { foo: "bar" } で作られるオブジェクト
  • Object.create(null) で作られるオブジェクト
  • Math オブジェクト

逆に以下のようなオブジェクトは普通のオブジェクトではなく、風変わりなオブジェクトと呼ばれます。

  • function f() {} で作られるオブジェクト
  • [1, 2, 3] で作られるオブジェクト
  • new Proxy(...) で作られるオブジェクト

普通のオブジェクトは、最低限、以下の3つのデータを持ちます。

  • このオブジェクトのプロトタイプへの参照 (O.[[Prototype]] と表記する)。別のオブジェクトまたはnull。
  • このオブジェクトが所有するプロパティの集まり。各プロパティは文字列またはシンボルのキー[3]で関連づけて管理されている。
  • このオブジェクトが拡張可能かどうかのフラグ (O.[[Extensible]] と表記する)。

たとえば、 { foo: "bar" } という式で作られるオブジェクトは以下のようなデータを持っています。

  • このオブジェクトのプロトタイプは Object.prototype
  • このオブジェクトは1つのプロパティを所有している。
    • キー "foo" に対応するプロパティは以下の属性を持つ。
      • [[Value]] = "bar"
      • [[Writable]] = true
      • [[Configurable]] = true
      • [[Enumerable]] = true
  • このオブジェクトは拡張可能。

さて、オブジェクトがもつ内部メソッドは全部で13個あります。

  • [[Get]], [[Set]], [[HasProperty]]
  • [[GetOwnProperty]], [[DefineOwnProperty]], [[Delete]], [[OwnPropertyKeys]]
  • [[GetPrototypeOf]], [[SetPrototypeOf]]
  • [[IsExtensible]], [[PreventExtensions]]
  • [[Call]], [[Construct]] (オプショナル)

そこで、これらを紹介しながら、オブジェクトの振る舞いについて説明していきます。

[[Get]]

オブジェクトが実装しなければならない振る舞いのひとつは、プロパティの読み取りです。これは [[Get]] 内部メソッドと呼ばれています。内部メソッドはJavaScriptのメソッドと同じような概念ですが、1つ下のレイヤで実装されているものです。

[[Get]] はJavaScriptの構文で obj[key]obj.foo といった構文に対応しています。なお、これらは同じもので、 obj.fooobj["foo"] と書き換えることができます。また、 arr[42] のように配列のインデックスを参照する構文も同じもので、インデックスを文字列化して arr["42"] のようにアクセスしたのと同等のものとして解釈されます。

普通のオブジェクトの [[Get]] は、以下のように実装されています。

  1. そのキーのプロパティを所有しているか調べる。所有していれば、その値を読み取って返す。
  2. なければ、プロトタイプチェーンの次のオブジェクトに移譲する。つまり、
    • プロトタイプがオブジェクトであれば、そのオブジェクトの [[Get]] を呼ぶ。
    • プロトタイプがnullであれば、 undefined を返す。

これにより、全てのオブジェクトは、プロトタイプからプロパティを継承しているかのように見えることになります。

たとえば、 {} という式によって生成されるオブジェクトは1つもプロパティを所有していませんが、 {}.toString は関数を返します。これは、プロパティを所有していない場合に、プロトタイプからの読み取りにフォールバックするからです。

[[Set]]

[[Get]]があるなら[[Set]]もあります。これはプロパティへの書き込みを処理します。

[[Get]] との大きな違いは、書き込みは必ずオリジナルのオブジェクトに対して行われ、プロトタイプチェーン上のオブジェクトは書き込み対象にはならないという点です。

たとえば以下のコードでは、 Object.prototype がうっかり書き換えられてしまうことはありません。

const obj = {};
obj.toString = function() {
  return "Hello!";
};

このとき、元々の obj.toStringObject.prototype の所有するプロパティを指していますが、 obj.toString = の書き込みは obj 自身の所有するプロパティを新規作成する形で行われます。

データプロパティとアクセサプロパティ

ここまではデータプロパティと呼ばれるプロパティを想定して説明してきましたが、この他にアクセサプロパティと呼ばれるプロパティもあります。アクセサプロパティは、[[Get]]や[[Set]]時の処理をJavaScriptの関数として書けるようにしたものです。クラス定義内で get foo() {}set foo() {} という形で目にすることが多いでしょう。

データプロパティは [[Value]], [[Writable]], [[Configurable]], [[Enumerable]] の4つの属性を持ちます。一方、アクセサプロパティは [[Get]], [[Set]], [[Configurable]], [[Enumerable]] の4つの属性を持ちます。 [[Get]]/[[Set]] はオブジェクトの内部メソッド名と同じ名前で呼ばれていますが、間違えないようにしましょう。

属性 データ アクセサ 効果
[[Value]] 現在の値
[[Writable]] [[Value]] を変更可能かどうか
[[Get]] 読み取り時に呼ばれる処理
[[Set]] 書き込み時に呼ばれる処理
[[Configurable]] [[Value]] 以外 を変更可能かどうか
[[Enumerable]] for-inや Object.entries 等の挙動を制御

アクセサプロパティとプロトタイプチェーン

アクセサプロパティを考慮した [[Get]] / [[Set]] 内部メソッドの処理はやや複雑です。というのも、アクセサプロパティに設定された関数はオリジナルのオブジェクトをthisとして呼び出されるからです。

たとえば以下のようなコードを考えます。

class C {
  get countText() {
    return `${this.count}`;
  }
  // その他の定義は省略
}

この場合、 "countText" という名前のアクセサプロパティは C.prototype の所有するプロパティとして定義されています。しかし、このプロパティの読み出し処理を実行するには、元となった C のインスタンスが必要です。そうでなければ this.count は undefined になってしまいます。

この問題は、[[Get]]Receiver という追加の引数を取ることで対応されています。

また、 [[Set]] についても同様の問題が発生します。アクセサプロパティを発見するためにはプロトタイプチェーンを辿る必要がありますが、見つけたプロパティのset処理を呼び出すにはオリジナルのオブジェクトが必要なので、こちらも Receiver という追加の引数を取ることで対応しています。

[[Writable]] とプロトタイプチェーン

同様に、データプロパティの [[Writable]] もプロトタイプチェーンに対して効果を発揮します。

[[Set]] 内部メソッドが書き込み前にアクセサプロパティを探索することは前節で説明しましたが、このとき [[Writable]] = false なデータプロパティが見つかった場合は書き込みは失敗扱いになります。

[[HasProperty]]

[[HasProperty]]は[[Get]]と似ていますが、値を読みとるのではなくプロパティが見つかったかどうかを報告します。 [[HasProperty]] が [[Get]] とは別にあることで、 undefined の値を持つプロパティと、プロパティがない状態を区別できます。

"foo" in obj 構文はこの内部メソッドに対応しています。

[[GetOwnProperty]], [[DefineOwnProperty]], [[Delete]], [[OwnPropertyKeys]]

[[Get]], [[Set]], [[HasProperty]] がプロトタイプチェーンを参照する処理だったのに対して、所有するプロパティのみに焦点を当てているのがこれらの4つの内部メソッド [[GetOwnProperty]], [[DefineOwnProperty]], [[Delete]], [[OwnPropertyKeys]] です。

これらはちょうどプロパティのCRUDに対応します。

  • [[GetOwnProperty]] は所有するプロパティの 読み取り を行います。
  • [[DefineOwnProperty]] は所有するプロパティの 作成 および 更新 を行います。
  • [[Delete]] は所有するプロパティの 削除 を行います。
  • [[OwnPropertyKeys]] は所有するプロパティの 列挙 を行い、読み取り処理を補助します。

このうち [[Delete]] は delete obj["foo"] 構文に対応します。それ以外の内部メソッドには対応する構文はないため、 Object または Reflect の対応する関数を使って呼び出します。

[[GetPrototypeOf]], [[SetPrototypeOf]]

[[GetPrototypeOf]], [[SetPrototypeOf]] は名前の通りプロトタイプの取得または変更を行います。

紛らわしい概念として以下があります。

  • foo.prototype で取得できるのは、そのコンストラクタのインスタンスのプロトタイプであり、それ自体のプロトタイプではありません。
  • obj.__proto__ でオブジェクトのプロトタイプを取得できる場合があります。これは一部の処理系で Object.prototype に定義されているアクセサプロパティであり、ECMAScriptでもレガシー機能として選択式の正式機能として容認されています。しかし、Denoなどこの機能を実装していない処理系もありますし、冒頭に述べたように全てのオブジェクトが Object のインスタンスというわけではないため、確実にプロトタイプを操作できる機能ではない点に注意が必要です。

確実にプロトタイプを取得または変更するには、 Object または Reflect の対応する関数を使います。

[[IsExtensible]], [[PreventExtensions]]

[[IsExtensible]][[PreventExtensions]] はオブジェクトの拡張可能性フラグを取得・変更します。

拡張可能フラグがオフの場合、このオブジェクトには以下の制限が生じます。

  • 所有するプロパティを新たに追加することができなくなります。
  • プロトタイプを変更することができなくなります。

実は本稿において最も重要な機能がこれです。これについては後述します。

風変わりなオブジェクト1: 関数オブジェクト

ここまでで紹介したのは、普通のオブジェクト (ordinary object) の挙動でした。普通のオブジェクトがあるということは、そうでないオブジェクトもあるということです。それらは風変わりなオブジェクト (exotic object) といいます。

風変わりなオブジェクトの代表例は関数オブジェクトです。これらは、普通のオブジェクトに加えて1個~2個の追加の内部メソッドを実装します。なお、クラスもこれに含まれます。

[[Call]]

[[Call]] は関数呼び出しです。たとえば f(a, b, c)f の指すオブジェクトの [[Call]] を呼び出します。また、 this が指定される各種呼び出しでも同様です。

[[Construct]]

[[Construct]] はnewおよびsuperの呼び出しです。これは [[Call]] とは独立した処理として扱われますが、歴史的には function 構文で両方同時に定義する方式が使われていたため、混同されやすいかもしれません。

[[Call]] と [[Construct]] の主な違いは以下の通りです。

  • this に使うための値は渡されない。必要な場合は呼び出された側で生成・取得する。
  • new.target に使うための値が渡される。
  • [[Construct]] を実装するが、 [[Call]] に対しては常にエラーを返すオブジェクトがある。class構文で作られた関数オブジェクトはこれに該当する。
  • [[Call]] を実装するが、 [[Construct]] を実装しないオブジェクトがある。アロー関数構文やメソッド構文で作られた関数オブジェクトはこれに該当する。

注意点として、「[[Call]]だけ実装する」ことは可能ですが、「[[Construct]]だけ実装する」ことは禁止されています。そのため、上で述べたように、 「[[Call]]も実装するが常にエラーを返す」というようなことが行われています。

風変わりなオブジェクト2: 配列オブジェクト

配列には特殊な挙動があります。たとえば以下の例を見てください。

const arr = [];
arr[3] = undefined;
console.log(arr.length); // => 4

2行目で "3" という名前のプロパティを書き換えていますが、その結果としてlengthが変化しています。つまり、プロパティの書き換えで副作用が生じています。これはアクセサプロパティであれば起きえますが、 "3" には元々プロパティはありません。普通のオブジェクトであればこういうことは起きません。

同じことが length への代入でも発生します。

const arr = [1, 2, 3];
arr.length = 0;
console.log(0 in arr); // => false

こちらも、 length がアクセサプロパティであればありえるのですが、実際にはオブジェクトが所有するデータプロパティです。

このような特殊な挙動を実現するために、配列オブジェクトは [[DefineOwnProperty]] 内部メソッドを独自の挙動に差し替えています。

その他の風変わりなオブジェクト

  • [[Call]] を実装するオブジェクト
    • ユーザー定義の関数オブジェクト (前述)。
    • 組み込みの関数オブジェクト。
    • Function.prototype.bind によって生成される関数オブジェクト。
  • インデックスを実装するオブジェクト
    • 配列オブジェクト (前述)。
    • 文字列ラッパーオブジェクト。
    • non-strict modeのArgumentsオブジェクト。
    • TypedArrayオブジェクト。
  • モジュール名前空間オブジェクト。
  • Object.prototype など、プロトタイプの上書きを禁止するオブジェクト。
  • Proxyオブジェクト

ProxyとReflect

オブジェクトの内部メソッドについて説明したことで、あらためてProxyとReflectを説明できる状態になりました。

ProxyReflect は、オブジェクトの内部メソッドを直接操作するためのAPIです。

  • Proxyは、オブジェクトの内部メソッドを自分で定義するために使います。
  • Reflectは、オブジェクトの内部メソッドを自分で呼び出すために使います。

ここまで紹介してきた内部メソッドを、Proxy/Reflectを使って呼び出すときには以下の名称を使います

規格上の名称 Proxy/Reflect
[[GetPrototypeOf]] getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf
[[IsExtensible]] isExtensible
[[PreventExtensions]] preventExtensions
[[GetOwnProperty]] getOwnPropertyDescriptor
[[DefineOwnProperty]] defineProperty
[[HasProperty]] has
[[Get]] get
[[Set]] set
[[Delete]] deleteProperty
[[OwnPropertyKeys]] ownKeys
[[Call]] apply
[[Construct]] construct

たとえば、極端な例として、あらゆるプロパティの値が42であるような怪しいオブジェクトを以下のように作ることができます。

const obj = new Proxy({}, {
  get(target, p) {
    return 42;
  },
});
console.log(obj.foo); // => 42
console.log(obj[Symbol()]); // => 42
console.log(obj.toString); // => 42

オブジェクトの不変条件

Proxyによって、オブジェクトの内部メソッドについてかなり自由度の高い実装を与えられるようになりました。

すると問題になるのは、こうしたオブジェクトを使う側の期待との整合性です。

通常、オブジェクトを利用する側は、提供されている内部メソッドの間 (たとえば [[Get]] と [[Set]] の間) にある種の整合性があることを期待します。しかし、Proxyのような特殊なオブジェクトが登場すると、こうした期待のかなりの部分が崩れてしまいます。

実際、Proxyを用いてかなり破天荒なオブジェクトを作ることができるのも事実ですが、一方で最低限の期待として満たさなければいけない条件も設定されました。それが §6.1.7.3 に規定されているオブジェクト内部メソッドの不変条件です。

これらの条件は以下のように大別できます。

  • 型に関する基本的な制約。
  • プロパティライフサイクルに関する制約。
  • [[OwnPropertyKeys]]の一意性制約。
  • [[Construct]]の存在と[[Call]]の存在の一貫性制約。

このうち、条件の大半を占めるのが「プロパティライフサイクルに関する制約」です。そこで、これをまず説明していきます。

プロパティライフサイクル

JavaScriptではふつう、オブジェクトが所有するプロパティを自由に追加・変更・削除できます。しかし、これをあえて禁止することもできるようになっています。これは以下の3種類に分類できます。

  • オブジェクトの拡張禁止。つまり、 [[Extensible]] = false にすること。
  • プロパティの封印 (seal)。つまり、 [[Configurable]] = false にすること。
  • プロパティの凍結 (freeze)。つまり、 [[Configurable]] = false かつ、データプロパティであれば [[Writable]] = false にすること。

これらの変更は不可逆です。つまり、一度変更を適用すると、もう元に戻すことはできません。元に戻したくなったら諦めてオブジェクトを作り直す必要があります。

なお、 [[Writable]] を単体で変更するだけでは効果は限定的です。というのも、 [[Configurable]] = true であればプロパティを削除して作り直したり、 [[Writable]] を元に戻すことが可能だからです。

また、拡張禁止は存在しないプロパティの状態に関する指定だととらえることもできます。そこでプロパティの状態遷移を以下のように描くことができます。

ポイントは、「拡張禁止」領域に入るとプロパティの非存在が確定し、「封印」領域に入るとプロパティの存在が確定する点です。

Proxyは多くの場面に副作用を介在させられるので、一見して一貫性のない結果であっても「アクセスの途中で副作用があって結果が変わった」として説明がついてしまうことがあります。たとえばownKeysの一覧にないプロパティをgetして結果が返ってきたとしても、その間にプロパティが追加されたものとして説明がつきます。しかし、拡張禁止や封印といった結果が観測されていればこの言い訳は通用しません。おそらくJSの処理系はこの拡張禁止や封印の観測事実を前提とした最適化を行っており、そのために規格でこういった仕様が残されたのだと思います。

target objectの存在意義

では、仕様で定められた不変条件をProxyはどう満たしているのか。Proxyはユーザーから与えられたハンドラの実行結果をチェックしているのですが、そのときに必要になるのが target object です。

target objectはProxyコンストラクタの第一引数に渡されるオブジェクトです。ハンドラを指定しなかったときのデフォルト処理はこのtarget objectに移譲されますが、それよりも重要なのが拡張禁止・封印・凍結時の整合性チェックです。

簡単に言うと、Proxyオブジェクトが拡張禁止されたりプロパティが封印・凍結されたりすると、その挙動(の一部)はtarget objectのものと一致することが要求されるという仕様になっています。

たとえば、先ほど挙げた「あらゆるプロパティの値が42であるような怪しいオブジェクト」は以下のように実装されていました。

const obj = new Proxy({}, {
  get(target, p) {
    return 42;
  },
});
console.log(obj.foo); // => 42
console.log(obj[Symbol()]); // => 42
console.log(obj.toString); // => 42

このオブジェクトに以下のようにプロパティを定義しても、指定通り42が返ってきます。 (この場合、実際にはtarget objectにプロパティが定義されています)

const obj = new Proxy({}, {
  get(target, p) {
    return 42;
  },
});
obj.foo = "bar";
console.log(obj.foo); // => 42

しかし、凍結されたプロパティを定義すると、get の結果がtarget objectのプロパティと一貫性がないとしてエラーになってしまいます。

const obj = new Proxy({}, {
  get(target, p) {
    return 42;
  },
});
Object.defineProperty(obj, "foo", {
  value: "bar",
  writable: false,
  configurable: false,
});
console.log(obj.foo); // => TypeError: 'get' on proxy: property 'foo' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected 'bar' but got '42')

また、Proxyでは拡張禁止されたオブジェクトからのプロパティの削除について本来のオブジェクトの規約よりも厳しいチェックが行われているようです。

Proxyを正しく使うにはどうしたらいいか

このように使い方によってはエラーになってしまうProxyですが、プロパティライフサイクルの対策は大きく2つに分けられます。

  • ひとつは、拡張禁止・封印・凍結自体を拒否するという方法です。
  • もうひとつは、拡張禁止・封印・凍結時にtarget objectを更新することで一貫性を担保するという方法です。

具体的には以下のように実現できます。

  • 拡張禁止の拒否: preventExtensionsをフックし、何もせずにfalseを返します。
  • 拡張禁止時の一貫性確保: preventExtensionsをフックし、以下を行います。
    • getPrototypeOfをフックしている場合は、必要に応じてtarget objectのプロトタイプを更新します。以降は一貫した値を返すようにします。
    • getOwnPropertyDescriptorをフックしている場合は、必要に応じてtarget objectにプロパティを定義します。
    • has, ownKeysをフックしている場合は、必要に応じてtarget objectのプロパティを削除してきます。
  • 封印の拒否: definePropertyをフックし、configurableがfalseになるような変更であれば何もせずfalseを返します。
  • 封印時の一貫性確保: definePropertyのフックはconfigurableがfalseになるような変更時にtarget objectにもプロパティを定義するようにします。また、getOwnPropertyDescriptor, has, get, set, ownKeysなどはそれと一貫した値を返すようにします。
  • 凍結についても同様です。

Proxyと内部スロット

もう1つ注意しなければいけないのは、特別な内部スロットを持つオブジェクトの場合です。

なお、特別な内部スロットを持つからといって、風変わりなオブジェクトであるとは限りません。オブジェクトの一般的な規約である内部メソッドが他と同じように実装されていれば、それは「内部スロットを持つ普通のオブジェクト」として扱われます。

特別な内部スロットを持つオブジェクトはたくさんありますが、たとえば以下のようなものがあります。

  • String, Number, BigInt, Boolean, Symbol のラッパーオブジェクト
  • Date (のインスタンス, 以下同様)
  • Error
  • Promise
  • RegExp
  • Map, Set, WeakMap, WeakSet
  • WeakRef, FinalizationRegistry
  • ArrayBuffer, SharedArrayBuffer, DataView
  • Private Identifier を持つクラス

これらのオブジェクトが提供するメソッドは内部スロットを直接参照しているため、Proxyでラップすると動作しなくなってしまうことがほとんどです。

内部スロットさえなければ new Proxy(obj, {})obj とほぼ同じように振る舞うはずですが、たとえば new Proxy(/a/, {}).test("a") は期待通りに動作しません。

特に注意が必要なのがPrivate Identifierです。Private Identifierは見た目はプロパティアクセスにそっくりですが、仕組みは全然違うので、プロトタイプやProxyとの相性がとても悪いです。

Proxyを作っても、target objectとproxy objectの間でPrivate Identifierの内容は共有されません。そのため、ProxyとPrivate Identifierを組み合わせるには以下のどちらかを選ぶことになります。

  • target objectにPrivate Identifierを設定する。
  • proxy objectにPrivate Identifierを設定する。

まず、「target objectにPrivate Identifierを設定する」の場合、newあるいはsuperして得られたインスタンスをnew Proxyでラップするだけなのでセットアップは簡単です。しかし、肝心のPrivate Identifierにアクセスできないトラブルが発生することでしょう。それはthisの値がproxy objectになることが理由です。getter/setterやメソッドに渡されるthisの値は、呼び出し元が持っているレシーバオブジェクトそのものです。本来は class C のメソッドには class C のインスタンスが渡ってくるはずですが、それをラップしたProxy objectが渡ってきてしまうため、中でPrivate Identifierにアクセスしようとした時点でエラーになってしまいます[4]

これに対処するには、proxy objectからtarget objectを取得する手段を何とかして確保しておくしかありません。指定したSymbolに対する[[Get]]をフックして返すなり、WeakMapに入れて返すなりする必要があります。

次に、「proxy objectにPrivate Identifierを設定する」の場合です。この場合proxy objectを何とかして当該クラスのコンストラクタに通す必要がありますが、これは可能です。コンストラクタ内でreturnするとそのオブジェクトが [[Construct]] の結果になるという仕様があるので、proxy objectを返すベースクラスを作ることができます。これを継承してサブクラス側でPrivate Identifierを利用すれば、proxy objectにPrivate Identifierを設定することができます。ただし、この場合でも使い方次第では問題が発生します。というのも、ハンドラ内からproxy objectにアクセスする便利な方法がないのです。かといってtarget objectを使って処理しようとしても、今度はtarget object側にPrivate Identifierがないので必要な処理ができずエラーになってしまいます。もし、ハンドラ内でproxy objectへのアクセスが必要なら、ハンドラ生成時の環境をキャプチャするかハンドラオブジェクト (thisとして渡ってくる) にデータを入れましょう。

まとめ

  • JavaScriptのオブジェクトは必ずしもObjectのインスタンスではない
  • しかし低レベルでは決まったインターフェースを実装した物体である
  • このレイヤでの操作の自由度を高めてくれるのがProxy/Reflect
  • Proxyを使うときは、PreventExtensionsとPrivate Identifierに注意
脚注
  1. 見方によってはSymbolも「参照の同一性によって区別される」という性質を満たしますが、「内部に可変な状態を持つことができる」という性質は持ちません。また、Symbolはオブジェクトの性質を規定するために必要な存在であるため、Symbolをオブジェクトとして扱うわけにはいかないという事情もあると考えられます。 ↩︎

  2. Object はclass宣言によって作られる関数オブジェクトではありませんが、コンストラクタとしての性質を充足しているためここでは便宜上クラスと呼称しています。 ↩︎

  3. 数値はそのままプロパティキーとして使われるわけではなく、文字列に変換して使われるという建て付けになっている。ただし、実際の処理系では最適化されている可能性がある。 ↩︎

  4. Private Identifierでは、当該クラスのコンストラクタを経由して初期化したオブジェクト以外ではその識別子を使ったアクセスができない ↩︎

Discussion