今日のウェブ アプリケーション、特に JavaScript の部分はかなり大きくなる可能性があります。2018 年半ばの時点で、HTTP Archive によるモバイル デバイス上の JavaScript の転送サイズの中央値は約 350 KB です。これは単なる転送サイズですJavaScript はネットワーク経由で送信される際に圧縮されることが多いため、ブラウザが解凍すると、実際の JavaScript の量はかなり多くなります。リソースの処理に関して言えば、圧縮は無関係であるため、この点は重要です。解凍した JavaScript が 900 KB の場合、圧縮すると約 300 KB になるとしても、パーサーとコンパイラには 900 KB になります。
<ph type="x-smartling-placeholder">JavaScript は処理にコストがかかるリソースです。ダウンロード後比較的簡単なデコード時間しか発生しない画像とは異なり、JavaScript は解析、コンパイルされ、最後に実行されなければなりません。バイト単位のため、他のタイプのリソースよりも JavaScript のコストが高くなります。
<ph type="x-smartling-placeholder">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">このアプリケーションの原動力となる動作はベンダー(Preact や Emotion など)と、アプリ固有のコードバンドル(webpack と呼ぶように「チャンク」)では次のように記述します。
<ph type="x-smartling-placeholder">上の図に示す JavaScript バンドルは本番環境ビルドです。つまり、uglification を通じて最適化されています。アプリ固有のバンドルで 21.1 KB というのは悪いことではありませんが、ツリー シェイキングはまったく発生しないことに注意してください。アプリコードを見て、修正方法を見てみましょう。
どのようなアプリケーションでも、ツリー シェイキングの機会を見つけるには、静的な import
ステートメントを探す必要があります。メイン コンポーネント ファイルの先頭付近に、次のような行が表示されます。
import * as utils from "../../utils/utils";
ES6 モジュールはさまざまな方法でインポートできますが、以下のようなものに注目してください。この行には、「utils
モジュールのすべてを import
し、utils
という名前空間に配置します」と記述されています。ここで重要な問いは、「このモジュールに含まれる内容はどの程度なのか?」ということです。
utils
モジュールのソースコードを見ると、約 1,300 行あるコードがあります。
これらすべてが必要ですか?utils
モジュールをインポートするメイン コンポーネント ファイルを検索して、この名前空間のインスタンスの数を確認してみましょう。
結局のところ、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 Miller、Addy Osmani、Jeff Posnick、Sam Saccone、Philip Walton の貴重なフィードバックのおかげで、この記事の品質が大幅に向上しました。