Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

フロントエンドの本番ビルドに SWC を導入して、ビルド時間とメモリ使用量を同時に削減する

こんにちは。 Eight でエンジニアをしている鳥山(@pvcresin)です。
今年はいくつも BBQ の予定がたっていて、コロナ禍前の日常に戻りつつあることを実感しています。 BBQ ならラムチョップが好きです。
今回は、Web フロントエンドの本番ビルドに SWC を導入した話をしたいと思います。

目次

背景

Eight では Web フロントエンドの本番ビルドを AWS CodeBuild で行っていますが、長年の開発によりコードベースが膨らみ、それによってビルドに要求されるメモリ領域も日々増加していました。 そしてある日を境に、OOM Killer(Out Of Memory Killer) によるプロセス停止で時々ビルドが失敗するようになってしまいました。
これは CodeBuild のコンピューティングタイプを、よりメモリが多いものに変更すれば解決します。 一方で、それによるコスト増加は無視できないため、ビルド処理自体を改善することでこの問題を解決しようと考えました。
Eight の Web フロントエンドは TypeScript + React で開発を行っており、webpack でビルドしています。 そのビルド時間の大半は、約 30 万行もの膨大な TypeScript / JSX を JavaScript に変換する部分です。 そこで、より高速で省メモリな代替ツールを使うことで、メモリ不足問題の解消と同時にビルド時間の削減も試みることにしました。

esbuild と SWC

導入の話に入る前に、esbuild と SWC について簡単に触れておこうと思います。 ここでは、JavaScript や TypeScript コードを変換するツールを便宜上、単にコンパイラと呼ぶことにします1

esbuild

esbuild は Go 製の高速なモジュールバンドラです。 コンパイラや Minifier 単体の利用も可能で、webpack 用の loader(esbuild-loader)も用意されています。 国内で esbuild-loader を導入した事例が増えており、設定方法もシンプルだったため、Eight でも昨年に導入して開発ビルドの速度改善を行いました2
ただし、本番ビルドを esbuild-loader のみで行うには target ブラウザに応じてコードを変換したり Polyfill を注入したりする、Babel の preset-env 相当の機能が足らなかったため、開発ビルドでの利用に留まっていました。

SWC(Speedy Web Compiler)

SWC は Rust 製の高速なコンパイラです。 近年、Deno や Next.js、Vite をはじめとした様々なツールの内部で採用され、注目されています。 webpack 用の loader(swc-loader)が用意されており、env という Babel の preset-env 相当の機能も備えているため、本番ビルドでも十分に使えます。
実は esbuild-loader を導入した際に swc-loader も検討したのですが、当時はうまく動かすことができず、サクッと動いた esbuild-loader を採用したという経緯があります。 今回はそのリベンジになります 😎

事前検証

本番ビルドでも使うため、念入りに事前の検証を行いました。 具体的には、他のコンパイラと合わせて CLI の出力コードの比較を行ったり、小さな webpack プロジェクトを作成して swc-loader の動作チェックを行ったりしました。

tsc / Babel / esbuild / SWC の出力コードの比較

ビルド処理の移行イメージ

実際の移行作業に入る前に、現行のビルド処理について説明します。 Eight の Web フロントエンドでは主に TypeScript 周りで 4 つの変換処理を行っています。

  1. TypeScript の変換(ts, tsx → js, jsx)
  2. ECMAScript の変換(ESNext → ES2015)
  3. JSX の変換(jsx → js)
  4. Polyfill の注入(core-js の設置)

これを webpack の loader に対応させると以下のようになります。

  • 本番ビルド: ts-loader (1, 2) → babel-loader(3, 4)
  • 開発ビルド: esbuild-loader (1, 2, 3)

本番ビルドでは、ts-loader で型を取り除いてから ES2015 にし、その後 babel-loader で JSX の変換と Polyfill 設定を行っています3。 開発ビルドでは、esbuild-loader で一気に変換を行っており、Polyfill は設定していませんでした。

SWC を導入するにあたって、以下の形に移行します。

  • 本番ビルド: swc-loader (1, 2, 3, 4)
  • 開発ビルド: swc-loader (1, 2, 3, 4)

本番ビルド・開発ビルドともにすべての変換処理を swc-loader に置き換えます。 特に本番ビルドは、tsc と Babel の 2 つのツールを使っていたのが SWC に一本化されてシンプルになります 👍

移行作業

実際の移行作業について説明していきます。 Eight では yarn を使っているため、yarn add -D @swc/core swc-loader でインストールします。

.swcrc の作成

次に SWC の config ファイル(.swcrc)を作成します。

{
  "$schema": "https://json.schemastore.org/swcrc",
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "tsx": true,
      "decorators": true,
      "dynamicImport": true
    },
    "transform": {
      "useDefineForClassFields": false
    }
  },
  "env": {
    "targets": ["Browserslistの指定"],
    "mode": "entry",
    "coreJs": "3.31.0"
  }
}

jsc.parser で TypeScript のパーサを指定し、tsx などの設定も有効にします。 jsc.transform では元の挙動と揃えるため、useDefineForClassFields を false に設定しています。 詳しくはハマったポイントの章で述べます。
env.targets では Browserslist と同様の指定を行うことで、対応するブラウザに応じたコードを出力するようにします。 env.coreJs と env.mode を指定することで、core-js の Polyfill を必要なものだけ注入することができます。
ちなみに mode: "usage" は、コード内で明示的に import しなくても自動で必要な Polyfill を注入してくれる設定ですが、現時点では Babel の useBuiltIns: "usage" ほど効率的ではないようです4

webpack.config.js の修正

最後に、webpack.config.js を修正し、本番ビルドと開発ビルドの両方で swc-loader を使うようにします。

{
  test: /\.tsx?$/,
  exclude: /node_modules/,
- use: isProd ? [
-   {
-     loader: 'babel-loader',
-   },
-   {
-     loader: 'ts-loader',
-     options: { transpileOnly: true },
-   },
- ] : [
-   {
-     loader: 'esbuild-loader',
-     options: { loader: 'tsx', target: 'es2015' },
-   },
- ],
+ use: [
+   {
+     loader: 'swc-loader',
+   },
+ ],
},

これで移行作業は完了です。

ハマったポイント

ここでは、SWC 検証時や移行時にハマったポイントについて紹介します。

Browserslist の指定を読み取ってくれない

最初 @swc/cli で出力コードの検証を始めたとき、Browserslist の指定を読み取ってくれないという問題に遭遇しました5env.targetsenv.paths のドキュメントを見ると、env.targets を設定しなかった場合は Browserslist の指定を package.json や .browserslistrc ファイルから読み取ってくれるように見えますが、実際には読み取られませんでした6。 これに関しては、既に package.json に書いていた browserslist の部分を .swcrc の env.targets に写して対応しました。

target ブラウザの指定が同じでも Babel と SWC で出力が異なる場合がある

Babel(preset-env)と SWC(env)で注入される Polyfill が同一かをチェックしているとき、package.json の browserslist と .swcrc の env.targets で同じクエリを指定しても、出力が異なることがありました。
例えば、core-js の Polyfill import を entry モードで行う場合を考えてみます。 "chrome >= 109" の指定をした場合、Babel では 2 個の Polyfill が import されますが、SWC では 208 個も import されてしまいます7
ここで、.swcrc で env.debug: true にして SWC の変換時の設定を出力してみると、
"chrome >= 108" の場合はバージョンがしっかり認識されています。

Targets: BrowserData { chrome: Some(Version { major: 108, minor: 0, patch: 0 }), /* 省略 */ }

一方、"chrome >= 109" の場合は認識できていません。

Targets: BrowserData { chrome: None, /* 省略 */ }

どうやら SWC の env.targets が Browserslist と同期できておらず、情報が古い場合があるようです8。 2023 年 6 月現在、Babel は最新の Chrome 114 まで正しく認識できましたが、SWC は Chrome 108 で止まっていました。
この問題に関しては browserslist.dev と SWC の env.targets を見比べながら、target ブラウザに問題がないことを確認しました。

useDefineForClassFields が tsconfig.json を考慮してくれない

useDefineForClassFields は TypeScript 3.7 で追加された compilerOptions です。
例えば、tsc で TypeScript コードを JavaScript (ES2015) に変換するとき、tsconfig.json で useDefineForClassFields を false にした場合は foo は単に取り除かれますが、 true の場合はプロパティが定義されます。

// input
class A {
  foo?: string;
}

// useDefineForClassFields: false
class A {
}

// useDefineForClassFields: true
class A {
  constructor() {
    Object.defineProperty(this, "foo", {
      enumerable: true,
      configurable: true,
      writable: true,
      value: void 0,
    });
  }
}

この設定はクラスの継承まわりの挙動に大きく影響するため、注意が必要です。
クラスフィールド構文は元々 TypeScript に存在していましたが、挙動が異なる形で JavaScript(ES2022)に導入されたため、このような設定が必要になりました9。 SWC のドキュメントには tsconfig.json の target 設定を元にデフォルト値を設定すると書かれていますが、実際にはそのようには動いていないようで、.swcrc でも useDefineForClassFields を明示的に宣言する必要がありました。
Eight ではこの設定によってうまく動かない部分がありましたが、 target が ES2022 以降ではなかったので .swcrc で false に設定して解決しました。

結果

SWC + swc-loader を導入した結果はどうなったのか、見ていきたいと思います。

本番ビルド

まず、CodeBuild での本番ビルドにかかる時間(5 回平均)を比較したところ、

ビルド時間(秒)
ts-loader + babel-loader 150.5
swc-loader 103.9

swc-loader によってビルド時間の約 1/3 を削減することができました 🎉

次に、CodeBuild のメモリ使用率の最大値(5 回平均)を比較しました。

メモリ使用率の最大値(GB)
ts-loader + babel-loader 5.91
swc-loader 1.61

結果、こちらは 73% 削減することができました 🙌

開発ビルド

ちなみに手元のマシン10で開発ビルドにかかる時間(5 回平均)を比較したところ、

ビルド時間(秒)
esbuild-loader 22.9
swc-loader 22.4

少し速くなっていました。 こちらは意図していませんでしたが、ラッキーです ✌️

まとめ

Web フロントエンドの本番ビルドに SWC を導入して、ビルド時間とメモリ使用量を同時に削減することができました。
少々ハマりどころもありましたが、何とか解決することができてよかったです。
また、ビルド周りが SWC に一本化されたことで設定もシンプルになり、メンテナンスも楽になりそうです。
今後も Rust まわりの技術に注目していきたいと思います。


  1. トランスパイラと呼ばれることもあります。
  2. https://buildersbox.corp-sansan.com/entry/2022/04/19/110000
  3. かつては ES5 のコードを出力したことに加え、TypeScript を段階的に導入していった経緯から ts-loader と babel-loader を併用しています。
  4. 「The usage mode is currently not as efficient as Babel, yet.」と書かれています。 https://swc.rs/docs/configuration/supported-browsers#mode
  5. @swc/core v1.3.65, @swc/cli v0.1.62 で検証。
  6. Browserslist が読み取られないという Issue もたっています。 https://github.com/swc-project/swc/issues/3365
  7. @babel/core v7.22.5, @babel/preset-env v7.22.5, @swc/core v1.3.65 で検証。
  8. Browserslist での IE のサポート終了時には、SWC を使うと IE が依然として含まれてしまうという問題があったようです。 https://github.com/swc-project/swc/issues/3365#issuecomment-1320305091
  9. TypeScript 3.7 のリリースノート https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier
  10. MacBook Pro (16-inch, 2021), Apple M1 Max, RAM 64 GB

© Sansan, Inc.