Rails: Webpackをesbuildに移行してJSのビルドを縮小・高速化(翻訳)
先週、私はJavaScriptコードが30万行を超えるかなり大規模なプロジェクトで、Webpack 4をesbuildに移行する作業を担当しました。私たちのRailsプロジェクトではJavaScriptスタックをメインアプリケーションに統合するのにWebpackerを使っていました。ここ数か月はもっぱら長時間のビルドと格闘し続け、さらにWebpackerが推奨ライブラリでなくなったことでWebpack 4にロックインされていました。Webpack 4の依存ライブラリのいくつかに付いてCVEの脆弱性情報を受け取ったとき、ついにJavaScriptバンドラーを別のものに切り替える時期が到来したと判断しました。
短期間の調査を経た結果、私たちはesbuildを用いてJavaScriptエントリーを準備することに決めました。やがて時は過ぎ、ついにIE 11に嬉しい別れを告げる日がやってくると、ECMAScriptモジュール(ES Modules)に乗り換えてJavaScriptスタックを改善する良い機会となりました。Webユーザーのほぼ95%がES Modulesを理解できる環境になっているのですから、ES Modulesを使わない手はありません。
そして私たちがJavaScriptスタックを最後にチェックしてからさらに数年を経た今、私たちが使うほとんどの機能がブラウザに取り込まれたおかげでBabelやポリフィルはもはや不要であることがわかってきました。
本記事では、私たちが用いている現在のJavaScriptセットアップについて解説および議論します。
🔗 esbuildの設定
現在私たちが使っているesbuildのオプションは以下のような感じです。
const glob = require("glob");
const esbuild = require("esbuild");
const {lessLoader} = require("esbuild-plugin-less");
const isProduction = ["production", "staging"].includes(
process.env.WEBPACK_ENV
);
const config = {
entryPoints: glob.sync("app/javascript/packs/*.js"),
bundle: true,
assetNames: "[name]-[hash].digested",
chunkNames: "[name]-[hash].digested",
logLevel: "info",
outdir: "app/assets/builds",
publicPath: `${process.env.CDN_HOST ?? ""}/assets`,
plugins: [lessLoader({javascriptEnabled: true})],
tsconfig: "tsconfig.json",
format: "esm",
splitting: true,
inject: ["./react-shim.js"],
mainFields: ["browser", "module", "main"],
loader: {
".js": "jsx",
".locale.json": "file",
".json": "json",
".png": "file",
".jpeg": "file",
".jpg": "file",
".svg": "file",
},
define: {
global: "window",
RAILS_ENV: JSON.stringify(process.env.RAILS_ENV || "development"),
VERSION: JSON.stringify(process.env.IMAGE_TAG || "beta"),
COMMITHASH: JSON.stringify(process.env.GIT_COMMIT || ""),
},
incremental: process.argv.includes("--watch"),
sourcemap: true,
minify: isProduction,
metafile: true,
target: ["safari12", "ios12", "chrome92", "firefox88"],
};
🔗 entryPoints:
entryPoints: glob.sync("app/javascript/packs/*.js"),
最初はentryPoints
リストについて説明します。当時はWebpackerを使っていたので、エントリポイントを配置するそこそこ標準的なpacksディレクトリ(app/javascript/packs)が使えました。作成しているエントリをすべてリストアップしたくなかったのと、新しいエントリを手動で追加する方法を覚える気になれなかったので、glob
パッケージを用いて指定のパターンとマッチするすべてのファイルリストを生成していました。この方法を用いれば新しいエントリをapp/javascript/packsディレクトリに追加して次回の実行で自動的にビルドされるようになります。
🔗 bundle:
bundle: true,
作成したエントリーにインポートされる依存関係はすべてインライン化していたので、bundle: true
を指定しています。
🔗 assetNames:
、chunkNames:
assetNames: "[name]-[hash].digested",
chunkNames: "[name]-[hash].digested",
次のassetNames
とchunkNames
設定は、sprocketsが管理するアセットパイプラインを適切に扱い、チャンクやアセットが2回ダイジェスト化されないようにします。
注意: 設定の-[hash].digested
という記述は、生成されたファイルがsprocketsによって再度ダイジェスト化されないために必要です。これを指定しないと、アセットがコンパイルされた後でファイルローダーが扱う動的インポートやファイルインポートがすべて動かなくなってしまいます。
🔗 outdir:
outdir: "app/assets/builds",
私たちは複数のエントリをビルドしていたので、outdir
プロパティが必要です。このプロパティにapp/assets/buildsを設定することで、後でRailsのアセットパイプラインと統合できるようにします。
🔗 publicPath:
publicPath: `${process.env.CDN_HOST ?? ""}/assets`,
私は、多くのチュートリアルではpublicPath
にassets
が設定されていることに気づきました。しかしこの設定は、splitting
を有効にすると正しく動作しません(インポートしたチャンクが相対パスになってほとんどのページで動作しなくなり、さらにどのCDNでも動作しなくなります)。
私たちの場合、環境変数で設定されるCDNのホスト名をpublicPath
の冒頭に追加することで、Railsアプリサーバーからのアセット配信を不要にしています(CDN_HOST
はRailsのconfig.asset_host
の値と等しくなります)。
🔗 plugins:
plugins: [lessLoader({javascriptEnabled: true})],
ここではLESSサポート用のプラグイン(lessLoader
)を使っています。私たちはJavaScriptスタックからすべてのCSSを移動させるつもりですが、まだすべてのCSSが移動完了したわけではなく、その一部は引き続きLESSを使っています。このプラグインはいずれ削除するつもりです。
🔗 tsconfig:
tsconfig: "tsconfig.json",
Webpackerではresolve
とalias
を使っていました。これにより、インポートで相対パスを使わずにimport foo from "~/foo"
と書けるようになっていました。esbuildはこうしたエイリアスをデフォルトでサポートしていませんが、tsconfig.json
ファイルについてはサポートしています。tsconfig
オプションを使うとtsconfig.json
ファイルへのパスを指定できます。これは、アプリケーションでTypeScriptを有効にしていない場合でも有効です。
私たちのtsconfig.json
ファイルは以下のようになっています。
{
"compilerOptions": {
"target": "es6",
"baseUrl": ".",
"paths": {
"~/*": [
"./app/javascript/src/*"
]
}
},
"include": [
"app/javascript/src/**/*"
],
}
compilerOptions.path
オブジェクトを用いることで、Webpackerからのインポートが維持され、~/
が適切に解決されるようになります。
🔗 format:
、splitting:
format: "esm",
splitting: true,
その次のformat
オプションとsplitting
オプションは、ESMフォーマットと、動的インポートによるチャンク分割を有効にします。私たちのSPAでは動的インポートと複数の遅延読み込みページが使われているので、初期レンダリングで必要なJavaScriptファイルのサイズを最適化するためにこのオプションが必要でした。
🔗 inject:
inject: ["./react-shim.js"],
inject
オプションは、esbuildによって生成されるすべてのファイルに、Reactコンポーネントを適切に扱うためのshimを注入するのに必要でした。
私たちのreact-shim.js
ファイルは以下のように非常にシンプルです。
import * as React from "react";
export {React};
🔗 mainFields:
mainFields: ["browser", "module", "main"],
また、デフォルトのmainFields
設定を変更する必要もありました。これは、私たちが使っているライブラリのうち1つか2つがブラウザにコードを正しくエクスポートしておらず、nodeパッケージが利用できなくなってビルド中にエラーが発生したためです。詳しくは以下のドキュメントをご覧ください。
🔗 loader:
loader: {
".js": "jsx",
".locale.json": "file",
".json": "json",
".png": "file",
".jpeg": "file",
".jpg": "file",
".svg": "file",
},
ローダーについてはこんなこぼれ話もあります。私たちのプロジェクトでは、国際化(i18n)のためにi18nextとカスタムバックエンドを用いています。訳文はJSONファイルに保存して、動的インポートで読み込む形になっています。ダイジェスト化されたアセットでこれを有効にするために、このダイジェスト化URLをi18nの初期化モジュール内で取得するfile
ローダーが必要でした。しかし単に{".json": "file"}
をローダーとして指定すると、依存関係のどこかで別のエラーが発生することが判明したのです。
少し調べてみたところ、必要な依存関係のどこかでJSONファイルのインポートで副作用が発生していることがわかりました。その結果、すべてのJSONファイルをこのfile
ローダーで読み込むわけにいかなくなりました。
最終的に、訳文ファイルの拡張子を.locale.json
に変更して、file
ローダには拡張子が.locale.json
であるファイルだけを読み込ませ、.json
拡張子のファイルだけはjson
ローダーで読み込むことにしました。私たちの場合は.jsx
拡張子を使っていないので、すべての.js
ファイルについてjsx
ローダーを有効にし、画像ファイルはfile
ローダーに任せました。
🔗 define:
define: {
global: "window",
RAILS_ENV: JSON.stringify(process.env.RAILS_ENV || "development"),
VERSION: JSON.stringify(process.env.IMAGE_TAG || "beta"),
COMMITHASH: JSON.stringify(process.env.GIT_COMMIT || ""),
},
私たちはWebpackでdefineプラグインを使って環境変数をコードに注入していました。しかしesbuildにはそのための define
オプションが既にあるので、defineプラグインは不要になりました。
🔗 incremental:
incremental: process.argv.includes("--watch"),
最後のincremental
オプションは、主にdevelopmentモードやproductionモード向けの最適化用です。このオプションを指定することで、development環境のアセットリビルドを高速化できます。
なお、組み込みのwatch
設定は私たちのセットアップでは動かなかったので使いませんでした。そこで、chokidar
パッケージを用いるカスタムのファイルウォッチャーを書いてJavaScriptディレクトリを監視し、変更を検出してエントリをリビルドすることに決めました。
/* ... */
const fs = require("fs");
const config = { /* see above */ };
if (process.argv.includes("--watch")) {
(async () => {
const result = await esbuild.build(config);
chokidar.watch("./app/javascript/**/*.js").on("all", async (event, path) => {
if (event === "change") {
console.log(`[esbuild] Rebuilding ${path}`);
console.time("[esbuild] Done");
await result.rebuild();
console.timeEnd("[esbuild] Done");
}
});
})();
} else {
const result = await esbuild.build(config);
fs.writeFileSync(
path.join(__dirname, "metafile.json"),
JSON.stringify(result.metafile)
);
}
🔗 sourcemap:
、minify:
、metafile:
sourcemap: true,
minify: isProduction,
metafile: true,
現時点の私たちは、sourcemapをすべての環境で生成し、production環境ではコードを最小化しています。なお、ファイルウォッチャーを使わない場合は、メタファイルはmetafile.json
に保存されます。
🔗 target
target: ["safari12", "ios12", "chrome92", "firefox88"],
最後のtarget
オプションは、生成されたJavaScriptコードが対象とする環境を指定します。
esbuildのコンフィグはesbuild.config.js
ファイルに保存し、package.json
内の以下のスクリプトですべてのJavaScriptファイルのビルドやリビルドを実行します。
{
"scripts": {
"build:js": "node esbuild.config.js",
"watch:js": "node esbuild.config.js --watch",
}
}
🔗 Railsとの統合
jsbundling-rails gemのソースコードを調査した結果、私たちはjsbundling-railsを使わないことに決め、代わりにこのgemと同等の処理を行うrakeタスクを1つだけ追加することにしました。実はRailsとesbuildの統合は非常に簡単で、基本的に必要なのはapp/assets/config/manifest.js
ファイルに以下を追加しておくことだけです。
//= link_tree ../builds
また、いくつかのrakeタスクを拡張して、esbuildアセットを生成し終わってからアセットをプリコンパイルするようにしておく必要もあります。そのために以下のコードを使っています。
namespace :javascript do
desc "Build your JavaScript bundle"
task :build do
system "yarn install" or raise
system "yarn run build:js" or raise
end
desc "Remove JavaScript builds"
task :clobber do
rm_rf Dir["app/assets/builds/**/[^.]*.{js,js.map}"], verbose: false
end
end
Rake::Task["assets:precompile"].enhance(["javascript:build"])
Rake::Task["test:prepare"].enhance(["javascript:build"])
Rake::Task["assets:clobber"].enhance(["javascript:clobber"])
これで、rails assets:precompile
を実行するたびにyarnパッケージマネージャがすべての依存関係をインストールし、JavaScriptファイルをビルドしてapp/assets/build
に配置するようになります。ファイルウォッチャーをアプリ開発用に設定したい場合は、コンソールで run watch:js
を実行すればdevサーバーが起動します。
🔗 可能な改良について
このコンフィグは今のところ私たちのアプリで完璧に動作しており、ビルドのサイズも縮小されて高速になりました。Webpackのときは、負荷によってはアセットのビルドに6〜15分を要していましたが、esbuildでは最小化済みアセットのビルドに1分もかからなくなりました。development環境ではさらに高速です。ファイルウォッチャーの初期ビルドは私のiMac 2019でも5秒足らずで完了し、リビルドはさらに高速になりました。
しかし、今後改善する余地はまだいくつか残っています。JavaScriptファイルをローカル環境で開発する場合、多数のモジュールで利用されているファイルを頻繁に変更するとapp/assets/builds
に生成されるファイル数が急激に増加することがあり、そうなるとRailsサーバーの初期リクエスト処理が非常に遅くなる可能性があります。
この問題を解決するには、yarn run watch:js
スクリプトを実行する前にときどきapp/assets/builds
ディレクトリをクリーンアップし、さらにスクリプトも再起動するとよいでしょう。ビルドのたびにメタファイルを読み込んで、そのファイルに記載されていないファイルをすべて削除する何らかのesbuildプラグインを使えば、おそらく改善されるでしょう。
もうひとつの問題はapp/assets/builds
そのものです。watch:js
スクリプトを起動した後、リビルド中に何らかのエラーが発生するとesbuildプロセスは終了しますが、アセットディレクトリはクリーンアップされないので、アセットが引き続きRailsから配信されてしまいます。そうなるとページを再読み込みしてもモジュールの変更が反映されなくなり、首をかしげることになるかもしれません。
この問題を解決するには、esbuildプロセスの状態を監視して(少なくとも)エラーが通知されたら再起動する別のプロセスがおそらく必要になると思われます。この問題のうまい解決方法をご存知でしたら、ご遠慮なく共有してください。
お知らせ: 5600人以上のRailsエンジニアが購読しているメールマガジン
元記事末尾のフォームに登録いただくと、Arkencyのベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。
私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。
概要
元サイトの許諾を得て翻訳・公開いたします。
原文の章立ての一部を訳文で変更しています。