こんにちは。ぬこすけです。
最近(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
によって魔改造 させられています。
じゃあ 魔改造って具体的になんなん? という感じでしょう。
その前に、そもそも「プロキシ」ってどういう意味 でしょうか?
正解は「 代理 」という意味です。
もしかしたら「プロキシサーバー」とかよく聞くかもしれません。
プロキシサーバーとは、クライアントとバックエンドのサーバーを中継する「代理」のサーバーです。
(https://cybersecurity-jp.com/column/32171 より引用)
Proxy
も代理をしています 。
先ほどのゆめみさんのコード例を見てみてください。
// 元のオブジェクト
const obj = { name: "" }
// Proxyオブジェクトを生成
const user = new Proxy(obj, { set: (target) => { target.name = "やめ太郎"; return true; } })
この例では obj
に対してセッターが働いた時 Proxy
が target.name = "やめ太郎";
するように代理をしています。
じゃあ 魔改造って具体的になんなん? という感じでしょう。
少しずつ解像度が上がってきたのではないでしょうか?
ここで「プロキシサーバー」のお話に戻ります。
プロキシサーバーの役割をもう少し詳しく考えます。
クライアントとバックエンドのサーバーを中継する「代理」のサーバー
プロキシサーバーは中継をします。
中継といっても、何を起点に何をする のでしょうか?
例えば、クライアントから「ページの情報くれ」とリクエストが飛んだ時には、ページの情報を得るためにバックエンドのサーバーにリクエストを飛ばします。
また、バックエンドからページの情報が返ってきた時には、クライアントにページの情報を返します。
Proxy
にも同じことが言えます。
この例では
obj
に対してセッターが働いた時Proxy
がtarget.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.fetchApiArticles
や myClient.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 の使いどころ
正直なんでもできちゃうので発想次第なところはありますが、肝となるのは オブジェクトの拡張 でしょう。
Vue
や MobX
などのライブラリは、コンポーネントに渡すデータを Proxy
で拡張します。
オブジェクトの変更を検知して、データ依存のあるコンポーネントのみをレンダリングしますが、これは Proxy
を使って検知しています。
「あるオブジェクトに変更が走った時に〇〇したい!」というケースに使えるかもしれません。
その他、私の例で恐縮ですが、 WebWorker
のスレッド管理や WebWorker
内でメインスレッドの DOM にアクセスする処理に Proxy
を使ったことはあります。
WebWorker
のスレッド管理というのは、例えば WebWorker
で処理したいタスクをキューに積んでおいて、あるスレッドで処理が終了した時にキューにタスクがあれば引き続き実行、なければスレッドを終了させる、みたいな感じです。 Proxy
でラップしたスレッドの状態管理するオブジェクトに変更が走った時に、「キューにタスクがあれば引き続き実行、なければスレッドを終了」などのスレッドを管理する処理をします。
後者の「WebWorker
内でメインスレッドの DOM にアクセスする処理」については下の記事で少し話を触れています。
Ruby が特にこの分野に強いですが、 Proxy
は言わば メタプログラミング と呼ばれる分野でもあります。
完全に余談ですが、もし Ruby を勉強している方がいれば、「メタプログラミングRuby」という本がおすすめです。
私はこの本を読んで Ruby 技術者認定試験の Gold をとりました!笑
(ここから本買ってもらえると泣いて喜びます!!)
Proxy 使いまくろう?
「 Proxy
って便利やん!早速使いまくろう!」
ダメ です。
まあ、ダメは言いすぎかもしれませんが、私はおすすめしません。
おすすめしない理由はいくつかあります。
一番の理由は 誰もコード読めない ためです。
Proxy マジックでの Proxy
を使ったコード例ですが、正直かなりコード読むの辛かったのではないでしょうか?
Proxy
自体なかなか癖がありますし、「こういうプロパティ名で書かなきゃいけない」という暗黙の独自ルールもありました。
Proxy
の処理を書いた自分自身も数ヶ月後には読めなくなってるんじゃないかと思います😇
その他、 TypeScript と相性が悪い です。
Proxy
で実装するとわかりますが、型定義するのが厳しくなるケースがよくあります。
そもそも Proxy
というのが動的に、自由さを求めるものに対し、 TypeScript は静的に、厳格さを求めるものであります。
犬猿の仲ではないですが、お互い理念的に相反するものかなーと思います。
強大な力を得るためには代償が伴います。
デメリットを認識した上で Proxy
は使いましょう。
最後に
いかがだったでしょうか? Proxy
についてなんとなくでも理解できたら嬉しいです!
JavaScript は深ぼれば新しい発見が得られます。
例えば、 setTimeout
も実はただの指定した時間に実行する関数でなかったりします。
もし記述に誤りなどありましたらご指摘いただけば幸いです!
今後も記事を書いていこうと思うので、良かったら Twitter をフォローしていただければ記事投稿を通知します!
ここまでご覧いただきありがとうございました!