ビルド設定編: UA に応じた最適な JS バンドルの配信と webpack との距離感(新規開発のメモ書きシリーズ2)

たくさんある道具をどのように組み合わせるか

今回はコード設計編のつもりでしたが、ビルド周りを先にまとめることにしました。主にパフォーマンス上の都合ですが、心がけたポイントは次の点です。

  • 画一的なバンドルではなく、適切なバンドルを選択的に配信できるようにする
  • 適当な粒度で Code Splitting できるようにする
  • ひとつのツールに何でもかんでもやらせない( webpack、お前のことだよ!)
  • ビルドのパイプラインを短く、シンプルに済ませる(できることを全てやろうとしない)

タスクランナーは前回述べた通り make を利用しています。同僚が使っているのを見てパクりましたが Self-Documented Makefile の手法が、タスク名忘れに優しくてよかったです。 npm run したら npm scripts が一覧で出てくるのと似たようなやつです。

このシリーズの他の記事はこちら。

出力される成果物

生成している主な成果物は次のとおりです。

  • クライアント JavaScript 一式
    • bootstrap.js ( ブートストラップ用のメインバンドル )
    • ほにゃららRoute.js * n ( Code Splitting された route 単位のバンドル )
    • vendor.js ( react などライブラリ系バンドル )
  • compat 版クライアント JavaScript 一式( 古めのブラウザ向けバージョン )
  • サーバーサイド JavaScript 一式
  • sw.js ( ServiceWorker )
  • styles.css ( 結合済み CSS )

ビルドプロセスの概要

成果物を生成するのに、次のようなビルドプロセスを用意しました。

  1. クライアント JavaScript
    • TypeScript を webpack で変換&バンドル
    • vendor 系を Commons Chunk したり、route 単位で Dynamic Import したり
    • 同じプロセスで設定を少し変えて compat 版も処理
  2. サーバーサイド JavaScript
    • TypeScript を tsc で変換
  3. ServiceWorker
    • rollup でバンドルし、workbox で precache 定義を差し込み
    • SW 対応しているブラウザ向けなので babel なども一切なし
  4. CSS
    • postcss-cli で実行可能な CSS に変換& CSS Modules 用の json を出力
    • 配信用に concat して source maps を生成

実行環境に合わせてアウトプットの ES バージョンを変える

サーバーやモダンブラウザは syntax レベルの変換があまり必要ではありません。ところが Babel などでサポート対象環境の下限(IE11とか)に合わせて素直に設定すると、多くのブラウザにとっては冗長なコードが得られてしまいます。

それを避けるために今回は、比較的モダンなブラウザには ES2015 水準のバンドルを提供し、レガシーなブラウザには ES5 水準のバンドルを提供します。node はもっとエッジに ES2017 水準で実行します。

ES の対応レベルにおけるレガシーブラウザには googlebot の Chrome M41 も含まれることに注意が必要です。

TypeScript の compilerOptions.target を分岐

デフォルトを node サーバー向けとして、client.compat は主に IE11 用、client はそれ以外のモダンブラウザ用の設定としています。

// tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    ...
    "target": "ES2017",
    "lib": [
      "es2017",
      "dom"
    ],
    ...
  },
  ...
}

// tsconfig.client.json ( webpack 越しに利用 )
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "target": "es2015",
    "module": "esnext",
    "moduleResolution": "node"
  }
}

// tsconfig.client.compat.json ( webpack 越しに利用 )
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "moduleResolution": "node"
  }
}

tsconfig.json を複数用意してコンパイル時の target を使い分けて指定しています。あと、webpack には ES moduels のままパスしつつ依存解決は node 風なのでクライアント向け設定は module: esnext, moduleResolution: node です。

今回は webpack 内で TypeScript と Babel の多段変換をしたくなかったので TypeScript の設定のみで大雑把に出力を分けていますが、babel-preset-env を利用すると browserslist のクエリでより厳密に変換内容を指定できます。

リクエスト元ブラウザに応じた過不足のない Polyfill 配信

api の対応状況をみながら polyfill について細かく追加、削除等を管理するのは億劫なので Polyfill.io に委ねています。前述した TypeScript のコンパイル設定で syntax レベルの互換性は解決されますが、例えば Array.includes()Object.entries() のような api レベルの互換性は別で解決が必要なためです。

<script src="https://cdn.polyfill.io/v2/polyfill.js?features=default-3.4,Array.prototype.includes,Object.entries"></script>

このように読み込めば デフォルトセット 3.4 のうち UserAgent から足りない API を判別して必要な分の Polyfill だけをバンドルして配信してくれます。Examples を見るとどういうことか分かりやすいはずです。

Code Splitting の設定

たくさんの JavaScript を1バンドルに叩き込んで肥大化させるとパフォーマンス問題を起こしうるというのは繰り返し述べてきていますが、webpack を使う最大の理由はその問題を解決(ないし緩和)する Code Splitting の存在です。

CommonsChunkPlugin と Dynamic Import

App Shell モデルの素振り(前編)webpack と Workbox を利用した構築に記載したのと同様の方法で CommonsChunkPlugin で vendor.js を生成し、Dynamic Import でルート単位のチャンクファイルを生成しています。

Dynamic Import 部分は次の例ではルート単位といいつつ諸般の事情で container の index をロードしていますが、ともかくこのようなカタチです。前述の compat ビルドのときは、Dynamic Import の対象も compat ファイルになるので、その分岐が含まれます。

async function dynamicRouteLoad(containerName: string): Promise<any> {
  if (process.title === 'browser') {
    const requireCompatSuffix = process.env.COMPAT === 'true';

    if (requireCompatSuffix) {
      return import(/* webpackChunkName: "[request]" */ `./containers/${containerName}/index.compat`);
    } else {
      return import(/* webpackChunkName: "[request]" */ `./containers/${containerName}/index`);
    }
  } else {
    return require(`./containers/${containerName}/index`);
  }
}

ブラウザ ES Modules は検討の結果見送り

ES Modules というかいわゆる <script type="module"> は採用を見送りました。Loading Performance with (Many) Modules: Summary as of Oct 7, 2017 を参考にする限り、今回のアプリケーションはモジュール数、依存解決の階層ともに閾値をオーバーするため劇的な効果は望めなかったからです。

芋づる式にしか辿れないモジュール解決(require.js って懐かしいですね!)をサーバー push で先読みさせようにも対象数が多くてソリューションにも乏しいので素直にバンドルしています。正直、リアルなプロダクトコードで試さないとベンチマークのしようもないので、まずは Code Splitting と precache を有効利用して手堅く作り切ってから、気が向いたら検証をしてみようと思います。

webpack の利用を最小限に抑制

webpack の芸風があまり好きではありません ...というのも多大な理由ですが、背に腹は代えられず実用レベルの Code Splitting を求めるとやはり webpack かなという状況です。せめて webpack への依存度を下げようという考えでプラグインの利用を控えています。今使ってるのは次の3つ。

  • webpack-clean-obsolete-chunks
  • uglifyjs-webpack-plugin
  • copy-webpack-plugin ( ついでに静的ファイルのコピーもしてるが交換可能な程度... )

今後、より良い解決方法が表れたときに webpack を他の何かに交換可能にするため、クライアントサイドスクリプトのモジュールバンドラという役割に限定した結果です。CSS Modules の扱いも分離してあるので、サーバーサイド用のスクリプトにも関与していません。

なにげに Babel 使わずにフィニッシュ

というような感じです。polyfill.io さんありがとう。

compat 周りの開発時の安全性担保としては browserslist クエリの定義を使い回すかたちで

あたりも使ってみたかったのですが、そもそも eslint じゃなくて tslint メインだったり、ほとんどは autoprefixer で supported に変換できたり、といった事情でそれぞれ採用を見送っています。

次こそコード設計編になるはず...。