現在のウェブ アプリケーションは、特に JavaScript 部分が非常に大きくなる可能性があります。2018 年半ばの時点で、HTTP Archive ではモバイル デバイス上の JavaScript の転送サイズの中央値は約 350 KB となっています。これは転送サイズに限った話です。JavaScript はネットワーク経由で送信される際に圧縮されることがよくあります。つまり、ブラウザで解凍された後の JavaScript の実際の量は、かなり多くなります。これは重要なポイントです。リソースの処理に関しては、圧縮は関係ありません。解凍した JavaScript が 900 KB の場合、圧縮すると約 300 KB になるとしても、パーサーとコンパイラには 900 KB になります。
JavaScript は処理にコストがかかるリソースです。ダウンロード後に比較的わずかなデコード時間しか発生しない画像とは異なり、JavaScript は解析、コンパイル、最終的な実行が必要です。バイト単位で比較すると、JavaScript は他のタイプのリソースよりも費用が高くなります。
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 ページ アプリも用意されています。必要に応じてクローンを作成して、このガイドに沿って操作を進めることができます。ただし、このガイドではすべての手順を説明しているため、クローンを作成する必要はありません(ハンズオン ラーニングを希望する場合を除きます)。
サンプルアプリは、ギター エフェクト ペダルの検索可能なデータベースです。クエリを入力すると、エフェクト ペダルのリストが表示されます。
このアプリケーションの原動力となる動作はベンダー(Preact や Emotion)とアプリ固有のコードバンドル(webpack では「チャンク」と呼ばれます)を作成します。
上の図に示す JavaScript バンドルは製品版ビルドです。つまり、uglify によって最適化されています。アプリ固有のバンドルで 21.1 KB というのは悪いことではありませんが、ツリー シェイキングはまったく発生しないことに注意してください。アプリのコードを確認し、この問題を解決するためにできることを見てみましょう。
どのアプリでも、ツリー シェイキングの機会を見つけるには、静的 import
ステートメントを探す必要があります。メイン コンポーネント ファイルの上部に、次のような行があります。
import * as utils from "../../utils/utils";
ES6 モジュールはさまざまな方法でインポートできますが、以下のようなものに注目してください。この行は、「import
utils
モジュールのすべてを utils
という Namespace に配置します」と記述しています。ここで大きな疑問となるのは、「そのモジュールにはどれだけのものが含まれているのか?」ということです。
utils
モジュールのソースコードを見ると、コードは約 1,300 行あります。
これらすべてが必要ですか?utils
モジュールをインポートするメイン コンポーネント ファイルを検索して、その名前空間のインスタンスがいくつ表示されるかを確認しましょう。
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 Miller、Addy Osmani、Jeff Posnick、Sam Saccone、Philip Walton の貴重なフィードバックにより、この記事の質を大幅に向上させることができました。深く感謝いたします。