261
209

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【JavaScript】数千行のコードを数行にしてしまう Proxy の魔力

Last updated at Posted at 2022-10-30

こんにちは。ぬこすけです。

最近(2022/10/20)に、ゆめみさんがクイズを出しました。

ゆめみさんの記事を読むと、 Proxy というオブジェクトが出てきました。
記事のタイトル通り「代入したのに、代入されない」、摩訶不思議なオブジェクトです。
Proxy って一体何者なんでしょうか?

ゆめみさんのクイズの例では やめ太郎さんがたかしに改名するのを断固拒否する 例ですが、
実は Proxy には 数千行のコードを数行にしてしまう魔力 も持っているのです。

今回は JavaScript の Proxy について魔力についてご紹介したいと思います。

本来、 Proxy は難しい内容ですが、 setTimeout の時と同じく、 できるだけ初心者の方にもわかりやすく説明していきたいと思います。

Proxy って何?

簡単にいえば、 オブジェクトを魔改造できる ものです。
ゆめみさんの記事の例から引用させていただきます。

const user = new Proxy(obj, { set: (target) => { target.name = "やめ太郎"; return true; } })
// 元のオブジェクト
const obj = { name: "" }

// Proxyオブジェクトを生成
const user = new Proxy(obj, { set: (target) => { target.name = "やめ太郎"; return true; } })

user.name = "たかし"

console.log(user.name)
// -> "やめ太郎"

console.log(obj.name)
// -> "やめ太郎"

この例では user.name = "たかし" というように、「たかし」にユーザー名を変更しようとしても、強制的にやめ太郎になっています。
まさしく user というオブジェクトが Proxy によって魔改造 させられています。

じゃあ 魔改造って具体的になんなん? という感じでしょう。

boy_question.png

その前に、そもそも「プロキシ」ってどういう意味 でしょうか?

正解は「 代理 」という意味です。

もしかしたら「プロキシサーバー」とかよく聞くかもしれません。
プロキシサーバーとは、クライアントとバックエンドのサーバーを中継する「代理」のサーバーです。

img_32171_14.jpeg
https://cybersecurity-jp.com/column/32171 より引用)

Proxy も代理をしています
先ほどのゆめみさんのコード例を見てみてください。

// 元のオブジェクト
const obj = { name: "" }

// Proxyオブジェクトを生成
const user = new Proxy(obj, { set: (target) => { target.name = "やめ太郎"; return true; } })

この例では obj に対してセッターが働いた時 Proxytarget.name = "やめ太郎"; するように代理をしています。

じゃあ 魔改造って具体的になんなん? という感じでしょう。

少しずつ解像度が上がってきたのではないでしょうか?
ここで「プロキシサーバー」のお話に戻ります。

プロキシサーバーの役割をもう少し詳しく考えます。

クライアントとバックエンドのサーバーを中継する「代理」のサーバー

プロキシサーバーは中継をします。
中継といっても、何を起点に何をする のでしょうか?
例えば、クライアントから「ページの情報くれ」とリクエストが飛んだ時には、ページの情報を得るためにバックエンドのサーバーにリクエストを飛ばします。
また、バックエンドからページの情報が返ってきた時には、クライアントにページの情報を返します。

Proxy にも同じことが言えます

この例では obj に対してセッターが働いた時 Proxytarget.name = "やめ太郎"; するように代理をしています。

「〇〇した時に〇〇する」 というような表現になっています。
これが 魔改造の真理 です。

Proxy は「〇〇した時に〇〇する」と定義して、対象のオブジェクトの代理として動く のです。
先ほどのゆめみさんのコード例ではセッターでしたが、 ゲッターや関数実行時にも、好きな処理を定義することできるのです!

const user = new Proxy({ name: "" }, { get: () => "やめ太郎" })

こちらもゆめみさんの記事のコード例です。
この例ではゲッターを魔改造しており、ゲッターが働いた時は "やめ太郎" を返却するように代理しています。

Proxy マジック

Proxy の威力をお見せします。

あなたのサイトにはバックエンドの API にリクエストするために、次のような実装をしていました。

// 各 API へアクセスするためのパスを定義
const apiPathList = {
  articles: '/api/articles',
  users: '/api/users',
  // ...いっぱい
}

// 各 API へアクセスするためのオブジェクトを定義
const myClient = {
  fetchArticles(queryParams) {
    return new Promise((resolve, reject) => {
      // { page: 1 } などのオブジェクトを page=1 のようなクエリパラメータに変換
      const queryStr = (new URLSearchParams(queryParams)).toString();
      fetch(`${apiPathList.articles}?${queryStr}`)
        .then(res => res.json())
        .then(resolve)
        .catch(reject);
    })
  }
  fetchUsers(queryParams) {
    // ...
  }
  // API のパスごとに関数を用意
  // ...
}

// API へアクセスする時
const artcles = await myClient.fetchArticles({ page: 1 });

バックエンドにはいくつか API が用意してあり、 API ごとにリクエストする関数を作成しています。
しかし、サービスが拡大、バックエンドもマイクロサービス化していき、どんどん API が増えてしまいました。
API が増えるごとに各 API へアクセスするための URL の定義やリクエストする関数も増やさなければなりません。

ここで Proxy を使ってマジックします。

const apiClient = {};

// キャメルケースを小文字にしてスラッシュに変更する関数
// 例: fetchApiArticles -> fetch/api/articles
const convertCamelCaseToLowerCaseSlash = str => {
  return str
    .split(/(?=[A-Z])/)
    .join('/')
    .toLowerCase();
}

// Proxy でゲッターを魔改造して、 fetch する関数を返すようにします
const handler = {
  // prop には、関数名が入ります
  // myClient.fetchApiArticles だったら prop は 'fetchApiArticles' になります
  // target と receiver は使いません
  get(_target, prop, _receiver) {
    return queryParams =>
      new Promise((resolve, reject) => {
        if (prop.indexOf('fetch') !== 0) {
          reject('プロパティ名の先頭は「fetch」をつけてください');
          return;
        }
        // { page: 1 } などのオブジェクトを page=1 のようなクエリパラメータに変換
        const queryStr = queryParams
          ? `?${new URLSearchParams(queryParams).toString()}`
          : '';
        // convertCamelCaseToSlash の結果が fetch/api/articles のようになるので、先頭の fetch は削除
        const requestUrl = convertCamelCaseToLowerCaseSlash(prop).replace('fetch', '');
        fetch(`${requestUrl}${queryStr}`)
          .then(data => data.json())
          .then(resolve)
          .catch(reject);
      });
  },
};

const myClient = new Proxy(apiClient, handler);

// 関数は定義されていないが /api/articles?page=1 でリクエストされる!!
const articles = await myClient.fetchApiArticles({ page: 1 });

// 関数は定義されていないが /api/users?page=1 でリクエストされる!!
const users = await myClient.fetchApiUsers({ page: 1 });

// 「プロパティ名の先頭は「fetch」をつけてください」 のエラーが出る
const response = await myClient.hoge();

myClient.fetchApiArticlesmyClient.fetchApiUsers というような関数が定義されていないのにも関わらず、期待するリクエストが飛びます!!

そして、 バックエンドの API が増えても改修する必要がありません
例えば、新しく /api/search のような API が追加されたとしましょう。
myClient.fetchApiSearch を使うだけで終わり です。

このコードでは一定のルールに基づいてリクエストする URL を組み立てています。
ルールというのは次の通りです。

  • プロパティ名の先頭には「fetch」をつける。
  • プロパティ名のキャメルケースがそのままリクエストするパスになる。
    • 例えば、 fetchHogeFuga の場合は /hoge/fuga へのリクエストパスに変換される。

コード例の Proxy ですが、 handler をいじればさらに機能を拡張させることができます。
例えば、リクエスト先のドメイン名を変えたいとか、リクエストするメソッドを変えたり、などです。

// GET でリクエスト
myClient.fetchGetApiArticles();

// hoge.com のような別ドメインにリクエスト
myClient.fetchHogeDomainApiArticles();

テストケースも API ごとに関数を作成していた場合はその関数ごとにテストが必要ですが、この Proxy の例ではテキトーなプロパティ名に基づいて期待されるリクエスト URL が発行されているのを確認できれば良いので、テストケースも減らせます。

このように Proxy を使ってかなりのコード量を減らすことができます

[2022/11/02 追記]
コード例に載せたものを fetch-magic という npm ライブラリとして公開しました!
コードも公開しているので実装の参考にしたり、インストールして使ってみてください!
バグっていたりこうした方が良いとかあったら教えてもらえると嬉しいです!

Proxy の使いどころ

正直なんでもできちゃうので発想次第なところはありますが、肝となるのは オブジェクトの拡張 でしょう。

VueMobX などのライブラリは、コンポーネントに渡すデータを Proxy で拡張します。
オブジェクトの変更を検知して、データ依存のあるコンポーネントのみをレンダリングしますが、これは Proxy を使って検知しています。
「あるオブジェクトに変更が走った時に〇〇したい!」というケースに使えるかもしれません。

その他、私の例で恐縮ですが、 WebWorker のスレッド管理や WebWorker 内でメインスレッドの DOM にアクセスする処理に Proxy を使ったことはあります。

WebWorker のスレッド管理というのは、例えば WebWorker で処理したいタスクをキューに積んでおいて、あるスレッドで処理が終了した時にキューにタスクがあれば引き続き実行、なければスレッドを終了させる、みたいな感じです。 Proxy でラップしたスレッドの状態管理するオブジェクトに変更が走った時に、「キューにタスクがあれば引き続き実行、なければスレッドを終了」などのスレッドを管理する処理をします。

後者の「WebWorker 内でメインスレッドの DOM にアクセスする処理」については下の記事で少し話を触れています。

Ruby が特にこの分野に強いですが、 Proxy は言わば メタプログラミング と呼ばれる分野でもあります。
完全に余談ですが、もし Ruby を勉強している方がいれば、「メタプログラミングRuby」という本がおすすめです。
私はこの本を読んで Ruby 技術者認定試験の Gold をとりました!笑

(ここから本買ってもらえると泣いて喜びます!!)

Proxy 使いまくろう?

Proxy って便利やん!早速使いまくろう!」

banzai_kids_boy1.png

ダメ です。

まあ、ダメは言いすぎかもしれませんが、私はおすすめしません。

おすすめしない理由はいくつかあります。

一番の理由は 誰もコード読めない ためです。
Proxy マジックでの Proxy を使ったコード例ですが、正直かなりコード読むの辛かったのではないでしょうか?
Proxy 自体なかなか癖がありますし、「こういうプロパティ名で書かなきゃいけない」という暗黙の独自ルールもありました。
Proxy の処理を書いた自分自身も数ヶ月後には読めなくなってるんじゃないかと思います😇

その他、 TypeScript と相性が悪い です。
Proxy で実装するとわかりますが、型定義するのが厳しくなるケースがよくあります。

そもそも Proxy というのが動的に、自由さを求めるものに対し、 TypeScript は静的に、厳格さを求めるものであります。
犬猿の仲ではないですが、お互い理念的に相反するものかなーと思います。

強大な力を得るためには代償が伴います。
デメリットを認識した上で Proxy は使いましょう。

最後に

いかがだったでしょうか? Proxy についてなんとなくでも理解できたら嬉しいです!

JavaScript は深ぼれば新しい発見が得られます。
例えば、 setTimeout も実はただの指定した時間に実行する関数でなかったりします。

もし記述に誤りなどありましたらご指摘いただけば幸いです!

今後も記事を書いていこうと思うので、良かったら Twitter をフォローしていただければ記事投稿を通知します!

ここまでご覧いただきありがとうございました!

261
209
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
261
209

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?