JavaScriptでDIコンテナってどうなの?という話題について雑に書きます。↓の話題について意見をくれと某所で言われたのでざっくり意見を書きます。
DIコンテナ(≒依存性注入自動化フレームワーク)の発祥の地といってよさそうなJavaでは、DIコンテナでも使わないと依存性注入がやりづらいという言語上の制約があったために、DIを実現するためにはDIコンテナが事実上必須だった、みたいな雑な認識をしています。
しかし、JavaScriptのように関数が第一級市民として扱われる(関数を値として引数に渡せる)言語では、依存性の注入や差し替えが概ね容易であることが多い(DIコンテナがなくてもDIを実現しやすい)ため、DIコンテナ的なフレームワークを導入する旨みはJavaと比べると薄めなのかなと思います(ふわっとした発言)。実際、DIコンテナを内蔵しているNestJSを使ってみると、規模次第では便利っぽいけど、規模次第では煩雑さが目立つ(exportsやprovidersを登録するのめんどい)なあという印象です。
素朴なDIについて想いを馳せる
僕がDIでやりたかったこと(だと自分では思っている)を素朴に思い出してみます。
例として、次の fnA()
は fnB()
への依存性を持っており、モックが原則不可能です*1。
/* 依存性がある関数A */ import fnB from './b.ts'; export function fnA(a: number) { const b: Date = fnB(a); b.setDate(1); return b; } /* 使い方 */ const dateA: Date = fnA(1); // fnBの挙動をモックできない
一方、次の fnC()
は↑と同じ処理を表していますが、 fnB()
への依存性を持っていません。
/* 依存性を注入している関数C */ export function fnC(a: number, fnX: (a: number) => Date) { const b: Date = fnX(a); b.setDate(1); return b; } /* 使い方 */ import fnB from "./b.ts"; const dateC: Date = fnC(1, (a) => fnB(a)); // 第二引数を差し替えることでfnBをモック可能
fnC
では、第二引数で fnB()
相当の関数 fnX()
を外から注入しています。これも依存性の注入=DIと呼んで差し支えないでしょう。上記の例では fnB()
がそのまま実行されるだけですが、テストコード等では次のような書き方もできます。
const actual: Date = fnC(1, (a) => new Date('2023-01-15')); // fnXの挙動を固定する expect(actual).toBe(new Date('2023-01-01'));
fnX()
が内部的に fnB()
を利用しない形に差し替え、常に固定の結果を返すようにしました。これで fnB()
の実装を気にせず、 fnC()
固有の挙動のテストに集中することができます。
これは素朴な例です。もっとDIコンテナが欲しくなるような複雑なケースもあるかとは思いますが、少なくとも僕は欲しいDIはこの素朴な例を少し複雑にした程度に収まることが多めです。
まとめ
DIという概念で僕が最低限やりたいことは、前述のように素朴なものです。もっと複雑なケースではDIコンテナが必要になるケースもあるかもしれませんが、素朴なケースでDIコンテナを持ち出すのは嫌だなあと思います。
用法容量を守って、必要なDIを必要な分だけやっていきましょう。
*1:webpack等でimport元を差し替えてテスト用モジュールに差し替えるようなハックはできますが、テスト用途で気軽に使うには実装コストが見合わないように思います