🌐

CRA + Module Federation + TypeScriptの設定

2023/04/21に公開

最近、React + TypeScriptでプラグインベースのWebアプリケーションを作成していて、プラグインの仕組みにModule Federationが使えないかを試していた。ひとまず、動作する設定にたどり着いたので、要点をメモとして残しておく。

前提条件

この記事では、ReactアプリケーションはCRAを使って実装する。Module Federationを設定するにはWebpackの設定をする必要があるが、CRAをejectしたりせずにCRACOを使って設定を行う。

サンプルコード

実際のサンプルコードはこちら。
https://github.com/tadayosi/federated-app

プロジェクトの生成

まずCRAを使ってプロジェクトを生成し、それからreact-scriptsをCRACOに置き換えるところまでは省略。以下の公式ドキュメントを参照。

Webpackの設定

Webpackにはこんな感じで、ホスト側とリモート側の両方にModuleFederationPluginを設定していく。

ホスト側

craco.config.js
const { ModuleFederationPlugin } = require('webpack').container
const { dependencies } = require('./package.json')

module.exports = {
  webpack: {
    plugins: {
      add: [
        new ModuleFederationPlugin({
	  name: 'host',
          remotes: {
            remote1: 'remote1@http://localhost:3001/remoteEntry.js',
          },
          shared: {
            ...dependencies,
            react: {
              singleton: true,
              requiredVersion: dependencies['react'],
            },
            'react-dom': {
              singleton: true,
              requiredVersion: dependencies['react-dom'],
            },
          },
        }),
      ],
    },
  },
}

リモート側

craco.config.js
const { ModuleFederationPlugin } = require('webpack').container
const { dependencies } = require('./package.json')

module.exports = {
  webpack: {
    plugins: {
      add: [
        new ModuleFederationPlugin({
	  name: 'remote1',
          filename: 'remoteEntry.js',
          exposes: {
            './component1': './src/component1',
          },
          shared: {
            ...dependencies,
            react: {
              singleton: true,
              requiredVersion: dependencies['react'],
            },
            'react-dom': {
              singleton: true,
              requiredVersion: dependencies['react-dom'],
            },
          },
        }),
      ],
    },
  },
}

ホスト側remotesの設定

間違える人は少ないと思うが自分は最初にここでハマったので書いておくと、remotesエントリーのremote1@...の部分はリモート側のModuleFederationPlugin設定でnameに指定した名前を設定する。

craco.config.js
remotes: {
  remote1: 'remote1@http://localhost:3001/remoteEntry.js',
},

ここを間違えて、サンプルから取ってきた名前のままにしておくと当然ながら動かない。

キー側のremote1は、ホスト側のソースでインポートするときの名前になる。

import { Component } from 'remote1/component1'

output.publicPath = 'auto'

Webpackでもう1つ重要な設定は、outputpublicPathautoに設定すること。これを設定しないとチャンクされたCSSが読み込まれない。

craco.config.js
module.exports = {
  webpack: {
    // ...省略...
    configure: {
      output: {
        publicPath: 'auto',
      },
    },

Webpackではデフォルトがautoのはずなので、これはCRACOを使う場合限定の話かもしれない。

ソースコードの修正

index.tsとbootstrap.tsxを分ける

Module Federationを使う上で、とにかく一番重要なのはこの変更。これを忘れていると、画面に何も表示されず、Webコンソールに以下のエラーが出る問題に悩まされることになる。

Uncaught Error: Shared module is not available for eager consumption: ...

これを解決するには、以下のようなindex.tsxをそのままbootstrap.tsxにリネームする。

index.tsx → bootstrap.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

代わりに、新たに以下のようなindex.tsを作ってそこからbootstrap.tsxを読み込むようにする。

index.ts
import('./bootstrap')

export {}

Module Federationではホストとリモートで共通するライブラリのインスタンス共有化などの処理を行うので、モジュールのインポートを非同期に行う必要があるため。

https://webpack.js.org/concepts/module-federation/#uncaught-error-shared-module-is-not-available-for-eager-consumption

逆に、もしこれでホスト/リモートいずれかのCSSの読み込みなどに問題があった場合は、bootstrap.tsxでCSSをインポートするのを止めてindex.tsから直接インポートすることで解決することがある。

declare module '<remote>/*'

先程remotesの設定で、キー側はソースでインポートするときの名前になると書いたが、TypeScriptではそのままではModule Federationで読み込むリモートの型定義を認識できない。

import { Component } from 'remote1/component1'

そのため、コンパイル時にこのようなエラーが出る。

TS2307: Cannot find module 'remote1/component1' or its corresponding type declarations.
    1 | import React from 'react'
  > 2 | import { Plugin } from 'remote1/component1'
      |                        ^^^^^^^^^^^^^^^^^^^^

これを回避するには、以下のような.d.tsファイルを用意する。

declare.d.ts
declare module 'remote1/*'

https://stackoverflow.com/questions/71311933/microfrontend-cannot-find-module-error-for-module-federation

動的リモートローディング

Module Federationは外部モジュールを動的に読み込めるための仕組みだが、ドキュメントやサンプルを読む限りだとリモートのエントリーポイントを直接Webpackの設定やindex.htmlに埋め込んだりしているものばかりで、設定時に静的にリモートを決定している例しか見当たらない。

しかし、Module Federationはマイクロフロントエンドを実現するための仕組みなので、本来読み込み先のリモートモジュールを動的に構成できないといけない。

幸い、そのような動的リモートローディングはModule Federationのユーティリティパッケージ@module-federation/utilitiesを使って簡単に実現できる。

https://www.npmjs.com/package/@module-federation/utilities

使い方はNPMパッケージのREADMEにもあるように、以下のようにimportRemoteをインポートして使う。importRemoteに渡すパラメータは動的に変えられるので、どこかから読み込むリモートのセットを動的に渡してあげればいい。

import { importRemote } from '@module-federation/utilities'

importRemote({
  url: 'http://localhost:3001',
  scope: 'remote1',
  module: './component1',
}).then(component => {
    ...
})

@module-federation/utilitiesを使った動的リモートローディングのやり方は、公式のサンプルにも見つかる。[1]

https://github.com/module-federation/module-federation-examples/tree/master/dynamic-system-host

参考資料

最後に、Module Federationを使っていて分からないことがあったら、とりあえずこのプロジェクトのサンプルを漁ってみるとだいたい答えが見つかる。

https://github.com/module-federation/module-federation-examples

脚注
  1. この公式サンプルを@module-federation/utilitiesを使うようにプルリク投げたのは自分 😛 ↩︎

Discussion