qiankun を使用したマイクロフロントエンドアプリケーションを webpack から Vite に移行した話
はじめに
こんにちは、READYFOR のテックリード兼フロントエンドエンジニアの菅原(@kotarella1110)です!
READYFOR はクラウドファンディングのプラットフォームで、実行者(プロジェクトを掲載するユーザー)向けの管理画面ではマイクロフロントエンドアーキテクチャを採用しています。このアーキテクチャでは、qiankun というマイクロフロントエンドフレームワークを使用して、六つのフロントエンドアプリケーションを統合して提供しています。
これらのフロントエンドアプリケーションは、元々 webpack を使用していましたが、最近 Vite に移行しました。そこで本記事では、その移行の経緯や qiankun を使用したマイクロフロントエンドの移行作業の詳細、移行結果等について紹介します。
また、本記事では qiankun でどのようにマイクロフロントエンドが統合されるかの知識が無いと理解しにくい部分があるため、以前に私が投稿した以下の記事も併せてご覧いただけると嬉しいです。
なぜ webpack から Vite に移行したのか?
webpack を使用していた際、以下のような問題があり、開発環境での動作速度が課題でした。
- 開発サーバーを起動してからアプリケーションが表示されるまでに約15秒の時間がかかる
- 開発中にコードを変更した際のビルド・ブラウザ反映までに約5秒の時間がかかる
これらの遅延は、開発効率を低下させ、快適な開発環境を実現する上で妨げとなっていました。
そのため、より迅速かつ効率的な開発を実現するために、Vite への移行を決定しました。
なぜこのタイミングで Vite に移行したのか?始めから Vite を使えばよかったのでは?という疑問もあるかもしれませんが、以下の理由からこのタイミングでの移行となりました。
- qiankun が webpack に一定依存していることがわかっており、Vite での動作に調査するコストが発生する
- webpack の Module Federation を活用して、複数のフロントエンドアプリケーションで React といった共通モジュールを共有し、バンドルサイズを削減したかったが、Vite では Module Federation を公式にサポートしていない[1]
Module Federation について補足すると、そもそも、現行の qiankun v2 では Module Federation はサポートされておらず、v3 ではサポートする予定でした。そのため、v3 の対応を待っていましたが、v3 の開発状況が進まず Vite に移行することを決定しました。
Vite はなぜ速いのか?
Vite の速さの理由は、基本的にバンドルを行わないためです。通常、webpack などのバンドラーはビルド時に import ステートメントなどを辿ってファイル間の関係性を解析し、ファイルを連結または分割しますが、Vite ではこれらの処理を基本的に行いません。モダンブラウザがネイティブESM(import
ステートメントや export
ステートメントによるモジュールシステム)をサポートしているため、Vite はブラウザにモジュールの読み込みや解析を任せます。また、どうしても必要なライブラリ等のバンドルについては esbuild で 事前バンドルし、更にキャッシュを利用して再ビルドを高速化しています。
Vite への移行作業の詳細
既存の webpack プラグインは、Vite や rollup のプラグインで比較的簡単に置き換えられたため、それに関する苦労はありませんでした。しかし、qiankun と Vite の互換性に関する問題が浮上し、その対応に最もコストがかかりました。
qiankun は、一つのメインアプリケーションの上に複数のサブアプリケーションが配置される構成となります。各アプリケーションは独立しているため、サブアプリケーション単体での Vite 開発サーバーを利用した開発は問題ありません。しかし、メインアプリケーションにサブアプリケーションを統合した状態での開発は、qiankun と Vite の互換性の問題から難しいことがわかりました。
qiankun と Vite の互換性の問題
具体的な問題は以下の通りです。
サブアプリケーションのスクリプトを eval で実行できない
Vite を使用すると、JS ファイルを type="module"
属性をつけて ESM として読み込みます。例えば、サブアプリケーションで Vite を使用すると HTML は以下のように JS エントリーファイルを定義する必要があります。
<script type="module" src="/src/main.tsx"></script>
qiankun は、メインアプリケーションからサブアプリケーションを統合する際に、サブアプリケーションの HTML を fetch し、その HTML 内のスクリプトを eval
で実行します。もし、外部スクリプト(例えば /src/main.tsx
)の場合は fetch した内容を eval
で実行します。[2]
eval(`
// サブアプリケーションの JS エントリーファイルの内容(/src/main.tsx)
import foo from 'foo'
import bar from 'bar'
//...
`);
Vite を使用する場合 eval
で実行されるスクリプトは ESM 形式です。そのため、スクリプト内で import
ステートメントがあるコードを eval
で実行すると Uncaught SyntaxError: Cannot use import statement outside a module
というエラーが発生します。そのため、サブアプリケーション側で Vite を使用すると、サブアプリケーションのスクリプトを eval
で実行することができず統合ができません。
サブアプリケーションのライフサイクルを実行できない
上の問題を解決できたとしても他にも問題があります。
qiankun は、サブアプリケーションの JS エントリーファイルを webpack などを使用して UMD 形式にすることで、サブアプリケーションの JS エントリーファイルでエキスポートされた qiankun のライフサイクルをグローバルに保持して、メインアプリケーション側でグローバルに保持されたサブアプリケーションのライフサイクルを実行することができます。[3]
ただし、サブアプリケーション側で Vite を使用すると、JS エントリーファイルは ESM 形式で提供されます。そのため、サブアプリケーションの JS エントリーファイルでエキスポートされた qiankun のライフサイクルはグローバルに保持されず、メインアプリケーション側で実行することができません。
互換性の問題の解決方法
これらの問題を解決するために、vite-plugin-qiankun-lite という qiankun 向けの Vite プラグインを構築しました。
既存の vite-plugin-qiankun は有名ですが、以下の理由から自作することにしました。
- React の HMR をサポートしておらずエラーが発生する
- メインアプリケーションにサブアプリケーションを複数同時にロードすることができない
- プラグインを導入以外にも、
exportLifeCycleHooks
やqiankunWindow
といったヘルパーを使用する必要があり手間が発生する
自作したプラグインが vite-plugin-qiankun と比較して導入が非常に簡単であることから、vite-plugin-qiankun-lite というネーミングにしました。
このプラグインでどのように互換性問題を解決したかについてや導入方法について、以下で説明します。
「サブアプリケーションのスクリプトを eval で実行できない」問題の解決
eval
は dynamic import であれば実行可能なため、<script type="module">
で読み込まれている JS エントリーファイルを、インラインスクリプト内で dynamic import で読み込むように変換することで解決しています。
例えば、先程の /src/main.tsx
を、
<script type="module" src="/src/main.tsx"></script>
以下のように dynamic import で読み込むように変換しています。
<script>
import("/src/main.tsx");
</script>
「サブアプリケーションのライフサイクルを実行できない」問題の解決
UMD 形式と同じように、サブアプリケーションの JS エントリーファイルでエキスポートされた qiankun のライフサイクルをグローバルに保持するようなコードに変換することで解決しています。
以下のように window[name]
に事前に仮のライフサイクルを定義しておくようにして、qiankun でこれらの仮のライフサイクルが実行されたら、JS のエントリーファイルから取得したライフサイクルの実体が Promise で遅延実行されるコードに変換しています。
<script>
const name = "subApp";
window[name] = {};
const lifecycleNames = ["bootstrap", "mount", "unmount", "update"];
import("/src/main.tsx").then((lifecycleHooks) => {
lifecycleNames.forEach((lifecycleName) =>
window[name][lifecycleName].resolve(lifecycleHooks[lifecycleName]),
);
});
lifecycleNames.forEach((lifecycleName) => {
let resolve;
const promise = new Promise((_resolve) => (resolve = _resolve));
window[name][lifecycleName] = Object.assign(
(...args) => promise.then((lifecycleHook) => lifecycleHook(...args)),
{ resolve },
);
});
</script>
qiankun-vite-plugin-lite の導入
qiankun-vite-plugin-lite を導入する手順はとても簡単です。
サブアプリケーションの Vite の設定で、vite-plugin-qiankun-lite を追加するだけです(メインアプリケーション側は追加不要)。
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import qiankun from "vite-plugin-qiankun-lite";
export default defineConfig({
plugins: [react(), qiankun({ name: "subApp" })],
});
READYFOR の全てのサブアプリケーションにこのプラグインを導入したことで、qiankun と Vite の互換性の問題が解決され、無事 Vite への移行が完了しました 🎉
qiankun-vite-plugin-lite の課題
今回、メインアプリケーションは完全に Vite に移行しましたが、各サブアプリケーションについては開発サーバーのみを Vite に移行しています。そのため、サブアプリケーションのビルドについては引き続き webpack を使用しています。
何故サブアプリケーションが完全に Vite に移行できなかったのでしょうか?それは、先程説明した互換性の問題以外に、qiankun のサンドボックス機能が損なわれてしまうという問題があるためです。
サンドボックス機能が損なわれる問題について
qiankun には、サブアプリケーション間で JavaScript を分離するサンドボックス機能があります。この機能を有効にすることで、サブアプリケーション間での JavaScript のグローバル変数やイベントの衝突や競合を防いでくれます。READYFOR でもこの機能を有効にしていますが、Vite ではこのサンドボックス機能が損なわれてしまうという問題があります。
なぜ、Vite の場合 qiankun の サンドボックス機能が損なわれてしまうのか?
これを説明する前に、qiankun の JS サンドボックス機能がどのように実現されているのかについて理解する必要があります。
qiankun はサブアプリケーション毎に擬似的な window オブジェクト(window.proxy
)を用意し、以下のように即時関数(もしくは with
文)を活用して、サブアプリケーションのスクリプト内での window オブジェクトの参照がこの擬似的な window オブジェクト(window.proxy
)に参照されるようにハックすることで、サンドボックス機能を実現しています。
vite-plugin-qiankun-lite は、eval
の問題を解決するために、サブアプリケーションの JS ファイルを dynamic import で読み込むように変換します。しかし、即時関数が実行されるスクリプトと dynamic import される JS ファイルのコンテキストは異なるため、以下のように、dynamic import で読み込まれる JS ファイル内で window オブジェクトを参照しても擬似的な window オブジェクト(window.proxy
)は参照されません。そのため、Vite の場合はサンドボックス機能が無効化されます。
eval(`
(function(window) {
console.log(window) // window.proxy が表示される
import("./subApp.mjs");
})(window.proxy);
`);
console.log(window); // 本物の window が表示される
ちなみに、IceStark というマイクロフロントエンドフレームワークもサンドボックス機能を提供しているのですが、上で説明した同様の理由から Vite ではサンドボックス機能をサポートしていません。
サンドボックス機能実現に向けた取り組み
Vite でのサンドボックス機能の実現は難しいですが、vite-plugin-qiankun-lite では可能な限りその機能をサポートするための sandbox
オプションを提供しています。アプリケーションで読み込んでいる JS ファイルを AST で解析し、window オブジェクトが使われていた場合は擬似的な window オブジェクトに置換する(例えば、window.XXX
を window.proxy.XXX
に置換する)ようにすることでサンドボックス機能をサポートしています。
ただし、ブラウザが提供するグローバル変数等は、window.
無しにアクセスできるため、このようなグローバル変数の置換をサポートするのは非現実的です(とはいえ、ブラウザが提供するグローバル変数をリスト化して対応してはいます)。また、これらの処理はアプリケーションが読み込んでいる全ての JS ファイル(node_modules 含む)を変換する必要があるため当然処理が重くなります。その結果、Vite の高速性が台無しになる可能性があります。更に、JS ファイルサイズが増大してしまうという問題も抱えています。そのため、現状この sandbox
オプションは実験的なものとして提供しています。
本番モードでのサンドボックス機能のサポートについて
開発時は ESM のためサンドボックス機能のサポートが難しいですが、本番モードでは UMD 形式でビルドすることでサンドボックス機能を実現できます。Vite のライブラリモードを使用することで UMD 形式でのビルドが可能となります。ただし、Vite の IIFE および UMD 形式は コード分割をサポートしておらず遅延読み込みができないという制限があります。
vite-plugin-qiankun-lite でも、本番ビルドでは UMD 形式にする PR を作成していますが、この制限からマージは保留にしています。
以上の理由から、サブアプリケーションのビルドについては引き続き webpack を使用しています。
Vite への移行結果
上記の移行作業を経て、無事 Vite への移行が完了し、開発環境での動作速度が大幅に向上しました 🚀
- 開発サーバーを起動してからアプリケーションが表示されるまでの時間: 約15秒 => 約5秒
- 開発中にコードを変更した際のビルド・ブラウザ反映までに約5秒の時間: 約5秒 => 1秒未満
おわりに
マイクロフロントエンドフレームワーク qiankun を採用している都合上、Vite との互換性の問題がありましたが、それらの問題を解決するためのプラグインを構築することで、Vite への移行を実現しました。この移行によって、開発環境での動作速度を大幅に向上させることができました。
今後も、より快適で効率的な開発を実現するために、さらなる改善や最適化に努めていきます。
-
Vite で Module Federation をサポートするための vite-plugin-federation というコミュニティのプラグインは存在します。 ↩︎
-
こちらの詳細については、「qiankun によるマイクロフロントエンドの統合メカニズム」記事内の「7. 抽出したエントリーを含むスクリプトを実行し、エントリーでエキスポートされたライフサイクルを取得」セクションをご覧下さい。 ↩︎
-
こちらの詳細については、「qiankun によるマイクロフロントエンドの統合メカニズム」記事内の「サブアプリケーションのバンドラー設定」セクションをご覧下さい。 ↩︎
「みんなの想いを集め、社会を良くするお金の流れをつくる」READYFORのエンジニアブログです。技術情報を中心に様々なテーマで発信していきます。 ( Zenn: zenn.dev/p/readyfor_blog / Hatena: tech.readyfor.jp/ )
Discussion