🐶

【TypeScript×React】import時に絶対パスで参照したい

2023/05/26に公開

はじめに

別の投稿でフォルダ構成について書きましたが、階層が深いとimport文が

import * as Method from `../../../../../../../../../method`;

のようになる恐れがあります。このコードを深夜に書くと確実にバグを生み出します。
よって、これを

import * as Method from `@method`;

のように書きたいな、という話です。

開発環境

Windows 10
Node.js 18.15.0
TypeScript 4.9.5
React 18.2.0
Visual Studio 2022
crate-react-app 5.0.1
react-app-rewired 2.2.1

TypeScriptでのimportの書き方

以降、以下のようなフォルダ構成での話になります。

─ node_modules
─ src
 ├ utils
 │ ├ methods1.ts  // 関数 method1
 │ └ methods2.ts  // 関数 method2
 └ features
  ├ App
  │ ├ hooks
  │ │ └ app.ts  // 関数 hookApp
  │ └ types
  │   └ app.d.ts // 型 typeApp
  ├ FeatureA
  │ ├ compenents
  │ │ └ children
  │ │   ├ childA1.tsx
  │ │   ├ childA2.tsx
  │ │   └ ・・・ childAn.tsx まである
  │ └ hooks
  │   └ featureA.ts // 関数 hookFeatureA
  └ FeatureB
    ├ compenents
    │ └ featureB.tsx
    └ hooks
      └ featureB.ts // 関数 hookFeatureB

外部ライブラリ

node_module以下にある外部ライブラリのモジュールをインポートするとき、以下のように記述します。

import * as React from 'react'; // すべてのモジュールをインポート
import { useEffect } from 'react'; // 個別のモジュールをインポート

fromの後ろには、本来モジュールの格納場所を書くんですが、外部ライブラリの場合は上記のようなシンプルな書き方ができます。
上記の例ではreactと書いていますが、実際はnode_module/@types/react/index.d.tsが参照されています。仕組みはあまり理解できていませんが、node_module以下のモジュールは、モジュール名をそのまま書くことで参照できるようです。

独自モジュール

上記のフォルダ構成においてchildA1.tsxからfeatureA.tsをインポートする場合は以下のように書きます。

childA1.tsx
import { hookFeatureA } from '../../hooks/featureA.ts';

同様にapp.tsapp.d.tsmethods1.tsmethods2.tsも追加でインポートしてみましょう。

childA1.tsx
import { hookFeatureA } from '../../hooks/featureA.ts';
import { hookApp } from '../../../App/hooks/app.ts';
import { typeApp } from '../../../App/types/app.d.ts';
import { method1 } from '../../../../utils/methods1.ts';
import { method2 } from '../../../../utils/methods2.ts';

何とか書けました。
このコードを書ききった時点で今日の仕事は終わりの気分ですが、続けてfeatureB.tsxから同じモジュールをインポートするコードも書きます。

featureB.tsx
import { hookFeatureA } from '../../FeatureA/hooks/featureA.ts';
import { hookApp } from '../../App/hooks/app.ts';
import { typeApp } from '../../App/types/app.d.ts';
import { method1 } from '../../../utils/methods1.ts';
import { method2 } from '../../../utils/methods2.ts';

同じファイルを参照しているのにfromの右側の内容が違います。これは、参照元と参照先の相対位置が違うためです。
例えばここで、参照先のファイルの格納場所を変える仕様変更が発生したとしましょう。
当然エラーが発生しますが、どういった相対パスに修正すればエラーが消えるのか、フォルダ構成図とにらめっこしながら各ファイルの階層を意識して変更する必要が出てきます。
苦痛です。苦痛から解放されたいです。

第1段階:絶対パスみたいに書きたい

上記の2つの例ではどちらも、Apputilsフォルダにある関数をインポートしています。
このように、ほとんどのコードでインポートが必要となる共通モジュールのようなものがあると思います。
これらのインポートを同じような書き方で統一できればキレイですよね。
例えばこんな風に。

childA1.tsx
import { hookFeatureA } from '../../hooks/featureA.ts';
import { hookApp } from '@app/hooks/app.ts';
import { typeApp } from '@app/types/app.d.ts';
import { method1 } from '@utils/methods1.ts';
import { method2 } from '@utils/methods2.ts';
featureB.tsx
import { hookFeatureA } from '../../FeatureA/hooks/featureA.ts';
import { hookApp } from '@app/hooks/app.ts';
import { typeApp } from '@app/types/app.d.ts';
import { method1 } from '@utils/methods1.ts';
import { method2 } from '@utils/methods2.ts';

下4つのインポートは、どちらも同じコードになっています。ステキ。
この書き方ができるようになるためには、以下の操作が必要となります。

  1. まずは'tsconfig.json'を修正します。
tsconfig.json
{
+  "extends": "./tsconfig.paths.json",
  "compilerOptions": {
    ・・・
  },
+  "include": [
+    "src"
+  ]
}
  1. 次に、同じ場所にtsconfig.paths.jsonを新規作成し、以下のような内容とします。
tsconfig.paths.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@app/*": [ "./features/App/*" ],
      "@utils/*": [ "./utils/*" ]
    }
  }
}

"src"を起点とし、そこからの相対パスを@xxxという変数で表現する」といった意味になります。

create-react-appの場合

create-react-appで実行している場合、上記の対応だけではうまくいかないはずです。
create-react-appでは、"compilerOptions"に書いた独自の内容が、コンパイル時に初期状態にリセットされる挙動があるとか。
https://chaika.hatenablog.com/entry/2021/07/22/083000
なので、もう少し対策します。

  1. react-app-rewiredというライブラリをインストールします。
npm i react-app-rewired
  1. package.jsonを書き換えます。
package.json
 ・・・
  "scripts": {   
-    "start": "react-scripts start",
+    "start": "react-app-rewired start",
-    "build": "react-scripts build",
+    "build": "react-app-rewired build",
+    "test": "react-app-rewired test",
-    "test": "react-scripts test",
+    "eject": "react-app-rewired eject"
-    "eject": "react-scripts eject"
  },
  ・・・
  1. 最後に、srcと同じ階層にconfig-overrides.jsを新規作成し、以下のような内容とします。
config-overrides.js
const path = require('path');

module.exports = (config) => {
    config.resolve = {
        ...config.resolve,
        alias: {
            ...config.alias,
            '@app': path.resolve(__dirname, './src/features/App/'),
            '@utils': path.resolve(__dirname, './src/utils/')
        },
        extensions: ['.js', '.ts', '.d.ts', '.tsx']
    };

    return config;
};

ちょっとここら辺の詳しい仕組みは理解し切れていませんが、いろいろやった結果 私の環境ではこれでtsconfig.paths.json"paths"がしっかり反応するようになりました。

第2段階:まとめて書きたい

欲が出てくると、次に以下のようにまとめたい気持ちになってきます。

childA1.tsx
import { hookFeatureA } from '../../hooks/featureA.ts';
import { hookApp, typeApp } from '@app';
import { method1, method2 } from '@utils';
featureB.tsx
import { hookFeatureA } from '../../FeatureA/hooks/featureA.ts';
import { hookApp, typeApp } from '@app';
import { method1, method2 } from '@utils';

異なるファイルに書かれた関数を、1つのインポートで済ませる方法です。

  1. まずは、先ほど作成したtsconfig.paths.jsonを以下のように変更します。
tsconfig.paths.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
-      "@app/*": [ "./features/App/*" ],
+      "@app": [ "./features/App/" ],
-      "@utils/*": [ "./utils/*" ]
+      "@utils": [ "./utils/" ]
    }
  }
}
  1. 次に、features/Apputilsフォルダ直下にそれぞれindex.d.tsを新規作成し、以下のような内容とします。
features/App/index.d.ts
export * from './hooks/app.ts';
export * from './types/app.d.ts';
utils/index.d.ts
export * from './methods1.ts';
export * from './methods2.ts';

こうすることで、
@app
./features/App/
→ ファイルの指定が無ければデフォルトでindexというファイルを参照する&config-overrides.js"extensions".d.tsの拡張子を対象としている
./features/App/index.d.ts
exportで指定された各ファイルをすべて参照できる
という仕組みで、一度に複数ファイルの関数を参照できるようになります。

第3段階:やりすぎない

今回例に挙げたフォルダ構成は、冒頭に紹介した記事からいくつか抜粋したものになります。
実際は、もっとたくさんのフォルダがあり、階層があり、相互参照が発生しています。
では、すべて絶対参照形式で書けば楽になるか?というと、必ずしもそうはなりません。
例えば、ここまでの方法で"@features/*": [ "./features/*" ]を定義したとしましょう。

childA1.tsx
-import { hookFeatureA } from '../../hooks/featureA.ts';
+import { hookFeatureA } from '@features/FeatureA/hooks/featureA.ts';
import { hookApp, typeApp } from '@app';
import { method1, method2 } from '@utils';

確かに見やすくはなりましたが、長いし、同じFeatureA内のモジュールを参照したいだけなのにパスに"FeatureA"と書かなければならないことに少し違和感を感じます(個人的な感想です)。
よって、結論として、

  • 同じ機能内であれば相対パスでインポートする。
  • 外にあるモジュールは絶対パスでインポートする。

のルールで統一しようと思います。


以上です。

P.S.
この投稿日、41歳の誕生日です。
いつまで技術職ができるか不安ですが、これができないと管理職か営業職なので、がんばろうと思います。

Discussion