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

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

<ph type="x-smartling-placeholder">
</ph> JavaScript のダウンロード、解凍、解析、コンパイル、実行のプロセスを示す図。 <ph type="x-smartling-placeholder">
</ph> JavaScript をダウンロードして実行するプロセス。スクリプトの転送サイズが 300 KB 圧縮されていても、解析、コンパイル、実行が必要な JavaScript は 900 KB です。

JavaScript は処理にコストがかかるリソースです。ダウンロード後比較的簡単なデコード時間しか発生しない画像とは異なり、JavaScript は解析、コンパイルされ、最後に実行されなければなりません。バイト単位のため、他のタイプのリソースよりも JavaScript のコストが高くなります。

<ph type="x-smartling-placeholder">
</ph> 170 KB の JavaScript の処理時間と、同等のサイズの JPEG 画像の処理時間を比較した図。JavaScript リソースは、JPEG よりもはるかに多くのリソースをバイト単位で消費します。 <ph type="x-smartling-placeholder">
</ph> 170 KB の JavaScript を解析/コンパイルする場合の処理コストと、同等のサイズの JPEG のデコード時間(出典)。

JavaScript エンジンの効率を向上させる改善が継続的に行われていますが、JavaScript のパフォーマンスの改善は、通常どおり、デベロッパーにとっての課題です。

そのために、JavaScript のパフォーマンスを向上させる手法があります。コード分割は、そのような手法の 1 つで、アプリケーションの 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" モジュールからすべて(大量のコードが含まれる可能性がある)をインポートするのではなく、その特定の部分だけをインポートする例です。dev ビルドでは、変更に関係なくモジュール全体がインポートされるため、何も変更されません。本番環境のビルドでは、webpack を「シェイク」するように構成できる明示的にインポートされなかった ES6 モジュールからのエクスポートをオフにし、本番環境ビルドのサイズを小さくしました。このガイドでは、そのための方法について説明します。

木を揺らす機会を見つける

説明を目的として、ツリー シェイキングの仕組みを示したサンプルの 1 ページのアプリをご利用いただけます。必要に応じてクローンを作成し、それに沿って操作することもできますが、このガイドではすべての手順を一緒に説明するので、クローンの作成は必要ありません(ハンズオン学習が必要な場合を除きます)。

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

<ph type="x-smartling-placeholder">
</ph> ギターエフェクトペダルのデータベースを検索するためのワンページサンプルアプリケーションのスクリーンショット。 <ph type="x-smartling-placeholder">
</ph> サンプルアプリのスクリーンショット。
をご覧ください。

このアプリケーションの原動力となる動作はベンダー(PreactEmotion など)と、アプリ固有のコードバンドル(webpack と呼ぶように「チャンク」)では次のように記述します。

<ph type="x-smartling-placeholder">
</ph> Chrome の DevTools のネットワーク パネルに表示された 2 つのアプリケーション コード バンドル(またはチャンク)のスクリーンショット。 <ph type="x-smartling-placeholder">
</ph> このアプリの 2 つの JavaScript バンドル。これらは非圧縮サイズです。

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

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

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

ES6 モジュールはさまざまな方法でインポートできますが、以下のようなものに注目してください。この行には、「utils モジュールのすべてimport し、utils という名前空間に配置します」と記述されています。ここで重要な問いは、「このモジュールに含まれる内容はどの程度なのか?」ということです。

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

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

<ph type="x-smartling-placeholder">
</ph> テキスト エディタで「utils.」を検索して、3 件の結果のみを返すスクリーンショット。 <ph type="x-smartling-placeholder">
</ph> 大量のモジュールをインポートした 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 が依存関係ツリーを分析し、使用されていない依存関係を取り除くことができます。

副作用を念頭に置く

アプリから依存関係をシェイクする際に考慮すべきもう 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 の貴重なフィードバックのおかげで、この記事の品質が大幅に向上しました。