eslint-plugin-import-accessで「そこからそれはimportしないでください!!」を防ぐ
この記事は 株式会社ゆめみの23卒 Advent Calendar 2023 16日目の記事です。
3行で
- eslint-plugin-import-accessで「ディレクトリの他の階層からimportしてほしくないメンバ」を定義できるよ!
- さらに
defaultImportability: "package"
を指定するとちょっと初見殺し感があるけどかなり強力になるよ! - re-exportを使う場合はビルドパフォーマンスやバンドルサイズに影響する可能性があるから気をつけよう!
eslint-plugin-import-accessとは
アプリケーションなどを開発しているとき、あるモジュールの範囲内でのみ使用してほしい(=あるモジュールの中に隠蔽したい)変数や関数を定義したくなることがあります。
Webアプリケーションの文脈では、例えば次のようなものがあります。
- Recoilやjotaiのatomなど
- featureの中でのみ共通化したいロジック、定数
- いくつかのコンポーネントが中で使用しているが、直接使用されてほしくないコンポーネント
TypeScriptを用いた開発の場合、一定のモジュールの中に隠蔽する方法としては、次のようなものがあります。
- クラスのプライベートメンバにする(範囲はクラス)
- 変数をファイルからexportしない(範囲はファイル)
- パッケージを分ける(範囲はパッケージ)
しかし、先述の例ではクラスを使うとややこしくなり、1ファイルに隠蔽したい変数とその依存先を全部書くには量が多く複雑すぎ、パッケージを分けるほどの単位でもないことがあります。
そのような場合に1つのディレクトリの階層を「パッケージ」とし、アクセス範囲を限定できるのがeslint-plugin-import-accessです。
このESLint pluginが提供する"import-access/jsdoc"
ルールを使用すると、JSDocの@package
アノテーションが使用できるようになります。@package
アノテーションが指定された変数は同じディレクトリの階層でのみ使用できるようになります。
例えば、READMEより引用した次のイラストでは、@package
の挙動を表しています。sub/foo.ts
で定義された@package
アノテーションが付与された変数foo
は、同じ階層のsub/bar.ts
からは参照でき、上の階層のmain.ts
から参照するとエラーとなっています。
eslint-plugin-import-accessの詳細は、作者の @uhyo さんが書かれた記事をご覧ください。
defaultImportability: "package"
とは
5月、eslint-plugin-import-accessにdefaultImportability: "package"
というオプションが追加されました。
"import-access/jsdoc"
ルールのデフォルトではなにもアノテーションが付与されていない変数は制限なしで、@package
をつけたときのみimportを制限できました。このオプションを有効にすると、アノテーションが付与されていない変数に制限がかかり、制限を無くすためには@public
を付ける必要があります。
これによって @package
の付け忘れが原因の誤importを防ぐことができます。@public
の付け忘れは、importを書いたときのエラーで検知できますね。
これは"import-access/jsdoc"
ルールの挙動を変更する機能であるため、有効にすると大規模な変更が必要になる可能性があります。
eslint-plugin-import-accessを利用したディレクトリ設計
ここでは"import-access/jsdoc"
ルールとdefaultImportability: "package"
を使用したNext.jsプロジェクトの設計の例を提案します。この設計はわりと大きなプロジェクトでワークしています。特にパスの階層に関連するファイルをコロケーションするApp Routerで便利です(もちろんPages Routerでも便利に使えます)。
features
features
ディレクトリは、プロジェクトルートやsrc
直下のみにあり、各機能ごとのコンポーネントや、ドメイン知識に依存した関数やコンポーネントなどを入れるディレクトリです。Webフロントエンド界隈ではかなり広まったような気がします。元ネタは多分alan2207/bulletproof-reactです。
それぞれのfeatureディレクトリでは、直下のindex.ts
内で@public
アノテーションを付与しつつre-exportしています。index.ts
を各ディレクトリの窓口にしている感じですね。
export const Foo = 1;
/** @public */
export { Foo } from "./foo";
components
components
ディレクトリは、Reactコンポーネントを入れるディレクトリです。プロジェクトルートやsrc
直下のほか、各featureディレクトリ直下や、App Routerにおいては各パス階層に置きます。
Foo
、Bar
という複数のコンポーネントで、共通化したいが直接使われてほしくないUIを Baz
というコンポーネントに切り出すことを考えます。
export const Baz: FC = () => <></>
export const Foo: FC = () => <Baz />
export const Bar: FC = () => <Baz />
この場合、Foo
とBar
のindex.ts
のみに@public
を付与し、Baz
に付けないことでBaz
の直接の使用を防げます。
export { Baz } from "./Baz";
/** @public */
export { Foo } from "./Foo";
/** @public */
export { Bar } from "./Bar";
utils
など
それ以外のそれ以外では、適宜変数や関数に直接 @public
を付与します。
@public
アノテーションは、エディタ上でのJSDoc表示時に説明の邪魔にならないよう、JSDocの最後に書くのがおすすめです。
例えば、表示できるReact Nodeかどうかを判定するisRenderableReactNode関数は次のようになります。
import { type ReactNode } from 'react';
import * as R from 'remeda';
type RenderableReactNode = Exclude<ReactNode, null | undefined | boolean>;
/**
* 表示されない React node (`true`, `false`, `null`, `undefined`) の時に `false` を、それ以外は `true` を返す
*
* @see https://zenn.dev/ygkn/articles/optional-react-node-prop
* @see https://react.dev/reference/react/isValidElement#react-elements-vs-react-nodes:~:text=true%2C%20false%2C%20null%2C%20or%20undefined%20(which%20are%20not%20displayed)
*
* @public
*/
export const isRenderableReactNode = (node: ReactNode): node is RenderableReactNode =>
R.isDefined(node) && !R.isBoolean(node);
再export時の注意
import-access/jsdoc
ルールでは、index.ts
ファイルを使用することで、1つ親の階層でも@package
が付与された(または defaultImportability: "package"
にて@public
が付いていない)変数を使用できます。(この挙動はindexLoophole
オプションがfalse
の場合、無効にできます)そのため、index.ts
にて再exportする上記の設計方法にメリットがあります。
しかし、このような再exportは積み重なるとビルド時間やバンドルサイズなど、パフォーマンスに影響が出るという結果があります。
先述の設計はこのことを知る前に作られたものであるため考慮していませんが、パフォーマンスに無視できない影響が出たら設計を見直す可能性があります。
例えば、次のような変更が考えられます。
- featuresでは、
index.ts
を通さず直接変数や関数に@public
をつける - componentsでは、コンポーネントごとにフォルダを作成せず、VS CodeのExplorer file nestingなど、エディタの設定を利用してまとめて表示させる
まとめ
この記事では、"import-access/jsdoc"
ルールとdefaultImportability: "package"
を使用したNext.jsプロジェクトの設計の例を提案しました。
この設計で、importの制御をかなりやりやすくなりました。eslint-plugin-import-accessを開発されたuhyoさんには感謝でいっぱいです。
eslint-plugin-import-accessを使用した設計は情報がまだ少なく、この設計も手探りでできたものです。もし別の方法があれば、情報をお待ちしております。
Discussion