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