ツリー シェイキングで JavaScript のペイロードを削減する

今日のウェブ アプリケーション、特に JavaScript の部分は、かなり大きくなることがあります。2018 年半ばの時点で、HTTP Archive のモバイル デバイス上の JavaScript の転送サイズの中央値は約 350 KB です。これは単なる転送サイズです。JavaScript はネットワーク経由で送信されるときに圧縮される場合が多いため、ブラウザが圧縮解除した JavaScript の実際の量は、かなりの量になります。リソースの処理に関しては、圧縮は無関係であるため、指摘することは重要です。JavaScript を解凍すると 900 KB ですが、パーサーとコンパイラでは 900 KB になりますが、圧縮すると約 300 KB になることもあります。

JavaScript のダウンロード、解凍、解析、コンパイル、実行のプロセスを表す図。
JavaScript をダウンロードして実行するプロセス。スクリプトの転送サイズが 300 KB 圧縮されていても、解析、コンパイル、実行が必要な JavaScript は 900 KB です。

JavaScript は処理にコストがかかるリソースです。ダウンロード後のデコード時間が比較的短い画像とは異なり、JavaScript は解析、コンパイルし、最後に実行する必要があります。バイト単位なので、JavaScript のコストは他の種類のリソースよりも高くなります。

170 KB の JavaScript と同等のサイズの JPEG 画像の処理時間を比較した図。JavaScript リソースは、JPEG よりもはるかに多くのリソースを消費します。
170 KB の JavaScript を解析/コンパイルする処理費用と、同じサイズの JPEG のデコード時間を比較した場合の費用の比較。(出典)。

JavaScript エンジンの効率を改善するための改善は継続的に行われていますが、JavaScript のパフォーマンスを向上させることは開発者にとって常に課題です。

そのために、JavaScript のパフォーマンスを向上させる方法があります。コード分割は、アプリケーションの JavaScript をチャンクに分割し、そのチャンクを必要とするアプリケーションのルートにのみ提供することでパフォーマンスを改善する手法の 1 つです。

この手法は有効ですが、使用されないコードが含まれているという、JavaScript を多用するアプリケーションの一般的な問題には対応できません。この問題を解決しようとする試みがツリー シェイキングです。

ツリー シェイキングとは

ツリー シェイキングとは、コードによる死滅を一掃することです。この用語は Rollup で広まりましたが、デッドコードの排除という概念は以前から存在していました。このコンセプトは webpack でも購入されています。この記事では、サンプルアプリを使ってこれについて説明しています。

「ツリー シェイキング」という用語は、アプリケーションとその依存関係がツリー状の構造になっているというメンタルモデルに由来します。ツリーの各ノードは、アプリに個別の機能を提供する依存関係を表します。最新のアプリでは、これらの依存関係は、次のように静的な import ステートメントによって取り込まれます。

// Import all the array utilities!
import arrayUtils from "array-utils";

アプリの新しさや新生児には、依存関係がほとんどない場合があります。また、追加した依存関係のすべてではないにしても、ほとんどを使用します。ただし、アプリが成熟するにつれて、依存関係が追加される可能性があります。さらに厄介なことに、古い依存関係は使われなくなりますが、コードベースからプルーニングされない可能性があります。その結果、アプリで未使用の JavaScript が大量に含まれることになります。ツリー シェイキングは、静的な import ステートメントが ES6 モジュールの特定の部分を取り込む方法を活用することで、これに対処します。

// Import only some of the utilities!
import { unique, implode, explode } from "array-utils";

この import の例と前の例の違いは、この例では、"array-utils" モジュールからすべてをインポートするのではなく、大量のコードになる可能性があるため、その特定の部分だけをインポートしていることです。開発環境ビルドでは、モジュール全体がインポートされるため、変更はありません。製品版ビルドでは、明示的にインポートされていない ES6 モジュールからのエクスポートを「シェイク」するように Webpack を構成することで、製品版ビルドのサイズを小さくできます。このガイドでは、その方法について説明します。

木を振る機会を見つける

説明目的で、ツリー シェイキングの仕組みを示すサンプル 1 ページ アプリをご利用いただけます。必要に応じてクローンを作成し、順を追って進めることもできますが、このガイドではすべてのステップをまとめて扱うので、(ハンズオン学習以外の)クローンは必要ありません。

サンプルアプリは、検索可能なギター エフェクト ペダルのデータベースです。クエリを入力すると、エフェクト ペダルのリストが表示されます。

ギター エフェクト ペダルのデータベースを検索する 1 ページのサンプル アプリケーションのスクリーンショット。
サンプルアプリのスクリーンショット。

このアプリの動作はベンダー(PreactEmotion など)と、アプリ固有のコードバンドル(ウェブパックでは「チャンク」)です。

Chrome の DevTools のネットワーク パネルに表示された 2 つのアプリケーション コード バンドル(チャンク)のスクリーンショット。
アプリの 2 つの JavaScript バンドル。これらは非圧縮サイズです。

上図の JavaScript バンドルは製品版ビルドであり、uglification を通じて最適化されています。アプリ固有のバンドルの 21.1 KB は悪いものではありませんが、ツリー シェイキングはまったく発生していません。アプリコードを確認して、修正方法を確認しましょう。

どのようなアプリでも、ツリー シェイキングの機会を見つけるためには、静的な import ステートメントを探す必要があります。メイン コンポーネント ファイルの上部に、次のような行があります。

import * as utils from "../../utils/utils";

ES6 モジュールはさまざまな方法でインポートできますが、このような場合は注意が必要です。この特定の行は、「utils モジュールのすべてimport し、utils という名前空間に配置する」というものです。ここで問うべき大きな問題は、「そのモジュールにどれだけのものが含まれているのか」ということです。

utils モジュールのソースコードを見ると、約 1,300 行のコードがあります。

それらはすべて必要ですか?utils モジュールをインポートするメイン コンポーネント ファイルを検索して、その名前空間のインスタンス数をもう一度確認してみましょう。

テキスト エディタで「utils.」を検索して、3 件の結果のみが返されている様子のスクリーンショット。
大量のモジュールをインポートしてきた utils 名前空間は、メイン コンポーネント ファイル内で 3 回しか呼び出されません。

実際のところ、utils 名前空間はアプリ内の 3 つの場所にしか表示されませんが、どのような関数でしょうか。メイン コンポーネント ファイルをもう一度見ると、utils.simpleSort という 1 つの関数しかないように見えます。この関数は、並べ替えのプルダウンが変更されたときに、複数の条件で検索結果のリストを並べ替えるために使用されます。

if (this.state.sortBy === "model") {
  // `simpleSort` gets used here...
  json = utils.simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  // ..and here...
  json = utils.simpleSort(json, "type", this.state.sortOrder);
} else {
  // ..and here.
  json = utils.simpleSort(json, "manufacturer", this.state.sortOrder);
}

大量の書き出しを含む 1,300 行のファイルのうち、1 つだけが使用されている。その結果、未使用の JavaScript が大量に配布されます。

このサンプル アプリは若干工夫されていますが、実際のところ、この合成シナリオが本番環境のウェブアプリで直面する可能性のある実際の最適化の機会に似ているという事実は変わりません。ツリー シェイキングが役立つ可能性を特定したところで、実際にどのように行われるのでしょうか。

Babel が ES6 モジュールを CommonJS モジュールにトランスパイルしないようにする

Babel は欠かせないツールですが、木の揺れの影響を観察するのが少し難しくなる可能性があります。@babel/preset-env を使用している場合、Babel は ES6 モジュールを、より幅広く互換性のある CommonJS モジュール(import ではなく require のモジュール)に変換する可能性があります。

ツリー シェイキングは CommonJS モジュールではより難しいため、webpack はバンドルを使用する場合に何をバンドルから取り除くべきか判断できません。この問題を解決するには、ES6 モジュールを明示的に残すように @babel/preset-env を構成します。Babel を構成する場所(babel.config.js でも package.json でも)には、少し必要なものを追加する必要があります。

// babel.config.js
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

@babel/preset-env 構成で modules: false を指定すると、Babel が意図したとおりに動作し、Webpack が依存関係ツリーを分析して、使用されていない依存関係を除外できるようになります。

副作用を念頭に置く

アプリから依存関係を変更するときに考慮すべきもう一つの側面は、プロジェクトのモジュールに副作用があるかどうかです。副作用の例としては、関数が自身のスコープ外にあるものを変更する場合があります。これは実行の副作用です。

let fruits = ["apple", "orange", "pear"];

console.log(fruits); // (3) ["apple", "orange", "pear"]

const addFruit = function(fruit) {
  fruits.push(fruit);
};

addFruit("kiwi");

console.log(fruits); // (4) ["apple", "orange", "pear", "kiwi"]

この例では、スコープ外である fruits 配列を変更すると、addFruit によって副作用が発生します。

副作用は ES6 モジュールにも適用され、ツリー シェイキングのコンテキストで重要になります。予測可能な入力を受け取り、自身のスコープ外の要素を変更せずに同様に予測可能な出力を生成するモジュールは依存関係であり、使用しない場合は安全に破棄できます。自己完結型のモジュール式のコードです。したがって「モジュール」です。

webpack については、プロジェクトの package.json ファイルで "sideEffects": false を指定することで、パッケージとその依存関係に副作用がないことを示すヒントを使用できます。

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": false
}

または、副作用がない特定のファイルを Webpack に指定することもできます。

{
  "name": "webpack-tree-shaking-example",
  "version": "1.0.0",
  "sideEffects": [
    "./src/utils/utils.js"
  ]
}

後者の例では、指定されていないファイルには副作用がないとみなされます。これを package.json ファイルに追加しない場合は、module.rules を使用して webpack 構成でこのフラグを指定することもできます

必要なものだけをインポートする

Babel に ES6 モジュールをそのまま残すよう指示した後、utils モジュールから必要な関数のみを取り込むために、import 構文を少し調整する必要があります。このガイドの例で必要なのは simpleSort 関数のみです。

import { simpleSort } from "../../utils/utils";

utils モジュール全体ではなく simpleSort のみがインポートされるため、utils.simpleSort のすべてのインスタンスを simpleSort に変更する必要があります。

if (this.state.sortBy === "model") {
  json = simpleSort(json, "model", this.state.sortOrder);
} else if (this.state.sortBy === "type") {
  json = simpleSort(json, "type", this.state.sortOrder);
} else {
  json = simpleSort(json, "manufacturer", this.state.sortOrder);
}

この例では、ツリー シェイキングが機能するために必要なのはこれだけです。これは、依存関係ツリーをシェイクする前の webpack の出力です。

                 Asset      Size  Chunks             Chunk Names
js/vendors.16262743.js  37.1 KiB       0  [emitted]  vendors
   js/main.797ebb8b.js  20.8 KiB       1  [emitted]  main

以下は、ツリー シェイキングに成功したの出力です。

                 Asset      Size  Chunks             Chunk Names
js/vendors.45ce9b64.js  36.9 KiB       0  [emitted]  vendors
   js/main.559652be.js  8.46 KiB       1  [emitted]  main

どちらのバンドルも短くなっていますが、実際には main バンドルが大きなメリットとなります。utils モジュールの未使用部分を取り除くことで、main バンドルを約 60% 小さくしました。これにより、スクリプトからダウンロードまでの時間が短縮されるだけでなく、処理時間も短縮されます。

木を振ってください。

ツリー シェイキングからどれだけの距離を取るかは、アプリとその依存関係とアーキテクチャによって異なります。試してみましょう。この最適化を実行するようにモジュール バンドラを設定していないことを知っている場合は、試してみて、それがアプリケーションにもたらすメリットを確かめる必要はありません。

ツリー シェイキングによってパフォーマンスが大幅に向上する場合もありますが、まったく効果がない場合もあります。しかし、本番環境ビルドでこの最適化を活用できるようにビルドシステムを設定し、アプリケーションが必要とするものだけを選択的にインポートすることで、アプリケーション バンドルをできるだけ小さく抑えることができます。

貴重なフィードバックを提供してくれた Kristofer Baxter、Jason MillerAddy OsmaniJeff Posnick、Sam Saccone、Philip Walton に感謝します。この記事の質は大幅に向上しました。