私は最近Node.jsで趣味のスクリプトを書いています。Node.jsであれば最新のECMAScriptの文法が使えるので、その意味で勉強になって良いですね。
また、何をするにしてもnpmで複数の選択肢が見つかること、それらのモジュールを簡単に試せること、といった点はやはり便利です。私も複数のモジュールを利用して便利にコードを書いています。
ところで、こうしたモジュールを継承して自分好みの挙動に修正したり新しいメソッドを足したりしたい場合にどうすれば良いのでしょうか。これは私のようなECMAScript初心者には難しい問題で、試行錯誤にかなり時間を使ってしまいました。
本稿では、既存のCommonJSモジュールを継承して新たなモジュールを作る方法について紹介します。あくまで私なりの結論ですので、ツッコミをお待ちしております。
CommonJSモジュールとは
まずは前提知識について簡単に紹介します。CommonJSモジュールというのは、主にNode.jsで利用されているソースコード分割の仕組みです。Nodeを使っていると下記のようなコードをよく見ると思いますが、このように require
で読み込むモジュールがCommonJSモジュールです。
const puppeteer = require('puppeteer');
CommonJSモジュールではrequire
で返す値だけが公開され、それ以外の値は隠蔽されるので、グローバルオブジェクトの汚染で悩まされずにすむわけです。
require
では何を返しても構いません。実際、ES6クラスを返すモジュールもあれば、普通の関数やオブジェクトを返すモジュールもあります。
ケース1a: ES6クラスの継承(トップレベル)
では、CommonJSモジュールをどう継承するか考えていきます。
まずはrequire
でES6クラスが帰ってくる場合を考えてみましょう。この場合は素直に継承を実現できます。
const Foo = require('foo'); class Newfoo extends Foo { // 既存のメソッドを上書きしたり、新たなメソッドを追加したり } module.exports = Newfoo;
このように自分好みのクラスを作って、それをmodule.exports
にセットすれば既存のモジュールと同じように呼び出して使うことができます。
ケース1b: ES6クラスの継承(セカンドレベル以下)
ケース1aのクラス継承はトップレベルのES6クラスを置き換えたいときにしか使えません。下位のクラスの挙動を変更したい場合は別の方法をとることになります。
具体的な例を紹介します。以下はpuppeteerのElementHandleクラスに新たなメソッドを足す例です。
const {ElementHandle} = require('puppeteer/lib/api'); // via: https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom/21696585 ElementHandle.prototype.isVisible = async function () { return await this.executionContext().evaluate(el => { if (!el) return false; const style = window.getComputedStyle(el); if (!style) return false; if (style.display === 'none') return false; if (style.visibility !== 'visible') return false; if (style.opacity < 0.1) return false; const bndRect = el.getBoundingClientRect(); if (el.offsetWidth + el.offsetHeight + bndRect.height + bndRect.width === 0) { return false; } return true; }, this); }; const myPuppeteer = require('puppeteer'); // (本稿では省略) module.exports = myPuppeteer;
ここで書き換えているElementHandleはメソッド呼び出しを3回くらいしてようやく登場するクラスですので、クラス継承で挙動を変更するのは非現実的です。
このような場合、通常なら直接require
しない下位のクラスを取り出してプロトタイプ継承で書き換えることができます。
ちなみに、prototypeに与える関数はアロー関数で書いてはいけません。上の例のようにfunction
キーワードを使う必要があります。というのも、アロー関数で書くとthis
がグローバルオブジェクトを指してしまい、メソッド呼び出しとして動かなくなってしまうのです(参考:Prototypeの関数でアロー関数は使わない - Qiita)。
ケース2: モジュールパターンの書き換え
ところで、CommonJSモジュールの多くはケース1aに当てはまりません。私の知る限り、トップレベルのオブジェクトとしてファクトリメソッドを含んだオブジェクトを返すものが多いように思います。
たとえばlog4jsなどは次のように使います。
const logger = require('log4js').getLogger(); logger.level = 'debug'; logger.debug("Some debug messages");
この場合ケース1の方法で挙動を書き換えるわけにはいきません1。このような場合は必要に応じてオブジェクトを上書きすることになります。
下記は私がlog4jsを上書きして自分好みのデフォルト挙動で使えるようにしたモジュールの抜粋です。
const log4js = require('log4js'); // オリジナルをコピーして使う。ディープコピーが必要な場合はよしなに。 const myLog4js = Object.assign({}, log4js); // 自分好みのデフォルト値を設定する新規メソッド(本稿では省略) const origGetLogger = myLog4js.getLogger; myLog4js.getLogger = function (...args) { if (!enabled && !process.env.LOG4JS_CONFIG && defaults['appenders']) { configure(defaults); } return origGetLogger.apply(myLog4js, args); } module.exports = myLog4js;
このパターンでの書き換え対象はファクトリメソッドやその他の関数になるはずです。単に関数を書き換えるだけなら何も問題はありませんが、関数から元の関数を呼びだす場合は次のような手順が必要です。
- 元の関数を変数に保存
Function.prototype.apply()
で自分自身をthis
にセットして呼び出し
apply()
を使うと可読性が下がってしまいますが、既存の関数を上書きする場合は他に書きようがないと思います。
そもそも継承を使うべきかどうか
上記で紹介した継承による機能拡張は無制限に使って良いものではありません。継承を使うと親子間が密結合になってしまい、保守性が下がることが多いです。また、パターン1bは既存コードに影響を与えかねない修正なので、より慎重に適用する必要があるでしょう。
今回のように既存のモジュールの挙動を変更する場合、次の点に注意して使うのが良いように思います(異論はあるでしょうが…)。
- 子クラスの実装量は十分小さい範囲にとどめる
- 機能追加は原則として新規メソッドで行う
- 既存の関数を書き換える場合、従来の挙動を維持するよう注意する
- 既存コードに影響を与えないようにするため
- 特にセカンドレベル以下のモジュールの書き換えは注意
継承以外にも機能追加を実現するデザインパターンは多数あります。継承は用法・用量を守って正しく使いましょう。
まとめ
既存のCommonJSモジュールを継承して新しいモジュールを作る方法について議論してきました。私の得た結論は次のようなものです。
- トップレベルのモジュールがクラスを返す場合はES6のクラス継承が使える
- セカンドレベル以下のモジュールがクラスを返す場合はプロトタイプ継承で挙動を変更できる
- アロー関数を使うと死ぬので注意
- モジュールパターンのモジュールは単に上書きすれば良い
- 元の関数を呼び出す場合は
apply()
する
- 元の関数を呼び出す場合は
- 継承を使うべきかどうかは要検討
ES6モジュールについては私が使っていないのでわかりませんが、ほぼ同じ考え方が適用できると想像しています。
-
そもそもクラスではないので継承というのも不適切ですが…↩