12
13

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 5 years have passed since last update.

JSのProxyでCallableなクラスを作る

Last updated at Posted at 2017-12-26

はじめに

例えばD3.jsを使っていると以下のようなコードがたくさん現れます。

const manyBody = d3.forceManyBody()
  .strength(200)
  .distanceMax(400)
  .distanceMin(60)

manyBody(0.5)

メソッドチェインでオブジェクトを初期化して、作成されたオブジェクトは関数として呼び出すことができます。
このようなオブジェクトを綺麗に実装するのが目標です。

従来の方法

いわゆるES5でのやり方は大雑把に以下の通りです。

var myPlugin = function () {
  var plugin = function () {
    // 関数として呼び出した時の処理
    // ...
  }

  plugin.aMethod = function () {
    // インスタンスのメソッド
    // ...
    return plugin
  }

  return plugin
}

まず plugin の実体を返す myPlugin 関数で全体を括ります。
関数として呼び出した時の振る舞いを plugin 関数として実装します。
そして、 plugin のメソッドを追加していき、最終的に pluginmyPlugin の戻り値として返します。
D3.jsの実装も大体このようになっています。

使い方は以下のようになります。

var p = myPlugin()
  .aMethod()

p()

classを使った実装

最近のJSに慣れているとclassを使って実装したい気持ちになると思います。
まずは以下のように素直に実装します。

class MyPlugin {
  constructor () {
    // インスタンスの初期化
    // ...
  }

  aMethod () {
    // インスタンスのメソッド
    // ...
    return this;
  }

  execute () {
    // 関数として呼び出した時の処理
    // ...
  }
}

const myPlugin = () => new MyPlugin()

さて、classを使ってしまうと作成したインスタンス pp() のように呼び出すことはできません。
ここでは、代わりに execute メソッドを追加し、その役割を持たせることにします。
また、最初のバージョンと同じ使い勝手にするために、インスタンスを生成する myPlugin 関数を作っておきます。

使い方は以下のようになります。

// const p = new MyPlugin() としてもOK
const p = myPlugin()
  .aMethod()

p.execute()

これでも十分かなとは思うのですが、D3.jsでは特定のメソッドやプロパティを持っていて、なおかつ呼び出し可能なインスタンスを引数としてとるAPI(例えばd3-forceのプラグインを作るう場合など)が存在するため、単純なclassベースの実装では対応できません。

classとProxyを使った実装

さて、前述の課題を克服するために、いよいよ本題のProxyを使った実装を行います。
ProxyはES2015で追加された機能です。
(参照:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy)

最初に実装を示します。

class MyPlugin extends Function {
  constructor () {
    super()

    // インスタンスの初期化
    // ...

    return new Proxy(this, {
      apply (target, thisArg, argumentsList) {
        return target.execute(...argumentsList)
      }
    })
  }

  aMethod () {
    // インスタンスのメソッド
    // ...
    return this;
  }

  execute () {
    // 関数として呼び出した時の処理
    // ...
  }
}

変更箇所は2箇所です。
1つ目は constructorthis をラップしたProxyインスタンスを返すようにしたことです。
Proxyのハンドラで apply メソッドを定義することで、インスタンスの呼び出しをトラップすることができます。
2つ目は MyPluginFunction を継承するようにしたことです。
これは、Proxyで apply をトラップするためには、ターゲットオブジェクトが呼び出し可能である必要があるためです。

apply メソッドの第1引数の target は、Proxyでラップされていない MyPlugin インスタンスです。
そのため、 target.execute(...argumentsList) と実装することで、p()p.execute() と同じ動きになります。
MyPluginexecute メソッドを持たせたくない場合は、クラス内ではなくてプレーンな関数としても問題ありません。

実は、Functionを継承すればProxyを使うことは必須ではありません。
ただし、Functionクラスを使用する場合、その実装を文字列として与える必要があり、使い勝手が良いとは言い難いです。

さて、これでES5版と同じように使うことができるようになりました。

const p = myPlugin()
  .aMethod()

p()

おわりに

今回は触れませんが、従来版では外部からアクセスできないprivateなフィールドを作るという意味合いもありました。
ES2015クラスで同様のことを行う場合はWeakMapを使ったprivateなフィールドの実現などが可能です。
ただし、その際にはProxyでラップされたインスタンスとオリジナルのインスタンスの違いなどを意識しなければなりません。
この辺りはclass-fieldsの実装が進めばもう少し楽になるでしょう。

12
13
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
12
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?