Skip to main content

JavaScriptライブラリにおけるProxy Directoryパターンとライブラリの参照整理

概要

ここではJavaScriptをパッケージ内の参照構造の整理方法の1つ、Proxy Directoryパターンを紹介する。 また、その準備として、ライブラリ内のファイルを直接参照するときの名前空間に余計なディレクトリを切らない方法をはじめに示する。

ライブラリ内のファイル直接参照時の名前空間を整理する

整理されていな例

たとえばcsv-stringify@5.5.1というライブラリで、 同期的にcsv形式のデータを生成する際の関数は次のようにインポートする。

import stringify from "csv-stringify/lib/sync";

const stringify = require("csv-stringify/lib/sync");

ルートからexportされてないだけではなく、libという本来ライブラリを利用するコンテキストでは不必要なパスが入っている。

整理されている例

たとえばreact-dom@16.14.0はブラウザ用に利用するコードと、サーバーサイドで利用するコードの2種類がある。 ブラウザ側で利用する実装は次のように書ける。

import * as ReactDOM from "react-dom";

const ReactDOM = require("react-dom");

サーバー側で利用する実装は

import * as ReactDOM from "react-dom/server";

const ReactDOM = require("react-dom/server");

と書くことができ、不要なパスが存在せず、ライブラリ利用者は混乱する可能性がない。

実装方法

react-domのような構造をどうやって実現しているのか、実際にパッケージをダウンロードして中身を確認したらわかる。

細かい部分は割愛するが、以下のようなディレクトリツリーをそれぞれ持つ。

csv-stringify-5.5.1
├── lib
│   ├── es5
│   ├── index.d.ts
│   ├── index.js
│   ├── sync.d.ts
│   └── sync.js
└── package.json
react-dom-16.14.0
├── LICENSE
├── README.md
├── index.js
├── package.json
└── server.js

ライブラリ名で直接インポートされるのは、package.jsonmainフィールドに記述されたもので、 csv-stringifyの場合はlib/index.jsreact-domの場合はindex.jsが読み込まれる。

ここまでくるとおわかりだろうが、package.jsonのmainフィールドに記述されていないライブラリ内のファイルを直接参照する場合、 ファイルのパスの起点はnode_modules/[library name]となる。

したがって、不要なパスを直接参照時に含めたくない場合はライブラリのルートディレクトリにjsファイルもしくはディレクトリを直接配置し、名前空間を区切れば良い。

簡単にまとめると以下のようになる。

[library-root]
├── index.js
├── hoge.js // require("[library-root]/hoge") で参照可能
├── foo
│   └── index.js // require("[library-root]/foo") で参照可能
└── package.json

Proxy Directory

Proxy Directoryの説明に移る前にもう少し知識のインプットと、問題意識の共有をしておきたい。

package.jsonaliasとして使う

先に示したように、package.jsonのフィールドに置いて、mainフィールドはライブラリ内でaliasとして機能する。 また、TypeScriptにおいて、typesフィールドが型定義のaliasとして機能する(参照)。

これらはよく知られたことだが、package.jsonにはあまり知られていないもう一つの特性がある。 ライブラリ内でpackage.jsonを複数持ち、alias用のファイルとして機能させることができることだ。 yarn/npm(v7以降) のworkspacesでMonorepoを利用しているときとユースケースは近い。

具体的には次のようなことが可能となる。

[library-root]
├── index.js
├── foo
│   └── package.json // main/typesフィールドに記載されたディレクトリにリダイレクトする
└── package.json
const foo = require("[library-root]/foo"); // 参照先はfoo/package.jsonの設定次第

参照解決のアルゴリズムはNode.jsのModule APIに記述がある。

Tree Shakingに対応した実装コードの参照解決をする

現代のJavaScriptはwebpackやrollup、ParcelなどのバンドラーによるTree Shakingへの対応が求められる。 この対応をしなければファイルサイズの肥大化の原因となりパフォーマンスの問題を引き起こす。解決にはesmodule形式のコード生成し、package.jsonmoduleフィールドに明示する必要がある。

ライブラリ変更時の影響範囲とファイルの個別インポートによる問題点

commonjs用とesmodule用の両方を用意した場合、package.jsonmainmoduleにそれぞれ登録できるのは1ファイルだけである。 この場合、ライブラリ側を信用してパッケージのルートから必要なモジュールを読み込み、コードをTree Shakingにかける。 読み込んだモジュールの下位に属するモジュールが副作用(side effects)を持たなければ問題ないものの、 万が一、副作用のあるコードが紛れ込んだときに、芋づる式にバンドルされてしまう可能性があり、影響範囲が大きい。

問題点

影響範囲を最小限に抑えるためにはesmoduleの実装を個別にインポートする必要がある。 しかし、ライブラリ利用者側がライブラリの内部実装を知る必要が出てきてしまう。 これではライブラリとして提供している意味がなくなってきてしまうので、個別インポートの選択肢を残しつつ、利用者が内部実装を知らない状態にする解決策が必要となる。

解決方法

したがって、

が達成されれば、両者が恩恵を受ける。 このうち、package.jsonをaliasとして提供するための設計構造をProxy Directoryというデザインパターンと呼ぶ。

cherry-pickライブラリを利用してProxy Directoryを作成する

Proxy Directoryの作成にあたってはcherry-pickという先駆者がいる。 これを利用することで簡単にproxy directoryが生成できる。

インストールはいつもどおり次のようにする。

yarn add -D cherry-pick

CLIとして利用する場合は次の通り。

cherry-pick \
--cwd ./lib # 出力先のルートパス
--input-dir ../src \ # 対象ディレクトの直下にあるファイル名のProxy Directoryを作成する (cwdからの相対パス)
--types-dir ./types # .d.tsの出力ディレクトリ
--cjs-dir ./cjs \ # CommonJSの出力ディレクトリ
--esm-dir ./esm \ # esmoduleの出力ディレクトリ

これを実行すると、出力先にProxy Directoryが生成される。 実際にこれを利用しているライブラリとしてはreact-bootstrapが挙げられる。

また、筆者が用意した簡易なサンプルコードとDEMOは以下の通り。手にとって動かしてみてほしい。

サンプルコード

DEMO

サンプルコードはパッケージを配布しているので、codesandboxで実際に手を動かして触ることが可能だ。

アーキテクチャ

Proxy Directoryのアーキテクチャを俯瞰した図で表すと次のようになる。

Proxy Directoryのアーキテクチャ

ビルド周り

実際にはcherry-pickを利用してもまだライブラリの利用者が参照パスを書くために内部実装を知る必要が出てくる。 これを解決するにはProxy Directoryを吐き出した先でpublish可能な状態に調整する必要があるが、 全部書くと長く本筋とずれるので、記事を切り出しておく(現在工事中)。

まとめ

Proxy Directoryパターンの恩恵は以下。

  • ライブラリの実装者
    • 実装の都合がライブラリの利用者に影響しなくなる。
  • ライブラリ利用者
    • 小さな範囲でコードを利用する選択肢を得られる。
    • cjsかesmoduleか調べなくても良くなる。

Reference