ククログ

株式会社クリアコード > ククログ > 変更禁止のオブジェクトの一部のメソッドだけをES6 Proxyでオーバーライドする方法

変更禁止のオブジェクトの一部のメソッドだけをES6 Proxyでオーバーライドする方法

以前にMozillaのJavaScript実行エンジンの独自拡張機能である__noSuchMethod__をES6 Proxyで代替する方法をご紹介しましたが、その応用となる話です。

Proxyの制限事項

Proxyは任意のオブジェクトに対するプロパティアクセスやメソッド呼び出しなどの操作にフックを仕掛けたり動作をオーバーライドしたりするための汎用的な仕組みです。

しかし、何でも出来るわけではなく、仕様上で定義されている制限事項もあります。 具体的には、変更が禁止されたオブジェクトのプロパティアクセスに対しては、元と異なる値を返す事ができません。

var myObject = {
  hello: function() { return 'Hello!'; },
  bye: function() { return 'Bye!'; }
};
alert(myObject.hello()); // "Hello!"
alert(myObject.bye()); // "Bye!"

Object.freeze(myObject);

var proxied = new Proxy(myObject, {
  get: function(target, name, receiver) {
     if (name == 'hello')
       return function() { return 'Hi!'; };
     return target[name];
  }
});

alert(proxied.hello()); // TypeError: proxy must report the same value for a non-writable, non-configurable property

これは、"Hello!"という文字列を返すhello()メソッドをオーバーライドして、代わりに"Hi!"という文字列を返すようにしようとして失敗した例です。 メッセージを見ると分かりますが、getトラップでは、書き換え不可能・変更不可能なプロパティに対するアクセスには元と同じ値を返さなくてはならず、違う値を返すとその時点で例外が発生するという仕様になっています。 (詳しくはMDNの解説の「不変条件」の見出しを参照して下さい。)

実際に、Firefox/Thunderbird用のアドオン向けテスティングフレームワークのUxUではこの制限事項の影響がありました。 このアドオンはThunderbirdの動作に関わるテストを容易にするために、「メールを送信しようとした時に、実際にはメールを送らないで、送られるはずだったメールの内容を溜め込んでおいて後で比較できるようにする」という機能を含んでいるのですが、これはメール作成ウィンドウのgMsgComposeというオブジェクトのSendMsg()メソッドをオーバーライドする事で実現していました。 しかし、gMsgComposeが変更不可能になっているため、SendMsgプロパティの呼び出し時に違う関数を返す事で動作をオーバーライドするというようなことはProxyベースではできなくなってしまっていました。

手軽な解決策

この問題に対する最も簡単な回避策は、ダミーの変更可能なオブジェクトに対するProxyとして作成しておいて、実際には変更不可能なオブジェクトに対するProxyとして動作させるようにするというものです。 上記の例であれば、このようになります。

...
Object.freeze(myObject);

var proxied = new Proxy({}, { // <= Proxy対象のオブジェクトを、ダミーの変更可能なオブジェクトにしておく。
  get: function(target, name, receiver) {
     if (name == 'hello')
       return function() { return 'Hi!'; };
     return myObject[name].bind(myObject); // <= 実際のProxy先は本来のオブジェクトにしておく。bindによるthisの束縛も忘れないように!
  }
});

alert(proxied.hello()); // "Hi!"
alert(proxied.bye()); // "Bye!" <= 本来のオブジェクトに処理が委譲されている。

用途が限定されている場面では、これで十分実用になります。

ただ、こうして作成されたProxyはあくまで元のオブジェクトではなくダミーのオブジェクトに対するProxyなので、いくつかの場面では振る舞いが元のオブジェクトは異なってきます。 例えば、Proxyに対してinを使ってプロパティの存在を確認をしてもfalseが返るのに、実際にアクセスするとプロパティの値が返ってくる、という不思議な事が起こります。

alert('hello' in myObject); // true

alert('hello' in proxied); // false <= メソッドが無いと判定される。
alert(typeof proxied.hello); // "function" <= 実際にアクセスするとメソッドがある。
alert(proxied.hello()); // "Hi!" <= 実行もできる。

FirefoxやThunderbirdのアドオンでは、複数のバージョンのFirefoxやThunderbirdに対応するために、Firefox/Thunderbird自身が提供している機能の存在の有無を確認して、それを以てバージョン判別の代わりにするということが多いです。 しかし、上記のようにProxyでラップされたオブジェクトに対してはプロパティの存在を問い合わせても「無し」という答えが返ってきてしまうため、併用している他のアドオンが動作しなくなったり、Firefox/Thunderbird自身の機能が動作しなくなったりという事が起こり得ます。

より自然に振る舞うProxyを定義する

このような問題を避けるためには、「Proxyに何か操作が行われたら、ダミーのオブジェクトに対してではなく本来の変更不能なオブジェクトに対する操作として処理を委譲する」というトラップの指定をより広範囲に渡って行う必要があります。 以下は、仕様で定義されているすべてのトラップを定義した例です。

var proxied = new Proxy({}, {
  get: function(aTarget, aName, aReceiver) {
    if (name == 'hello')
      return function() { return 'Hi!'; };
    return myObject[aName].bind(myObject);
  },
  // ここから追加分
  getPrototypeOf: function(aTarget) {
    return Object.getPrototypeOf(myObject);
  },
  setPrototypeOf: function(aTarget, aPrototype) {
    return Object.setPrototypeOf(myObject, aPrototype);
  },
  isExtensible: function(aTarget) {
    return Object.isExtensible(myObject);
  },
  preventExtensions: function(aTarget) {
    return Object.preventExtensions(myObject);
  },
  getOwnPropertyDescriptor: function(aTarget, aProperty) {
    return Object.getOwnPropertyDescriptor(myObject, aProperty);
  },
  defineProperty: function(aTarget, aProperty, aDescriptor) {
    return Object.defineProperty(myObject, aProperty, aDescriptor);
  },
  has: function(aTarget, aProperty) {
    return aProperty in myObject;
  },
  set: function(aTarget, aName, aValue, aReceiver) {
    return myObject[aName] = aValue;
  },
  deleteProperty: function(aTarget, aProperty) {
    delete myObject[aProperty];
  },
  enumerate: function(aTarget) {
    return Reflect.enumerate(myObject);
  },
  ownKeys: function(aTarget) {
    return Object.getOwnPropertyNames(myObject);
  },
  apply: function(aTarget, aThis, aArgs) {
    return myObject.apply(aThis, aArgs);
  },
  construct: function(aTarget, aArgs) {
    return new myObject(...aArgs);
  }
  // ここまで追加分
});

こうしておけば、メソッド呼び出し以外の場面でも元のオブジェクトと同じ振る舞いをしてくれます。

alert('hello' in myObject); // true
alert('hello' in proxied); // true <= 元の変更不可能なオブジェクトと同じ結果になっている。

一般的な開発ではここまでの事をする必要はないと考えられますが、フレームワーク的な物を作る場合や、既存の仕組みの基盤にあたる部分をハックする場合には、こういった工夫が役に立つ場合があります。

Firefox/Thunderbirdのアドオン開発も、そのような特殊事例の1つと言えます。 これらのアドオンは、どのような組み合わせで使われるかが事前に予想できないため、既存の物の振る舞いを不用意に変えると、思わぬ所で互換性の問題が発生してしまいます。 「このアドオンは便利なのだが、入れたら他のアドオンが全く動かなくなってしまった。実用には使えないので、使用を諦めるしかない。」というような事態に陥りにくい、他のアドオンとの相互互換性が高いアドオンにするためには、このようにして可能な限り元の動作との互換性を確保しておく必要があるわけです。

まとめ

ES6 Proxyを使って、本来であれば変更ができないはずのオブジェクトの振る舞いを部分的に変える方法をご紹介しました。