技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

webpack@5で入るModule Federationについて

Module Federation(以下 mfe)はwebpack@5から入る新しい仕組みの一つです。

Proposal

github.com

目的

アプリケーションを作る時に、webpackはビルド時のソースコードは使う前提で実行するので、様々な最適化を行うことができます。
もし、node_modules経由以外でライブラリを使うという場合はscriptタグやESMから取得するというのが一般的です。

whatwg.github.io

しかし、この場合だと問題点が出てきます。それはライブラリの重複問題です。
取得するライブラリはすでにbundle済みなため、その中には同じライブラリが存在することが多いです。
ユーザーは重複するライブラリ(e.g. react, react-dom, etc.)の取得を行う可能性が高いため、その最適化を行うのがModule Federationです。

f:id:about_hiroppy:20200507073925p:plain

上記の図だと、main.jsで使っている緑のライブラリはlib-aでもlib-bでも使っていて、それを3つダウンロードするのは無駄なのでmain.jsがダウンロードしている緑にlib-a/bが依存すればネットワークの最適化ができます。

DLLPluginやexternalsでも同様のことはできますがそれらは貧弱なので今後はこちらに乗り換えることが可能なケースがあります。

この記事では内部は深く触れないため、内部アルゴリズムが知りたい人はこちら

github.com

コード: containers

この仕組みはMicro Frontendsのためだけにあるわけではありませんが、 一番機能する可能性が高いとは思います。

Micro Frontendsとはなにか?

micro-frontends.org

用語

  • ローカル(モジュール) => 使う側
    • ビルドラインに入っている通常モジュール
  • リモート(モジュール) => 使われる側
    • 実行時にコンテナーから呼び出されるモジュール
  • コンテナ => ローカル、リモートにそれぞれいるマネージャー
    • モジュールの前段にいるもの、実際にはリモートのURLではなくリモートのコンテナURLって言ったほうが正しい
    • ここでモジュールの公開をするかどうかを制御する
    • コンテナ間での循環的な依存が可能で、このコンテナが上書きAPI(__webpack_override__)を提供する
      • 兄弟関係でのみ上書きは可能で、単一方向の操作
    • コンテナは以下の処理を行います
      • 非同期チャンクの読み込み
      • そのチャンクの評価

注意

リモートモジュールは、非同期チャンクとなりチャンクロードの処理が必要なため基本的には、import()が使われますが、require()require.ensure()にも使用可能です。
top-levelでのimportもビルドはできますが、同期チャンクとなってしまうため実行時にはエラーとなります。
つまり、この仕組みはbundle時にまだ不明なプログラムを許容するため、実行時に初めてエラーがわかります。

そして、ローカルもリモートになることが可能なため、各チャンクは立ち位置がその場の状況によって変化します。
ローカルと決めてたチャンクにexposesを設定すればそれがリモートチャンクとなるということです。

f:id:about_hiroppy:20200507072616p:plain

この場合だと、左のノードがその例で、/subへアクセスした場合はLocalという扱いになりますが、/へアクセスすると左のノードはリモートという扱いになります。(下のノードから見てリモート)

設定とコード

先にコードを見ていきましょう。この構成では、リモートのファイルをローカルが取るシンプルな例です。

リポジトリ

github.com

リモート

// 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は以下のようになります。

f:id:about_hiroppy:20200506163315p:plain

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>;のみとなります。

f:id:about_hiroppy:20200506163145p:plain

では、リモート側にはshared: ['react']を付け、ローカル側をなくすとどうなるでしょう?
最初に説明したとおり、ローカル側がsharedを設定してなかった場合は、リモート側が自身のURLからReactライブラリをダウンロードするようにフォールバックが行われます。

f:id:about_hiroppy:20200506163933p:plain

この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.typevarからcommonjs-moduleに変えてみて。

さいごに

ユーザーはこのsharedさえ知っていればよくてcontainersとかは基本的には知らなくていいです。
まだ安定的なフェーズではなく実験的なのでインターフェイスの変更には注意してください。
なにか聞きたいことあれば、twitterまで。