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

現在のウェブ アプリケーションは、特に JavaScript 部分が非常に大きくなる可能性があります。2018 年半ばの時点で、HTTP Archive ではモバイル デバイス上の JavaScript の転送サイズの中央値は約 350 KB となっています。これは転送サイズに限った話です。JavaScript はネットワーク経由で送信される際に圧縮されることがよくあります。つまり、ブラウザで解凍された後の JavaScript の実際の量は、かなり多くなります。これは重要なポイントです。リソースの処理に関しては、圧縮は関係ありません。圧縮解除された 900 KB の JavaScript は、圧縮すると約 300 KB になるかもしれませんが、パーサーとコンパイラにとっては 900 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 をチャンクに分割し、それらのチャンクを必要とするアプリケーションのルートにのみ提供することによってパフォーマンスを向上させる手法の一つです。

この手法は有効ですが、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)とアプリ固有のコードバンドル(webpack では「チャンク」と呼ばれます)を作成します。

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

上の図に示す JavaScript バンドルは製品版ビルドです。つまり、uglify によって最適化されています。アプリ固有のバンドルで 21.1 KB というのは悪くありませんが、なお、ツリー シェイキングはまったく行われていません。アプリのコードを確認し、この問題を解決するためにできることを見てみましょう。

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

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

ES6 モジュールはさまざまな方法でインポートできますが、このような方法は注意が必要です。この行は、「import utils モジュールのすべてutils という Namespace に配置します」と記述しています。ここで大きな疑問となるのは、「そのモジュールにはどれだけのものが含まれているのか?」ということです。

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

これらのすべてが必要ですか?utils モジュールをインポートするメイン コンポーネント ファイルを検索して、その名前空間のインスタンスがいくつ表示されるかを確認しましょう。

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

utils 名前空間は、アプリケーションの 3 か所にのみ存在しますが、どの関数で使用されているのでしょうか。メイン コンポーネント ファイルに目を向けると、関数は utils.simpleSort のみです。これは、並べ替えプルダウンが変更されたときに、検索結果リストをさまざまな条件で並べ替えるために使用されます。

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 が依存関係ツリーを分析して、未使用の依存関係を削除できるようになります。

副作用に注意する

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

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"]

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

副作用は 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 構成でこのフラグを指定することもできます

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

ES6 モジュールをそのままにするよう Babel に指示した後、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 の貴重なフィードバックにより、この記事の質を大幅に向上させることができました。深く感謝いたします。