BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Multi-root Workspacesで、React monorepoプロジェクトのautoimportをいい感じに動作するようにする

この記事はBASE Advent Calendar 2020 15日目の記事です。 devblog.thebase.in

こんにちは、Native Application Groupの大木です。最近React.jsを使ったフロントエンドアプリケーションの開発に取り組んでいますが、プロジェクトをmonorepoで管理しています。 今回は、monorepo管理にしたはいいが、Visual Studio Codeエディター(以下vscode)で、TypeScriptのモジュールのautoimportのパス解決に悩まされてやったことを、Next.jsというReact Frameworkを使った例でご紹介します。

Monorepo?

monorepoは、次のうち1つまたは複数当てはまる場合に採用するのが効果的かと思います。

  • Web/ネイティブなど複数プラットフォームのアプリケーションが存在
  • メイン/管理画面など同じデータソースにアクセスする別々の役割を持つ複数のアプリケーションが存在
  • それらのアプリの実行環境に依存せず同じように利用する機能が存在
    • API Clientや共通UIコンポーネントなど特定アプリの環境に依存しない独立した共有する機能

最後の実行環境に依存せず同じように利用する機能というのは、過去に書いた ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録 で、React Nativeの複数の実行環境を共存する試みで紹介しました。

さて、フロントエンドアプリケーション開発で適切に機能分割したパッケージを、manyrepoなど機能ごとにリポジトリが分割されたものではなく、monorepoで管理する利点はなんでしょうか? Guide to Monorepos for Front-end Codeという記事によると、次の5つが挙げられています。

  1. 全ての設定とテストを一つの場所で
  2. 一連の関連するコミットをまとめてグローバルに機能を簡単にリファクタリング
  3. 簡略化されたパッケージのバブリッシング
  4. より簡単な依存関係管理
  5. 分離された状態を維持したまま、共有パッケージのコードを再利用可能

1.は運用の話で、パッケージごとにCIやテストの構成を一つにしておけば、それらの設定を追加する必要はなく、すぐに起動することが可能となります。 また、4.と5.の話の意味するところは、依存関係を適切に保てるし、新たにパッケージをmonorepo内に追加しても、共有パッケージを参照することが簡単になるということです。

Multi-root Workspace

vscodeのMulti-root Workspaces は、複数のプロジェクトフォルダーをまとめて操作できる機能です。 記事の解説によると、manyrepoのような機能ごとにリポジトリが分割されたものに対して有効な機能であるという認識をもちますが、今回は次のようなフォルダー構成を持つmonorepoに適用しました。

.
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .prettierrc
├── .stylelintrc
├── .vscode/
│   └── settings.json
├── README.md
├── multi-root-ts-monorepo.code-workspace
├── package.json
├── packages/
│   ├── admin/
│   │   ├── .vscode/
│   │   │   └── settings.json
│   │   ├── next-env.d.ts
│   │   ├── next.config.js
│   │   ├── package.json
│   │   ├── pages/
│   │   │   ├── _error.tsx
│   │   │   └── index.tsx
│   │   └── tsconfig.json
│   ├── storybook/
│   │   ├── .storybook/
│   │   │   ├── addons.js
│   │   │   ├── config.js
│   │   │   └── preview-head.html
│   │   ├── package.json
│   │   └── stories/
│   │       └── Button.stories.js
│   ├── ui/
│   │   ├── dist/
│   │   │   ├── components/
│   │   │   ├── constants/
│   │   │   ├── hooks/
│   │   │   ├── index.d.ts
│   │   │   └── index.js
│   │   ├── package.json
│   │   ├── src/
│   │   │   ├── components/
│   │   │   ├── constants/
│   │   │   ├── hooks/
│   │   │   └── index.ts
│   │   └── tsconfig.json
│   └── web/
│       ├── .vscode/
│       │   └── settings.json
│       ├── next-env.d.ts
│       ├── next.config.js
│       ├── package.json
│       ├── pages/
│       │   ├── _error.tsx
│       │   └── index.tsx
│       └── tsconfig.json
└── yarn.lock

26 directories, 37 files

monorepo内には、4つのパッケージが存在します。

- admin: 管理画面のNextアプリケーション
- storybook: 共有UIコンポーネントに対するStorybook
- ui: Nextアプリケーションで利用する共有UIに関するコードを管理するパッケージ
- web: 本体のNextアプリケーション

これら一つ一つをworkspaceに追加すると、エディターのファイルエクスプローラーでは次のように見えます。

なぜ、monorepoにこの機能を適用するの? という話ですが、monorepoプロジェクト全体のvscodeの設定(workspace設定)の他に、それぞれのフォルダごとにvscodeの設定をカスタマイズできるようになります。それによってvscodeの機能を利用するのにいくつか利点があり、特にvscodeのautoimportに関して助かったので、紹介していきます。

vscodeのautoimport

vscodeでTypeScript/JavaScriptファイルにコードを記述していると、次のようなサジェストが表示されることがあります。

サジェストを適用すると、下記のように参照先を自動でimportしてくれます。

import * as React from 'react';
// **↓自動で挿入**
import { SIZES } from '../../constants';

export const useSize = (size: keyof typeof SIZES) => {
  const [value, setValue] = React.useState<number>();

  React.useEffect(() => {
    setValue(SIZES[size]);
  }, []);

  return value;
};

この自動インポートなのですが、vscodeの設定ファイルに次の設定を追加することにより、サジェストされるパスに変化が生まれることになります。

"typescript.preferences.importModuleSpecifier": "auto"

設定値は全部で3つありそれぞれ下記の通りです。

設定値 説明
auto インポート パス スタイルを自動的に選択します。
non-relative jsconfig.json / tsconfig.json で構成されている baseUrl に基づきます。
relative ファイルの場所を基準にします。

上のコード例は、アプリケーションからライブラリのように利用する共有パッケージの実装コードでしたが、 Nextアプリケーションのパッケージではどのようにこの機能を利用するのが良いでしょう?

アプリケーションパッケージの例

アプリケーションでは複数のパッケージを組み合わせて多くのコードを実装するため、フォルダー階層が深くなりがちです。そのため相対パスでimportするという設定だと、import文が長くなる可能性があり好ましくありません。また、リファクタリングの際にソースファイルの配置を頻繁に移動する可能性もあり、階層がズレるとimport文にある相対パスの ../ を増やしたり減らしたりする作業が発生します。 それに対する解決案の一つとしては、モジュールバンドラーとtsconfig.jsonpaths を使うというものです。

Nextでは、Webpackというモジュールバンドラーを使っているため、Webpackの機能 resolve.aliasを利用して、パスのaliasを作成することができ、指定するimportパスを柔軟に変更することができます。

const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules');

module.exports = withPlugins(
  [
    withTM(['@multi-root-ts-monorepo/ui']),
  ],
  {
    reactStrictMode: true,
    webpack(config, options) {
      // [...]
      config.resolve.alias['~web/i18nextConfig'] = path.join(
        __dirname,
        'i18next.config.js'
      );
      config.resolve.alias['~web/defaultConfig'] = path.join(
        __dirname,
        'default-config.js'
      );
      config.resolve.alias['~web'] = path.join(__dirname, 'src');

      return config;
    }
  }
);

次に、このエイリアスをTypeScriptで使えるように tsconfig.jsonpaths にも定義するようにします。参照先のルートが必要なため、 baseUrl も併せて指定します。

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "target": "es5",
    "lib": ["es2016", "dom", "dom.iterable", "esnext", "webworker"],
    "noEmit": true,
    "jsx": "preserve",
    "baseUrl": ".",
    "paths": {
      "~web/i18nextConfig": ["i18next.config.js"],
      "~web/defaultConfig": ["default-config.js"],
      "~web/*": ["src/*"]
    }
  },
  "exclude": ["node_modules"],
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    "../../@types/worker-loader/index.d.ts"
  ]
}

このエイリアスを利用することによって、相対パスやソースコードを格納したフォルダーの外のパッケージルートにあるi18next.config.js のようなファイルの実際のパスを、隠蔽することが可能となるわけです。

// 相対パスの代わりにエイリアスを使う例
import { FC, FormHTMLAttributes } from 'react';
import { FormProvider, UseFormMethods } from 'react-hook-form';

//import { FormDevTool } from "../../lib/react-hook-form/devtool";  <- この代わりにエイリアスを使う
import FormDevTool from '~web/lib/react-hook-form/devtool';

type FormProps = {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    methods: UseFormMethods<any>;
};
type Props = FormHTMLAttributes<HTMLFormElement> & FormProps;

const Form: FC<Readonly<Props>> = ({
    id,
    methods,
    children,
    onSubmit,
    className,
}) => (
    <FormProvider {...methods}>
        <FormDevTool control={methods.control} />
        <form id={id} className={className} onSubmit={onSubmit}>
            {children}
        </form>
    </FormProvider>
);
export default Form;
// `src/` フォルダーを跨いだ位置にあるファイルのimportで `src/`を隠蔽する例
import { useTranslation } from '~web/i18nextConfig';

import Editor from './editor';

type Props = {
    editorName: EditorName;
    onDismiss?: () => void;
};

const CheckoutEditModal: FC<Props> = ({
    editorName,
    onDismiss,
}) => {
    useKeydown('Escape', onDismiss);

    const { t } = useTranslation();

vscodeの設定

さて、アプリケーションパッケージにおいて、柔軟にimportパスを設定できることは説明しましたが、結局vscodeの設定についてあまり触れておりませんでした。 フロントエンドのmonorepoプロジェクト構成で、パッケージのimportがどう動いて欲しいのかをまとめますと、次の通りです。

1. monorepo内の他のパッケージをimportする際には、npmの他のライブラリと同じく、 package.json に定義した name でimportパスが解決される

{
  "name": "@multi-root-ts-monorepo/ui",
// [...]
}
// 参照するパッケージのnameで解決
import { Button } from '@multi-root-ts-monorepo/ui';
import * as React from 'react';

const App: React.FC = () => {
  return (
    <div>
      Hello, World!
      <Button onClick={() => null} m={['0', '0 1rem']} label="a test button">
        Test!
      </Button>
    </div>
  );
};

export default App;

2. 共有パッケージ内に属するソースファイル同士のimportは、相対パスでimportが解決される

import * as React from 'react';
// 同じパッケージ内のソースファイルの相対パスで解決
import { SIZES } from '../../constants';

export const useSize = (size: keyof typeof SIZES) => {
  const [value, setValue] = React.useState<number>();

  React.useEffect(() => {
    setValue(SIZES[size]);
  }, []);

  return value;
};

3. アプリケーションパッケージ内のソースファイル同士のimportは、webpackを利用するため、エイリアスでimportが解決される

import { FC, FormHTMLAttributes } from 'react';
import { FormProvider, UseFormMethods } from 'react-hook-form';

// 相対パスの代わりにエイリアスを使う
import FormDevTool from '~web/lib/react-hook-form/devtool';

type FormProps = {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    methods: UseFormMethods<any>;
};
type Props = FormHTMLAttributes<HTMLFormElement> & FormProps;

const Form: FC<Readonly<Props>> = ({
    id,
    methods,
    children,
    onSubmit,
    className,
}) => (
    <FormProvider {...methods}>
        <FormDevTool control={methods.control} />
        <form id={id} className={className} onSubmit={onSubmit}>
            {children}
        </form>
    </FormProvider>
);
export default Form;

1.に関しては特別な設定は必要ないため考えることはないのですが、2.と3.に関しては異なるimportパスを解決する必要があるため、全体と個別の設定を適切に定義する必要があります。 Multi-root Workspacesを利用すると、全体のworkspace設定と個別に追加したフォルダーごとの設定の2種類の設定を定義することができます。3.の場合、webpackというモジュールバンドラーのサポートが必要なことから、2.の方式を全体で設定、3.の方式を個別に設定するのが良さそうということで出来上がったのが次の設定です。

全体のworkspace設定(multi-root-ts-monorepo.code-workspace)

2.の方式を優先する設定を追加

{
  "folders": [
    {
      "name": "project-root",
      "path": "."
    },
    {
      "path": "packages/admin"
    },
    {
      "path": "packages/storybook"
    },
    {
      "path": "packages/ui"
    },
    {
      "path": "packages/web"
    }
  ],
  "settings": {
    "typescript.tsdk": "./node_modules/typescript/lib",
    // 相対パス方式
    "typescript.preferences.importModuleSpecifier": "relative",
    "typescript.preferences.importModuleSpecifierEnding": "minimal",
    "eslint.alwaysShowStatus": true,
    "eslint.packageManager": "yarn",
    "eslint.validate": [
      "javascript",
      "javascriptreact",
      "typescript",
      "typescriptreact"
    ],
    "editor.codeActionsOnSave": {
      "source.fixAll.eslint": true,
      "source.fixAll.stylelint": true
    }
  }
  }
}

アプリケーションパッケージでの個別設定(settings.json)

"non-relative" を指定すると、monorepo内の他パッケージのimportパスまでも、"baseUrl"からみた相対パスに変更されてしまうようなので、 "auto"を設定。 実際に試してみたところ、importしようとしているソースファイルが、親フォルダまでの位置にある場合は相対パスが選択され、それ以上に離れている場合は、エイリアス設定が優先されるようです。

{
  "typescript.preferences.importModuleSpecifier": "auto",
}

その他考慮すべきこと

はじめの方に紹介した Guide to Monorepos for Front-end Codeで出てきた「全ての設定とテストを一つの場所で」に関連する話です。

上の説明に従い、テスト実行環境やESLint環境は、パッケージが増えても追加設定しなくてもいいように、プロジェクトルートに用意していました。そのため、これらのツールからはWebpackで定義したエイリアスなどを認識できるように追加設定が必要となります。

テストやESLintのために必要な追加設定

Webpackで定義したエイリアス設定が、プロジェクトルートからみたら何処にあたるのか認識できるようにするようにする必要があります。

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~web/i18nextConfig": ["packages/web/i18next.config.js"],
      "~web/defaultConfig": ["packages/web/default-config.js"],
      "~web/*": ["packages/web/src/*"]
      // [...]
    }
  },
  "exclude": ["**/dist", "**/build", "node_modules"]
}

まとめ

アプリケーションの開発に必ずしも必要ではないが、開発効率に大きく貢献する機能が提供されていることを知ることは重要だなと改めて思いました。また良さそうな機能を見つけたら紹介していきたいと思っております。

明日は、フロントエンドチームの田中さんです!お楽しみに!

参考