webpack@5で入るModule Federationについて
💁♀️ ブログが移管されたので,新しい方へ移動します。
Module Federation(以下 mfe)はwebpack@5から入る新しい仕組みの一つです。
Proposal
目的
アプリケーションを作る時に、webpackはビルド時のソースコードは使う前提で実行するので、様々な最適化を行うことができます。
もし、node_modules経由以外でライブラリを使うという場合はscriptタグやESMから取得するというのが一般的です。
しかし、この場合だと問題点が出てきます。それはライブラリの重複問題です。
取得するライブラリはすでにbundle済みなため、その中には同じライブラリが存在することが多いです。
ユーザーは重複するライブラリ(e.g. react, react-dom, etc.)の取得を行う可能性が高いため、その最適化を行うのがModule Federationです。
上記の図だと、main.jsで使っている緑のライブラリはlib-aでもlib-bでも使っていて、それを3つダウンロードするのは無駄なのでmain.jsがダウンロードしている緑にlib-a/bが依存すればネットワークの最適化ができます。
DLLPluginやexternals
でも同様のことはできますがそれらは貧弱なので今後はこちらに乗り換えることが可能なケースがあります。
この記事では内部は深く触れないため、内部アルゴリズムが知りたい人はこちら
コード: containers
この仕組みはMicro Frontendsのためだけにあるわけではありませんが、 一番機能する可能性が高いとは思います。
Micro Frontendsとはなにか?
用語
- ローカル(モジュール) => 使う側
- ビルドラインに入っている通常モジュール
- リモート(モジュール) => 使われる側
- 実行時にコンテナーから呼び出されるモジュール
- コンテナ => ローカル、リモートにそれぞれいるマネージャー
- モジュールの前段にいるもの、実際にはリモートのURLではなくリモートのコンテナURLって言ったほうが正しい
- ここでモジュールの公開をするかどうかを制御する
- コンテナ間での循環的な依存が可能で、このコンテナが上書きAPI(
__webpack_override__
)を提供する- 兄弟関係でのみ上書きは可能で、単一方向の操作
- コンテナは以下の処理を行います
- 非同期チャンクの読み込み
- そのチャンクの評価
注意
リモートモジュールは、非同期チャンクとなりチャンクロードの処理が必要なため基本的には、import()
が使われますが、require()
やrequire.ensure()
にも使用可能です。
top-levelでのimportもビルドはできますが、同期チャンクとなってしまうため実行時にはエラーとなります。
つまり、この仕組みはbundle時にまだ不明なプログラムを許容するため、実行時に初めてエラーがわかります。
そして、ローカルもリモートになることが可能なため、各チャンクは立ち位置がその場の状況によって変化します。
ローカルと決めてたチャンクにexposesを設定すればそれがリモートチャンクとなるということです。
この場合だと、左のノードがその例で、/sub
へアクセスした場合はLocalという扱いになりますが、/
へアクセスすると左のノードはリモートという扱いになります。(下のノードから見てリモート)
設定とコード
先にコードを見ていきましょう。この構成では、リモートのファイルをローカルが取るシンプルな例です。
リモート
// webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; module.exports = { output: { // このリモートのファイルがローカルで展開されるときにpublicPathを書かないと親の実行時に親のURLを見てしまうので必須 // このPRで変更される: https://github.com/webpack/webpack/pull/10703 publicPath: 'http://localhost:8081/', }, plugins: [ new ModuleFederationPlugin({ name: 'page1', // エントリーの名前、exposesがある場合は必須キー library: { type: 'var', // scriptタグを経由する、他のオプションはこちら https://github.com/webpack/webpack/blob/dev-1/schemas/plugins/container/ModuleFederationPlugin.json#L155 name: 'page1' // importされるときの名前(この場合は、import('page1/xxxx')) }, filename: 'page1RemoteEntry.js', // 出力されるentryのファイル名 exposes: { Page: './src/index.js', // コンポーネント名(この場合は、import('page1/Page')) }, }), ], };
// src/index.js import React from 'react'; const Page1 = () => <h1>This is Page1</h1>; export default Page1; // React.lazyはdefault exportsのみ許容
Asset Size 579.js 7.27 KiB [emitted] 579.js.LICENSE.txt 295 bytes [emitted] main.js 7.68 KiB [emitted] [name: main] main.js.LICENSE.txt 295 bytes [compared for emit] page1RemoteEntry.js 2.09 KiB [emitted] [name: page1]
ローカル
// webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { plugins: [ new HtmlWebpackPlugin({ template: './index.html', }), new ModuleFederationPlugin({ remotes: { page1: 'page1', // page1をローカル側で使用することを伝える、import('page1/xxx') }, }), ], };
// src/index.js import React, { lazy, Suspense } from 'react'; import { render } from 'react-dom'; // page1/page(remote)をdynamic import const Page1 = lazy(() => import('page1/Page')); const Wrapper = () => ( <div> <Suspense fallback={<span>Loading...</span>}> <Page1 /> </Suspense> </div> ); render(<Wrapper />, document.getElementById('root'));
<!-- index.html --> <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <div id="root"></div> <!-- import('page1/page'))で読み込むためにremoteのファイルを取得 --> <script src="http://localhost:8081/page1RemoteEntry.js"></script> </body> </html>
Asset Size index.html 194 bytes [compared for emit] main.js 128 KiB [emitted] [name: main] main.js.LICENSE.txt 790 bytes [compared for emit]
このように、import('<scope>/<request>')
という形でローカル側は取り込みます。
依存関係共有
shared
というオプションをつけることにより、依存関係の共有を行い、リモートはローカルの依存関係を優先的に参照します。
もしローカルに依存関係がない場合、リモートは独自にダウンロードを行います。
この仕組みにより、バンドルを跨いだ関係でも最小限にファイル量を留めることができます。
上記のコードでは、このオプションを入れる前のNetworkは以下のようになります。
579.jsというリモートのJSに注目してください。このファイルはpage1RemoteEntry.jsから呼び出されます。
この579.jsにReactのライブラリソースコードが入っています。(main.jsの中にも同様のコードが入っている)
では、ローカルもリモートもReactを使っているので、両者のwebpack.config.jsに以下を追加します。
new ModuleFederationPlugin({ ..., shared: ['react'], ]
そうすると、以下のように579.jsのサイズが3.4kbから506bまで下がりました。
これは、Reactライブラリのソースコードがなくなり、579.jsがmain.jsに含まれているReactのライブラリコードを参照しているという状態です。
この579.jsに残っているコードはconst Page1 = () => <h1>This is Page1</h1>;
のみとなります。
では、リモート側にはshared: ['react']
を付け、ローカル側をなくすとどうなるでしょう?
最初に説明したとおり、ローカル側がshared
を設定してなかった場合は、リモート側が自身のURLからReactライブラリをダウンロードするようにフォールバックが行われます。
この466.jsがReactのライブラリコードとなります。
これらはコンテナであるpage1RemoteEntry.jsが管理していて、リモートに同一のライブラリがないのでこのコンテナが466.jsをダウンロードする処理を行います。
まとめると、リモートは共有されるであろうライブラリはshared
に入れておいた方が管理が楽だと思います。 そうすると使う側がそれを許容するかどうかを管理できるためです。
Q&A
Q: URLはHTMLに書く必要あるの?
A: html-webpack-pluginの対応を待つかwebpackでcontainerのurlが引けるためそれをhtmlにわたす実装を書く
Q: 共有ライブラリのバージョンが異なる場合どうすればいいの?
A: 標準ではないが、以下のような書き方はできる。
shared: { "react@6": "react" }
Q: ハッシュ付きファイル名の場合どうすればいいの?
A: webpack間でのオーケストレーションを維持するためハッシュ付きファイル名は出力されません
Q: ローカルとリモートで共通ライブラリを管理するのだるい
A: 今後自動的に入れる仕組みが入ると思う。3rd partyではすでに存在するらしい。ただ手で書いたほうがいい気はする
Q: IDEでの補完が効きません。
A: わかります、けどこれwebpack.config.jsをIDEが理解できるようにならないと解決しない
Q: SSRでも動くか?
A: 設計上、webに限定して作ってないので動く。library.type
をvar
からcommonjs-module
に変えてみて。
さいごに
ユーザーはこのsharedさえ知っていればよくてcontainersとかは基本的には知らなくていいです。
まだ安定的なフェーズではなく実験的なのでインターフェイスの変更には注意してください。
なにか聞きたいことあれば、twitterまで。