Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Webpackをesbuildに移行してJSのビルドを縮小・高速化(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

原文の章立ての一部を訳文で変更しています。

Rails: Webpackをesbuildに移行してJSのビルドを縮小・高速化(翻訳)

先週、私はJavaScriptコードが30万行を超えるかなり大規模なプロジェクトで、Webpack 4esbuildに移行する作業を担当しました。私たちの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",

次のassetNameschunkNames設定は、sprocketsが管理するアセットパイプラインを適切に扱い、チャンクやアセットが2回ダイジェスト化されないようにします。

注意: 設定の-[hash].digestedという記述は、生成されたファイルがsprocketsによって再度ダイジェスト化されないために必要です。これを指定しないと、アセットがコンパイルされた後でファイルローダーが扱う動的インポートやファイルインポートがすべて動かなくなってしまいます。

🔗 outdir:

  outdir: "app/assets/builds",

私たちは複数のエントリをビルドしていたので、outdirプロパティが必要です。このプロパティにapp/assets/buildsを設定することで、後でRailsのアセットパイプラインと統合できるようにします。

🔗 publicPath:

  publicPath: `${process.env.CDN_HOST ?? ""}/assets`,

私は、多くのチュートリアルではpublicPathassetsが設定されていることに気づきました。しかしこの設定は、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ではresolvealiasを使っていました。これにより、インポートで相対パスを使わずに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パッケージが利用できなくなってビルド中にエラーが発生したためです。詳しくは以下のドキュメントをご覧ください。

参考: mainFields: esbuild – API

🔗 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のベテランプログラマーによる磨き抜かれた洞察や知恵を結晶したメールマガジンを配信いたします。

私たちは皆さんのメールボックスにスパムを決して送信することのないよう最大限に配慮しています。フォームを送信いただくと確認メールが届きます。

関連記事

Rails: beforeバリデーションをやめてセッターメソッドにしよう(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。