😺

【JavaScript】Proxyについて学ぶ

2022/09/15に公開約24,800字

なんとなく聞いたことはあるけど、あまりよくわからないJavaScriptの技術の1つとして、Proxyがあると思います。(もしかしたら私だけ)
そんなProxyについて改めて学び直したので、本記事はそのまとめです。

基本的な使い方

Proxyを使用するとオブジェクトの基本的な操作を拡張したオブジェクトを生成することが可能です。

まずは簡単に使い方をみてみましょう。
Proxyは第1引数にtarget、第2引数にhandlerの2つの引数を取ります。

  • target: もととなるオブジェクト
  • handler: 特定の操作をインターセプトする、またインターセプトした操作の処理を定義するオブジェクト
// もととなるオブジェクト
const target = {
  hoge: "fuga",
};
// 動作を定義するhandlerオブジェクト
const handler = {};

// Proxyオブジェクトを生成
const proxy = new Proxy(target, handler);

// handlerが空なので、proxyはもとのtargetと同様の動作をする
console.log(proxy.hoge); // "fuga"
console.log(proxy.piyo); // undefined
proxy.hoge = "piyo";
console.log(proxy.hoge); // "piyo"
console.log(target); // { hoge: 'piyo' }

上記ではhandlerが空のため、proxyがもとのオブジェクト(target)と同様の動作をします。

このままではあまり意味がないので、handlerに適切なメソッドを定義してその挙動を確認してみましょう。またhandlerで定義するメソッドのことを、Proxyではしばしばトラップと呼びます。

const target = {
  hoge: "fuga",
};
// handlerにgetトラップを定義
const handler = {
  get: (target, prop, receiver) => {
    return "hello";
  },
};

const proxy = new Proxy(target, handler);

proxy.hoge = "piyo";
console.log(proxy.hoge); // "hello"
console.log(target.hoge); // "piyo"

今回はhandlergetトラップを定義しました。
注目してほしいのはconsole.log(proxy.hoge)の部分です。
驚くべきことに「hello」が結果として表示されていますね。

想像がついている方も多いと思いますが、getトラップはプロパティの取得をインターセプトします。すなわち上記の実装では、実際のプロパティの値が何であろうと常に「hello」が返ってくるのです。

ここで気をつけてほしいのは、この例ではあくまでProxyオブジェクトのプロパティの取得をインターセプトしただけであって、target.hogeの値が「hello」になったわけではありません。その証拠にtarget.hogeの結果にはpiyoが表示されます。

proxyという英単語には「代理」という意味があります。ここまでの挙動を省みるとProxyオブジェクトという名前の意味が、なんとなくわかるのではないでしょうか。

ハンドラー関数

当然ながらgetトラップ以外にも、ハンドラー関数(トラップ)は存在します。以下にハンドラー関数を列挙します。

ハンドラー関数(トラップ) 呼び出しのタイミング
apply 関数呼び出し
construct new 演算子
defineProperty Object.defineProperty
deleteProperty delete 演算子
get プロパティの取得
getOwnPropertyDescriptor Object.getOwnPropertyDescriptor
getPrototypeOf Object.getPrototypeOf
has in 演算子
isExtensible Object.isExtensible
ownKeys Object.getOwnPropertyNamesObject.getOwnPropertySymbols
preventExtensions Object.preventExtensions
set プロパティの設定
setPrototypeOf Object.setPrototypeOf

おそらくこの中でもっとも使用頻度が高く、馴染み深いのはプロパティの取得と設定でしょう。
まずは理解しやすいgetトラップから考えていきます。

getトラップ

冒頭でも出てきましたが、getトラップはプロパティが取得された時に呼び出されるトラップです。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 取得するプロパティ名
  • receiver: proxyオブジェクト(またはproxyから継承している場合は継承したオブジェクト)

以下は存在しないプロパティを取得しようとした時にエラーとなる実装です。

JavasScript
const target = {
  hoge: "fuga",
};
const handler = {
  get: (target, property, receiver) => {
    if (property in target) {
      return target[property];
    }
    throw new Error("プロパティが存在しません");
  },
};

const proxy = new Proxy(target, handler);
console.log(proxy.hoge); // fuga
console.log(proxy.piyo); // Uncaught Error: プロパティが存在しません

setトラップ

取得があれば、当然設定もあります。
setトラップはプロパティを設定する時に呼び出されるトラップです。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 値を設定するプロパティ名(もしくはSymbol)
  • value: 設定するプロパティの新しい値
  • receiver: proxyオブジェクト

戻り値

setトラップ真偽値を返す必要があります。

以下はプロパティ設定時のバリデーションを実装したコードです。

const target = {
  hoge: "fuga",
};
const handler = {
  set: (target, property, value, receiver) => {
    if (!(property in target)) {
      throw new Error("存在しないプロパティです");
      return false
    }
    if (typeof value !== "string") {
      throw new Error("文字列を入力してください");
      return false
    }
    target[property] = value;
    return true
  },
};

const proxy = new Proxy(target, handler);
proxy.hoge = "piyo";
proxy.hoge = 1; // Uncaught Error: 文字列を入力してください

deletePropertyトラップ

プロパティの取得・設定ときたら、次は削除についてみてみます。
deletePropertyトラップdelete 演算子に対するトラップです。
delete 演算子を使うと、オブジェクトからプロパティを削除することが可能です。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: オブジェクトから削除するプロパティ名

戻り値

deletePropertyトラップ真偽値を返す必要があります。

以下はアンダーバーで始まるプロパティをdelete 演算子で削除できないようにする実装です。

const target = {
  hoge: "fuga",
  _hoge: "_fuga",
};

const handler = {
  deleteProperty(target, property) {
    if (property in target) {
      if (property.startsWith("_")) {
        console.log(`${property}プロパティは削除できません`);
        return false;
      }
      console.log(`${property}プロパティを削除しました`);
      delete target[property];
      return true;
    }
    consoel.log(`${property}プロパティは存在しません`);
    return false;
  },
};

const proxy = new Proxy(target, handler);
delete proxy.hoge; // hogeプロパティを削除しました
delete proxy._hoge; // _hogeプロパティは削除できません
console.log(target); // {_hoge: '_fuga'}

hasトラップ

hasトラップin 演算子に対するトラップです。
これまでのコードにもしれっと登場していますが、in 演算子はあるオブジェクトに指定されたプロパティが存在するか否かを判断する演算子です。指定されたプロパティが存在するときはtrueを、しないときはfalseを返します。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 対象となるプロパティ名

戻り値

hasトラップ真偽値を返す必要があります。

以下の実装はオブジェクトの中にプロパティが存在するか否かにかかわらず、常にtrueを返します。

const target = {
  hoge: "fuga",
};

const handler = {
  has: (target, property) => {
    // プロパティが存在しているか否かにかかわらず、trueを返す
    return true;
  },
};

const proxy = new Proxy(target, handler);
console.log("piyo" in proxy); // true  => 実際にpiyoが存在しているか否かによらず、常に存在しているように振る舞う
console.log(proxy.piyo); // undefined

こちらもgetトラップの時と同様に、あくまでin 演算子の操作をインターセプトしているだけであり、proxy.piyoundefinedとなります。

ownKeysトラップ

ownKeysトラップObject.getOwnPropertyNamesObject.getOwnPropertySymbolsに対するトラップです。

Object.getOwnPropertyNamesは対象のオブジェクト上のシンボルを除くすべてのプロパティを、Object.getOwnPropertySymbolsは対象のオブジェクト上のすべてのシンボルプロパティを配列で返します。

引数

次の引数が渡ってきます。

  • object: 列挙されるオブジェクト

戻り値

ownKeysトラップは列挙可能オブジェクトを返す必要があります。

以下はアンダーバーで始まるプロパティを除いたプロパティ一覧を返す実装になります。

const target = {
  hoge: "fuga",
  _hoge: "_fuga",
  [Symbol("piyo")]: "piyo",
};
const handler = {
  ownKeys: (object) => {
    // アンダーバーで始まるプロパティを除く
    return [
      ...Object.getOwnPropertyNames(object).filter(
        key => !key.startsWith("_")
      ),
      ...Object.getOwnPropertySymbols(object),
    ];
  },
};

const proxy = new Proxy(target, handler);
console.log(Object.getOwnPropertyNames(proxy)); // ['hoge'] => _hogeがフィルターされている
console.log(Object.getOwnPropertySymbols(proxy)); // [Symbol(piyo)]

簡単にownKeysトラップの実装方法を確認したところで、次のコードをみてみましょう。
もとのオブジェクトによらず、常に["hoge", "fuga"]をリターンしています。

const handler = {
  ownKeys: object => {
    // 常に["hoge", "fuga"]を返す
    return ["hoge", "fuga"];
  },
};

const proxy = new Proxy({}, handler);
console.log(Object.getOwnPropertyNames(proxy)); // ['hoge', 'fuga']
console.log(Object.keys(proxy)); // [] => 空の配列

注目すべきなのはObject.keys(proxy)の部分ですね。結果には空の配列が表示されました。

これはなぜかというとObject.keysがあくまで列挙可能なプロパティのみを返すからです。列挙可能なプロパティとはすなわちenumerable属性trueのプロパティのことです。

enumerable属性については本記事では解説しませんが、参考として以下のリンクを貼っておきます。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty#description

では列挙可能でないプロパティを取得するにはどうするのかいうと、次で紹介するgetOwnPropertyDescriptorトラップを使用することで可能になります。

getOwnPropertyDescriptorトラップ

getOwnPropertyDescriptorトラップObject.getOwnPropertyDescriptorに対するトラップです。
Object.getOwnPropertyDescriptorを使うと、プロパティ記述子(ディスクリプタ)を取得することが可能となります。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 記述の対象となるプロパティ名、またはSymbol

戻り値

オブジェクト、またはundefinedを返す必要があります。

以下の実装は列挙可能か否かにかかわらず、プロパティを取得することが可能です。

const target = {};
const handler = {
  ownKeys(target) {
    return ["hoge", "fuga"];
  },
  getOwnPropertyDescriptor(target, property) {
    return {
      value: property,
      writable: true,
      enumerable: true,
      configurable: true,
    };
  },
};
const proxy = new Proxy(target, handler);

console.log(Object.getOwnPropertyDescriptor(proxy, "piyo")); // {value: 'piyo', writable: true, enumerable: true, configurable: true}
console.log(Object.keys(proxy)); // ['hoge', 'fuga'] => enumerableがtrueなので取得できる

definePropertyトラップ

definePropertyトラップObject.definePropertyに対するトラップです。

Object.definePropertyを使うと、プロパティ記述子(ディスクリプタ)を設定することが可能となります。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 説明を受け取るプロパティ名、またはSymbol
  • descriptor: 定義や変更されるプロパティに対するディスクリプタ

戻り値

プロパティが正しく定義されたか否かを表す真偽値を返す必要があります。

const target = { hoge: "fuga" };
const handler = {
  defineProperty: (target, key, descriptor) => {
    Object.defineProperty(target, key, descriptor);
    return true;
  },
};
const proxy = new Proxy(target, handler);

Object.defineProperty(proxy, "hoge", {
  value: "piyo",
  writable: true,
  enumerable: true,
  configurable: true,
});
console.log(Object.getOwnPropertyDescriptor(proxy, "hoge")); // {value: 'piyo', writable: true, enumerable: true, configurable: true}

getPrototypeOfトラップ

getPrototypeOfトラップObject.getPrototypeOfに対するトラップです。

Object.getPrototypeOfは指定されたオブジェクトのプロトタイプを取得できます。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • property: 説明を受け取るプロパティ名、またはSymbol
  • descriptor: 定義や変更されるプロパティに対するディスクリプター

以下はプロトタイプをNumber.prototypeに偽る実装です。

const target = { hoge: "fuga" };
const proxy = new Proxy(target, {
  getPrototypeOf: (target) => {
    // Numberのprototypeを返す
    return Number.prototype;
  },
});

console.log(Object.getPrototypeOf(proxy) === Number.prototype); // true
proxy.toFixed(0); // Uncaught TypeError: proxy.toFixed is not a function

結果をみてわかるように、これもgetトラップ同様、あくまでプロトタイプの取得をインターセプトしているだけであり、実際にproxyのプロトタイプが変更されているわけではありません。
その証拠にproxy.toFixed(0)はエラーとなります。

setPrototypeOfトラップ

setPrototypeOfトラップObject.setPrototypeOfに対するトラップです。

Object.setPrototypeOfは指定されたオブジェクトのプロトタイプを設定できます。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • prototype: 設定するオブジェクトのプロトタイプ、またはnull

戻り値

setPrototypeOfトラップ真偽値を返す必要があります。

以下は配列のプロトタイプをObject.prototypeに変更してます。
その証拠にproxy.pushがエラーとなります。

const target = [];
const proxy = new Proxy(target, {
  setPrototypeOf: (target, prototype) => {
    Object.setPrototypeOf(target, prototype);
    return true;
  },
});

Object.setPrototypeOf(proxy, Object.prototype);
proxy.push(1); // Uncaught TypeError: proxy.push is not a function

isExtensibleトラップ

isExtensibleObject.isExtensibleに対するトラップです。
Object.isExtensibleはオブジェクトが拡張可能か否かを真偽値で返します。
拡張可能とはオブジェクトに新しいプロパティ追加できる状態を指します。

引数

次の引数が渡ってきます。

  • target: もとのオブジェクト

戻り値

isExtensibleトラップ真偽値を返す必要があります。

今までのトラップでは操作をインターセプトすることにより、(実際に正しい挙動かどうかによらず)その挙動を偽ることができました。
一方でisExtensibleトラップは制限が厳しく、Object.isExtensibletrueを返すべきところでfalseを返したり、逆にfalseを返すべきところでtrueを返すことができません。
すなわち以下の実装は必ずエラーとなります。

const target = {};
const handler = {
  isExtensible: (target) => {
    // Object.isExtensibleとは逆の挙動
    // 必ずエラーとなる!!
    return !Object.isExtensible(target);
  },
};
const proxy = new Proxy(target, handler);
console.log(Object.isExtensible(proxy)); // Uncaught TypeError: 'isExtensible' on proxy: trap result does not reflect extensibility of proxy target (which is 'true')

preventExtensionsトラップ

preventExtensionsトラップはObject.preventExtensionsに対するトラップです。

Object.preventExtensionsはオブジェクトを拡張不可能とするメソッドです。
拡張不可能となったオブジェクトは、新しいプロパティを追加することができません(ただし既存のプロパティの変更と削除はできる)。

引数

次の引数が渡ってきます。

  • target: もとのオブジェクト

戻り値

preventExtensionsトラップ真偽値を返す必要があります。preventExtensionsの操作に成功した(オブジェクトが拡張不可能になった)ときはtrue、失敗したときはfalseを返す必要があります。

const target = {
  hoge: "hoge",
};
const handler = {
  preventExtensions: (target) => {
    Object.preventExtensions(target);
    return true;
  },
};

const proxy = new Proxy(target, handler);
proxy.fuga = "fuga";
// ここから拡張不可能
Object.preventExtensions(proxy);
proxy.piyo = "piyo";
console.log(target); // {hoge: 'hoge', fuga: 'fuga'} => piyoは追加されていない

applyトラップ

applyトラップは関数呼び出しに対応するトラップです。new Proxyの第1引数に渡すオブジェクトは関数である必要があります。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • thisArg: 関数呼び出し時のthis
  • argumentsList: 関数呼び出し時の引数の配列

以下は関数実行時に渡した引数をconsole.logで表示するコードです。

const target = (...args) => {
  console.log([...args]);
};
const handler = {
  apply: (target, thisValue, args) => {
    return target.apply(thisValue, args);
  },
};

const proxy = new Proxy(target, handler);
proxy("hoge", "fuga", "piyo"); //  ['hoge', 'fuga', 'piyo']

constructトラップ

constructトラップnew 演算子に対するトラップです。applyトラップの時と同様に、このトラップではnew Proxyの第1引数に渡すオブジェクトは、コンストラクタとして使用できる(newできる)必要があります。

引数

次の引数が順に渡ってきます。

  • target: もとのオブジェクト
  • argumentsList: コンストラクタに対する引数の配列
  • newTarget: Proxyオブジェクト自身

戻り値

constructトラップはオブジェクトを返す必要があります。

const target = class Target {
  constructor({ firstname, lastname, age }) {
    this.firstname = firstname;
    this.lastname = lastname;
    this.age = age;
  }
  echo() {
    console.log(
      `${this.lastname}${this.firstname}さんは${this.age}歳です`
    );
  }
};
const handler = {
  construct: (target, argumentsList, newTarget) => {
    return new target(...argumentsList);
  },
};
const proxy = new Proxy(target, handler);
const person = new proxy({
  firstname: "太郎",
  lastname: "佐藤",
  age: 20,
});
person.echo(); // 佐藤太郎さんは20歳です

取り消し可能なProxyオブジェクト(Proxy.revocable)

Proxy.revocableメソッドを使うことで取り消し可能なproxyオブジェクトを生成できます。
以下のように使います。

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

基本的には通常のProxyオブジェクトの生成と同じ(new Proxyと同じ)ですが、生成された取り消し可能なProxyオブジェクトはproxyrevokeの2つのプロパティを持っています。

proxynew Proxy(target, handler)で生成できるProxyオブジェクトと同様なものです。
revokeproxy無効にする(取り消す) メソッドです。

revoke関数が実行されると、ハンドラー関数のトラップに関わる操作がすべてエラーとなります。

const target = { hoge: "fuga" };
const handler = {
  get: (target, property, receiver) => {
    return Reflect.get(target, property, receiver);
  },
};
const { proxy, revoke } = Proxy.revocable(target, handler);
console.log(proxy.hoge); // "fuga"
revoke();
console.log(proxy.hoge); // Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked

proxyが無効化されると、もととなるターゲットオブジェクトへの内部参照がなくなるので、メモリが節約できるという点でメリットがあります。

Reflect

ここでProxyと関わりのあるReflectについても軽くみておきましょう。

Reflectはオブジェクト操作を行うためのメソッドを提供する組み込みオブジェクトです。
Reflect自体はコンストラクタではないので、newすることはできません。(Mathオブジェクトのように)Reflectのメソッドとプロパティは静的です。

RefectはProxyのハンドラー関数と同じ名前で、同じ引数のメソッドをもちます。つまりProxyのトラップと1対1で対応するメソッドをもっているのです。

各々のメソッドの挙動を1つ1つ確認していくと、かなり冗長になってしまう(またReflectについてはこの記事の目的ではない)ので、本記事ではRelfect.getだけ簡単に紹介します。

基本的にはProxyのgetトラップと同じ引数を持つので、Reflect.getは次の3つの引数を順に取ります。

  • taregt: 対象のオブジェクト
  • property: 取得するプロパティ名
  • receiver: ゲッターがある場合、targetの呼び出しで使用するthis(第3引数は省略可能
const target = {
  hoge: "fuga",
};
// target.hogeと同じ
console.log(Reflect.get(tagret, 'hoge')); // "hoge"

シンプルな挙動ですね。この場合target.hogeと変わらないので、あまりメリットを感じませんが、ProxyとReflectを一緒に使うことで効果を発揮することがあります。

ProxyのgetトラップReflect.getを使ってみましょう。

const target = {
  hoge: "fuga",
};
const handler = {
  get: (target, property, receiver) => {
    // target[property]と同じ?
    return Reflect.get(target, property, receiver);
  },
};
const proxy = new Proxy(target, handler);
console.log(proxy.hoge); // "fuga"

この場合でもtarget[property]と置き換えられるように思えます。

では下記の場合はどうでしょうか。proxyで存在しないプロパティを取得しようとした時はdefaultValueが返ってくること期待した実装です。

const target = {
  hoge: "hogeValue",
  get piyo() {
    return this.fuga;
  },
};
const handler = {
  get: (target, property, receiver) => {
    if (property in target) {
      return target[property];
    }
    return "defaultValue";
  },
};
const proxy = new Proxy(target, handler);
console.log(proxy.piyo); // undefined

なんと期待とは裏腹に、proxy.piyoundefinedとなりました。
上記のコードをみるとgetトラップで返される値がtarget[property]ですね。
この場合target.piyoがゲッタなので、その中のthistargetとなります。したがってtarget上にはthis.fugaが存在せず、undefinedが返ってきます。

ではdefaultValueを返すためにはどうすれば良いでしょうか。このような場合の解決策としてReflect.getを使います。

  const target = {
    hoge: "hogeValue",
    get piyo() {
      return this.fuga;
    },
  };
  const handler = {
    get: (target, property, receiver) => {
      if (property in target) {
-       return target[property];
+       return Reflect.get(target, property, receiver);
      }
      return "defaultValue";
    },
  };
  const proxy = new Proxy(target, handler);
  console.log(proxy.piyo); // defaultValue

今度は期待通りproxy.piyodefaultValueとなりました。
肝となるのはReflect.getの第3引数(receiver)です。第3引数に渡されたオブジェクトが、ゲッタ呼び出しの際のthisとなるので、期待通りの挙動となります。

ざっくりですが、Reflectについて紹介しました。本記事ではこれ以上深掘りはしませんが、参考までにMDNのリンクだけ載せておきます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Reflect

ユースケース

さて、ここまでProxy(とReflect)の挙動についてみてきました。
しかし、挙動がわかってきたところで使う場面が思いつかない……なんてことはないでしょうか。実際、この記事を書いている私自身もユースケースをあまり思いつきませんでした。

ということで、Proxyのユースケースをいくつか調べてみましたので紹介します。

デフォルト値の取得

これは比較的思い浮かべやすい使い方ですね。
存在しないオブジェクトのプロパティを取得しようとした時にデフォルトの値を返すというものです。

const target = {
  "1th": "金メダル",
  "2th": "銀メダル",
  "3th": "銅メダル",
};
const handler = {
  get: (target, property, receiver) => {
    if (property in target) {
      return Reflect.get(target, property, receiver);
    }
    return "参加賞";
  },
};

const proxy = new Proxy(target, handler);
console.log(proxy["1th"]); // 金メダル
console.log(proxy["4th"]); // 参加賞

プロパティの加工

プロパティを加工することで、大文字/小文字をを区別せずに同じ値を取得できます。

const handler = {
  get: (target, property, receiver) => {
    return Reflect.get(target, property.toLowerCase(), receiver);
  },
  set: (target, property, value, receiver) => {
    Reflect.set(target, property.toLowerCase(), value, receiver);
    return true;
  },
};

const proxy = new Proxy({}, handler);
proxy.hoge = "fuga";
console.log(proxy.HOGE); // "fuga"

バリデーション

setトラップの箇所でも書いてしましたが、プロパティの値の設定時にバリデーションが可能となります。

const target = {
  name: "田中",
  age: 20,
};
const handler = {
  set: (target, property, value, receiver) => {
    if (!(property in target)) {
      console.error("文字列を入力してください");
      return false;
    }
    if (property === "name" && typeof value !== "string") {
      console.error("nameには文字列を入力してください");
      return false;
    }
    if (property === "age" && typeof value !== "number") {
      console.error("ageには数字を入力してください");
      return false;
    }

    Reflect.set(target, property, value, receiver);
    return true;
  },
};

const proxy = new Proxy(target, handler);
proxy.name = "佐藤";
proxy.age = "30"; // ageには数字を入力してください
console.log(target); // {name: '佐藤', age: 20}

配列に対しても同様です。

const target = [0, 1, 2];
const handler = {
  set: (target, property, value, receiver) => {
    if (typeof value === "number") {
      Reflect.set(target, property, value, receiver);
      return true;
    }
    return false;
  },
};

const proxy = new Proxy(target, handler);
proxy.push(3);
proxy.push("4"); // Uncaught TypeError: 'set' on proxy: trap returned falsish for property '4'

プライベートプロパティ

外部からアクセスできないプライベートなプロパティをつくることができます。

const target = {
  public: "Public",
  _private: "Private",
  getPrivate: function () {
    return this._private;
  },
};
const handler = {
  get: (target, property, receiver) => {
    const value = Reflect.get(target, property, receiver);
    if (typeof value === "function") {
      return value.bind(target);
    }
    if (!property.startsWith("_")) {
      return value;
    }
    return undefined;
  },
  ownKeys: object => {
    return Reflect.ownKeys(object).filter(key => !key.startsWith("_"));
  },
  has: (target, property) => {
    if (property.startsWith("_")) {
      return false;
    }
    return Reflect.has(target, property);
  },
  set: (target, property, value, receiver) => {
    if (property.startsWith("_")) {
      console.error(`${property}に値は設定できません`);
      return false;
    }
    return Reflect.set(target, property, value, receiver);
  },
};

const proxy = new Proxy(target, handler);
console.log(proxy.public); // "Public"
console.log(proxy._private); // undefined
console.log("_private" in proxy); // false
console.log(proxy.getPrivate()); // Private
console.log(Object.keys(proxy)); // ['public']
proxy._private = "hoge"; // _privateに値は設定できません

ReadOnlyなプロパティ

ReadOnlyなプロパティをつくることが可能です。

const ReadOnlyError = () => {
  throw new Error("ReadOnlyError");
};

const target = {
  hoge: "fuga",
};

const handler = {
  set: ReadOnlyError,
  defineProperty: ReadOnlyError,
  deleteProperty: ReadOnlyError,
  setPrototypeOf: ReadOnlyError,
  preventExtensions: ReadOnlyError,
};

const proxy = new Proxy(target, handler);
proxy.hoge = "piyo"; // Uncaught Error: ReadOnlyError

配列のマイナスインデックス

配列のインデックスに負の値を対応させる(負の値の場合、末尾から数える)ことも可能です。

const target = ["hoge", "fuga", "piyo"];
const handler = {
  get: (target, property, receiver) => {
    const num = Number(property);
    if (num < 0) {
      const i = target.length + num;
      return Reflect.get(target, i, receiver);
    }
    return Reflect.get(target, property, receiver);
  },
};
const proxy = new Proxy(target, handler);
// マイナスインデックスで取得したら、末尾から数える
console.log(proxy[0]); // "hoge"
console.log(proxy[-1]); // "piyo"
console.log(proxy[-2]); // "fuga"

生存時間のあるキャッシュ

TTL(生存時間)のあるキャッシュを実装できます。
以下は一定時間経過後に、hogeプロパティへアクセスするとundefinedが返ってくるコードです。

const SECONDS = 5;
const start = Date.now();
const isExpired = s => {
  return Date.now() - start > s * 1000;
};

const target = {
  hoge: "fuga",
};
const handler = {
  get: (target, propetry) => {
    return isExpired(SECONDS) ? undefined : Reflect.get(target, propetry);
  },
};
const proxy = new Proxy(target, handler);

setTimeout(() => {
  console.log(proxy.hoge); // "fuga"
}, (SECONDS - 1) * 1000);
setTimeout(() => {
  console.log(proxy.hoge); // undefined
}, (SECONDS + 1) * 1000);

データバインディング

オブジェクト間で、値の同期をシンプルに実装できます。
オブジェクトの値が変わった時に、inputの値を変更してみましょう。
加えてinputが変化した時に、オブジェクトの値を変更することで双方向のデータ同期を実装できます。

// <input type="text" id="inputText" /> が HTML上に存在すると仮定
const inputText = document.getElementById("inputText");

const target = {
  value: "",
};
const handler = {
  set: (target, property, value, receiver) => {
    if (property === "value") {
      inputText.value = value;
      return Reflect.set(target, property, value, receiver);
    }
    return false;
  },
};
const proxy = new Proxy(target, handler);

// proxyの値を変えると、inputの値が変わる
proxy.value = "hoge";

// inputに値を入力すると、proxyの値が変わる
inputText.addEventListener("input", e => {
  proxy.value = e.currentTarget.value;
});

別の処理のトリガー

別の処理のトリガーとして使うこともできます。
たとえば何かしらの入力が成功した時に、メールを送信するような実装が考えられます。

const sendEmail = () => {
  // ...処理
  console.log("メールを送信しました");
};
const errorLog = () => {
  // ...処理
  console.log("処理に失敗しました");
};

const target = {
  status: "",
};
const handler = {
  set: (target, property, value, receiver) => {
    if (property !== "status") {
      console.error("存在しないプロパティです");
      return false;
    }

    if (value === "success") {
      sendEmail();
      return Reflect.set(target, property, value, receiver);
    }

    if (value === "failure") {
      errorLog();
      return Reflect.set(target, property, value, receiver);
    }

    console.error("success か failure を入力してください");
    return false;
  },
};
const proxy = new Proxy(target, handler);
proxy.status = "success"; // "メールを送信しました"

まとめ

今回はJavaScriptのProxyについて、改めて学び直しました。
正直な話、ほとんど使ったことも遭遇したこともありませんでしたが、今回いろいろ調べてみて少し理解が進んだと思います。
今後は適切な場面で使えると嬉しいですね。

参考

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy
https://uhyohyo.net/javascript/16_14.html
https://ja.javascript.info/proxy
https://note.affi-sapo-sv.com/js-proxy.php
https://blog.logrocket.com/practical-use-cases-for-javascript-es6-proxies/
https://blog.bitsrc.io/a-practical-guide-to-es6-proxy-229079c3c2f0

Discussion

ログインするとコメントできます