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

今日のウェブ アプリケーションは、特に JavaScript 部分がかなり大きくなる可能性があります。HTTP Archive によると、2018 年半ばの時点で、モバイル デバイス上の JavaScript の転送サイズの中央値は約 350 KB でした。これは転送サイズにすぎません。JavaScript は、ネットワーク経由で送信されるときに圧縮されることが多いため、ブラウザで解凍された後の JavaScript の実際の量はかなり多くなります。 リソースの処理に関する限り、圧縮は関係ないため、この点を指摘しておくことが重要です。解凍された JavaScript が 900 KB の場合、圧縮すると約 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" モジュールから すべて をインポートするのではなく(多くのコードになる可能性があります)、この例では特定の部分のみをインポートしていることです。開発ビルドでは、モジュール全体がインポートされるため、何も変わりません。本番環境ビルドでは、webpack を構成して、明示的にインポートされていない ES6 モジュールからエクスポートを「シェイク」し、本番環境ビルドを小さくすることができます。このガイドでは、その方法について説明します。

ツリーをシェイクする機会を見つける

説明のために、ツリーシェイキングの仕組みを示すサンプル 1 ページアプリを用意しました。クローンして試すこともできますが、このガイドではすべての手順を説明しますので、クローンする必要はありません(ハンズオン ラーニングがお好きな場合を除く)。

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

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

このアプリを駆動する動作は、ベンダー(PreactEmotion)とアプリ固有のコードバンドル(webpack では「チャンク」と呼ばれます)に分割されます。

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

上の図に示す JavaScript バンドルは本番環境ビルドであり、難読化によって最適化されています。アプリ固有のバンドルが 21.1 KB というのは悪くありませんが、ツリーシェイキングはまったく行われていないことに注意してください。アプリのコードを見て、修正する方法を確認しましょう。

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

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

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

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

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

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

実際には、utils 名前空間はアプリ内の 3 か所にしかありませんが、どの関数に使用されていますか?メイン コンポーネント ファイルをもう一度見てみると、1 つの関数のみのようです。これは、並べ替えプルダウンが変更されたときに、検索結果リストをいくつかの条件で並べ替えるために使用される 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 に心より感謝いたします。