概要
個人用のElectronアプリ(Vite + React + TypeScript構成)のビルドで巨大チャンク警告が出たので、manualChunksでCodeMirror関連パッケージを分割しようとした。ビルドは通るがアプリが起動しない。原因は@lezer/パッケージをCodeMirrorコアから分離したことによるチャンク間の循環依存だった。そのまとめ。
背景: 巨大チャンク警告
pnpm app:buildを実行すると以下の警告が出ていた。
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking1つのチャンクに全てが入って1.5MB超。Electronアプリなのでバンドルサイズ自体はそこまで問題にならないが、Viteが毎回警告を出すのが気になるので対処することにした。
manualChunksで分割を試みる
vite.config.tsのmanualChunksで、パッケージをグループごとに分割した。
manualChunks(id) {
if (id.includes("node_modules")) {
if (
id.includes("@codemirror/language-data") ||
id.includes("@codemirror/lang-") ||
id.includes("@lezer/")
) {
return "codemirror-langs";
}
if (
id.includes("@codemirror") ||
id.includes("@replit/codemirror-vim")
) {
return "codemirror";
}
if (id.includes("highlight.js")) {
return "highlight";
}
if (
id.includes("react-markdown") ||
id.includes("rehype-highlight") ||
id.includes("remark-gfm") ||
id.includes("unified") ||
id.includes("remark-") ||
id.includes("rehype-") ||
id.includes("mdast-") ||
id.includes("hast-") ||
id.includes("micromark") ||
id.includes("unist-")
) {
return "markdown";
}
if (id.includes("xterm") || id.includes("@xterm")) {
return "xterm";
}
}
}@lezer/をCodeMirrorの言語モードと一緒にcodemirror-langsチャンクに、CodeMirrorのコア(@codemirror/view、@codemirror/state等)をcodemirrorチャンクに分けた。ビルドは通る。警告も消える。
しかしアプリを起動すると真っ白な画面。
エラーの正体
DevToolsのConsoleに以下のエラーが出ていた。
Uncaught ReferenceError: Cannot access 'F' before initialization
at codemirror-langs-Cizn3g71.js:6:73379
@lezer/パッケージをCodeMirrorのコアから分離したことで、チャンク間の循環参照が発生している。Rollupがモジュールの初期化順序を解決できず、変数が初期化前にアクセスされる。
なぜ壊れるのか
@lezer/はCodeMirrorのパーサーフレームワーク。@codemirror/viewや@codemirror/stateが@lezer/commonを直接importしている。つまり以下の状況が起きる。
codemirrorチャンクが@lezer/commonをimportする →codemirror-langsチャンクに存在codemirror-langsチャンクが@codemirror/stateをimportする →codemirrorチャンクに存在
チャンク間で循環importが発生し、初期化順序が解決不能になる。
npm上は@codemirror/*と@lezer/*は別パッケージだが、内部的には密結合している。パッケージの境界とチャンクの境界は別の話で、manualChunksでパッケージ名だけ見て機械的に分割すると、こういう問題が起きる。
highlight.jsとmarkdownの循環
@lezer/だけでなく、highlight.jsとreact-markdownの間にも同様の循環があった。
rehype-highlightがhighlight.jsをimportしており、highlight.jsをmarkdownチャンクとは別のhighlightチャンクに分離したことで循環が発生する。
highlight → markdown → highlightこちらも同じ構造の問題。
修正
@lezer/をCodeMirrorコアと同じチャンクに統合し、highlight.jsもmarkdownチャンクに統合した。
manualChunks(id) {
if (id.includes("node_modules")) {
// @lezer/ はCodeMirrorコアと一緒にする(分離すると循環依存)
if (
id.includes("@codemirror") ||
id.includes("@replit/codemirror-vim") ||
id.includes("@lezer/")
) {
return "codemirror";
}
// highlight.js はmarkdownと一緒(rehype-highlightとの循環回避)
if (
id.includes("highlight.js") ||
id.includes("react-markdown") ||
id.includes("rehype-highlight") ||
id.includes("remark-gfm") ||
id.includes("unified") ||
id.includes("remark-") ||
id.includes("rehype-") ||
id.includes("mdast-") ||
id.includes("hast-") ||
id.includes("micromark") ||
id.includes("unist-")
) {
return "markdown";
}
if (id.includes("xterm") || id.includes("@xterm")) {
return "xterm";
}
}
}Electronアプリなのでバンドルサイズを細かく最適化する意味はあまりない。chunkSizeWarningLimitも上げた。
build: {
chunkSizeWarningLimit: 2000,
}最終的なチャンク構成は以下。
codemirror(1.7MB)— CodeMirror + Lezer + 全言語モードmarkdown(343kB)— react-markdown + rehype + highlight.jsxterm(287kB)— ターミナルindex(269kB)— アプリ + React
教訓
manualChunksでパッケージを分割する際は、パッケージ間の内部依存関係を確認する必要がある@lezer/はCodeMirrorの内部依存。npm上は別パッケージだが実質的に一体のもの- Rollupの循環参照はビルド時にはwarningで済むが、ランタイムではfatalになる。ビルドが通ったからといって安心はできない
- Electronアプリではバンドルサイズの最適化より動作の確実性を優先したほうが良い。チャンクが大きくてもネットワーク転送は発生しないので、無理に分割して壊すより1つにまとめるほうが安全
余談
Cannot access 'X' before initializationというエラーメッセージだけ見ると、自分のコードのミスに見える。minifyされたコードでFとかxとか言われても手がかりがない。ソースマップ付きでビルドするか、ファイル名にチャンク名が含まれているのでそこからどのパッケージ群が問題かを推測するしかない。今回はcodemirror-langsというチャンク名がエラーのファイル名に入っていたので、CodeMirror周りの分割が原因だろうという当たりはすぐ付いた。