ツリーシェイク:リファレンスガイド
公開: 2022-03-10ツリーシェーキングとは何か、そしてそれを成功させるための準備をする方法を学ぶ旅を始める前に、JavaScriptエコシステムにどのモジュールがあるかを理解する必要があります。
JavaScriptプログラムは、その初期の頃から、複雑さと実行するタスクの数が増えてきました。 このようなタスクを閉じた実行範囲に区分する必要があることが明らかになりました。 これらのタスクのコンパートメント、つまり値は、モジュールと呼ばれるものです。 それらの主な目的は、繰り返しを防ぎ、再利用性を活用することです。 そのため、アーキテクチャは、そのような特別な種類のスコープを許可し、それらの値とタスクを公開し、外部の値とタスクを消費するように考案されました。
モジュールとは何か、およびそれらがどのように機能するかを深く掘り下げるために、「ESモジュール:漫画の詳細」をお勧めします。 しかし、ツリーの揺れとモジュールの消費のニュアンスを理解するには、上記の定義で十分です。
ツリーシェイクは実際にはどういう意味ですか?
簡単に言えば、ツリーシェイクとは、到達不能コード(デッドコードとも呼ばれます)をバンドルから削除することを意味します。 Webpackバージョン3のドキュメントには次のように記載されています。
「アプリケーションをツリーとして想像することができます。 実際に使用するソースコードとライブラリは、木の緑の葉を表しています。 デッドコードは、秋までに消費される木の茶色の枯れ葉を表します。 枯れ葉を取り除くには、木を振って倒す必要があります。」
この用語は、ロールアップチームによってフロントエンドコミュニティで最初に普及しました。 しかし、すべての動的言語の作成者は、ずっと以前からこの問題に苦労してきました。 ツリーシェイクアルゴリズムのアイデアは、少なくとも1990年代初頭にさかのぼることができます。
JavaScriptの世界では、以前はES6として知られていたES2015のECMAScriptモジュール(ESM)仕様以降、ツリーの揺れが可能になりました。 それ以来、ツリーシェーキングは、プログラムの動作を変更せずに出力サイズを縮小するため、ほとんどのバンドラーでデフォルトで有効になっています。
これの主な理由は、ESMが本質的に静的であるということです。 それが何を意味するのかを分析してみましょう。
ESモジュールとCommonJS
CommonJSは、ESM仕様より数年前のものです。 JavaScriptエコシステムでの再利用可能なモジュールのサポートの欠如に対処するようになりました。 CommonJSには、提供されたパスに基づいて外部モジュールをフェッチするrequire()
関数があり、実行時にスコープに追加します。
そのrequire
は、プログラム内の他のfunction
と同様に、コンパイル時に呼び出し結果を評価するのを十分に困難にする関数です。 その上、コード内のどこにでもrequire
呼び出しを追加できるという事実があります。別の関数呼び出し、if / elseステートメント、switchステートメントなどにラップされています。
CommonJSアーキテクチャーの幅広い採用に起因する学習と苦労により、ESM仕様は、モジュールがそれぞれのキーワードimport
およびexport
によってインポートおよびエクスポートされるこの新しいアーキテクチャーに落ち着きました。 したがって、これ以上機能的な呼び出しはありません。 ESMは、トップレベルの宣言としてのみ許可されます。静的であるため、他の構造にネストすることはできません。ESMは実行時の実行に依存しません。
範囲と副作用
ただし、膨満感を回避するためにツリーシェイクが克服しなければならない別のハードルがあります。それは副作用です。 関数は、実行範囲外の要因を変更または依存する場合、副作用があると見なされます。 副作用のある関数は不純と見なされます。 純粋関数は、それが実行されたコンテキストや環境に関係なく、常に同じ結果をもたらします。
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
バンドラーは、モジュールが純粋であるかどうかを判断するために、提供されたコードを可能な限り評価することで目的を果たします。 しかし、コンパイル時またはバンドル時のコード評価は、これまでのところしかできません。 したがって、完全に到達不能であっても、副作用のあるパッケージを適切に排除することはできないと想定されます。
このため、バンドラーはモジュールのpackage.json
ファイル内のキーを受け入れるようになりました。これにより、開発者はモジュールに副作用がないかどうかを宣言できます。 このようにして、開発者はコード評価をオプトアウトし、バンドラーにヒントを与えることができます。 到達可能なインポートがない場合、またはそれにリンクするステートメントrequire
場合は、特定のパッケージ内のコードを削除できます。 これにより、バンドルがスリムになるだけでなく、コンパイル時間が短縮されます。
{ "name": "my-package", "sideEffects": false }
したがって、パッケージ開発者の場合は、公開する前にsideEffects
を慎重に使用し、もちろん、予期しない重大な変更を避けるために、リリースごとに改訂してください。
ルートsideEffects
キーに加えて、メソッド呼び出しにインラインコメント/*@__PURE__*/
を注釈することにより、ファイルごとに純度を決定することもできます。
const x = */@__PURE__*/eliminated_if_not_called()
このインラインアノテーションは、パッケージがsideEffects: false
を宣言していない場合、またはライブラリが特定のメソッドに実際に副作用を示している場合に実行される、コンシューマー開発者にとってのエスケープハッチであると考えています。
Webpackの最適化
バージョン4以降、Webpackは、ベストプラクティスを機能させるために必要な構成を段階的に減らしてきました。 いくつかのプラグインの機能がコアに組み込まれました。 また、開発チームはバンドルサイズを非常に重要視しているため、ツリーの揺れが容易になりました。
あなたがあまりいじくり回していない場合、またはアプリケーションに特別なケースがない場合、依存関係をツリーシェイクするのはたった1行の問題です。
webpack.config.js
ファイルには、 mode
という名前のルートプロパティがあります。 このプロパティの値がproduction
環境である場合は常に、ツリーを揺るがし、モジュールを完全に最適化します。 TerserPlugin
でデッドコードを排除することに加えて、 mode: 'production'
は、モジュールとチャンクの決定論的なマングル名を有効にし、次のプラグインをアクティブにします。
- 依存関係の使用にフラグを立てます。
- フラグに含まれるチャンク、
- モジュールの連結、
- エラー時に放出されません。
トリガー値がproduction
であるのは偶然ではありません。 問題のデバッグがはるかに困難になるため、開発環境で依存関係を完全に最適化することは望ましくありません。 したがって、2つのアプローチのいずれかでそれを実行することをお勧めします。
一方では、 mode
フラグをWebpackコマンドラインインターフェイスに渡すことができます。
# This will override the setting in your webpack.config.js webpack --mode=production
または、 webpack.config.js
でprocess.env.NODE_ENV
変数を使用することもできます。
mode: process.env.NODE_ENV === 'production' ? 'production' : development
この場合、デプロイメントパイプラインで--NODE_ENV=production
を渡すことを忘れないでください。
どちらのアプローチも、Webpackバージョン3以下のよく知られたdefinePlugin
を抽象化したものです。 どのオプションを選択しても、まったく違いはありません。
Webpackバージョン3以下
このセクションのシナリオと例は、最近のバージョンのWebpackやその他のバンドラーには適用されない可能性があることに注意してください。 このセクションでは、Terserの代わりにUglifyJSバージョン2の使用について検討します。 UglifyJSはTerserがフォークされたパッケージであるため、コード評価はそれらの間で異なる可能性があります。
Webpackバージョン3以下はpackage.json
のsideEffects
プロパティをサポートしていないため、コードを削除する前にすべてのパッケージを完全に評価する必要があります。 これだけではアプローチの効果が低下しますが、いくつかの注意事項も考慮する必要があります。
上記のように、パッケージがグローバルスコープを改ざんしている場合、コンパイラはそれ自体を検出する方法がありません。 しかし、それがツリーシェイクをスキップする唯一の状況ではありません。 よりあいまいなシナリオがあります。
Webpackのドキュメントからこのパッケージの例を見てください。
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
そして、これがコンシューマーバンドルのエントリーポイントです。
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
mylib.transform
が副作用を引き起こすかどうかを判断する方法はありません。 したがって、コードが削除されることはありません。
同様の結果を持つ他の状況は次のとおりです。
- コンパイラが検査できないサードパーティモジュールからの関数の呼び出し、
- サードパーティのモジュールからインポートされた関数を再エクスポートします。
コンパイラがツリーシェイクを機能させるのに役立つ可能性のあるツールは、babel-plugin-transform-importsです。 すべてのメンバーと名前付きエクスポートをデフォルトのエクスポートに分割し、モジュールを個別に評価できるようにします。
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
また、面倒なインポートステートメントを回避するように開発者に警告する構成プロパティもあります。 Webpackバージョン3以降を使用していて、基本構成でデューデリジェンスを行い、推奨プラグインを追加したが、バンドルがまだ肥大化しているように見える場合は、このパッケージを試してみることをお勧めします。
スコープの巻き上げとコンパイル時間
CommonJSの時代には、ほとんどのバンドラーは、各モジュールを別の関数宣言内にラップし、それらをオブジェクト内にマップするだけでした。 これは、他のマップオブジェクトと何ら変わりはありません。
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
静的に分析するのが難しいことは別として、これは基本的にESMと互換性がありません。これは、 import
ステートメントとexport
ステートメントをラップできないことがわかったためです。 したがって、今日では、バンドラーはすべてのモジュールをトップレベルに引き上げます。
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
このアプローチはESMと完全に互換性があります。 さらに、コード評価により、呼び出されていないモジュールを簡単に見つけてドロップすることができます。 このアプローチの注意点は、コンパイル中にすべてのステートメントにアクセスし、プロセス中にバンドルをメモリに格納するため、かなり長い時間がかかることです。 これが、バンドルのパフォーマンスがすべての人にとってさらに大きな関心事になり、コンパイルされた言語がWeb開発用のツールで活用されている大きな理由です。 たとえば、esbuildはGoで記述されたバンドラーであり、SWCはRustで記述されたTypeScriptコンパイラであり、これもRustで記述されたバンドラーであるSparkと統合されます。
スコープの巻き上げをよりよく理解するために、Parcelバージョン2のドキュメントを強くお勧めします。
時期尚早のトランスパイルを避ける
残念ながらかなり一般的であり、樹木を揺るがすために壊滅的である可能性がある1つの特定の問題があります。 つまり、特別なローダーを使用して、さまざまなコンパイラーをバンドラーに統合しているときに発生します。 一般的な組み合わせは、TypeScript、Babel、およびWebpackであり、すべての可能な順列です。
BabelとTypeScriptの両方に独自のコンパイラがあり、それぞれのローダーを使用すると、開発者はそれらを使用して簡単に統合できます。 そしてそこに隠された脅威があります。
これらのコンパイラは、コードを最適化する前にコードに到達します。 また、デフォルトであろうと設定ミスであろうと、これらのコンパイラーはESMではなくCommonJSモジュールを出力することがよくあります。 前のセクションで説明したように、CommonJSモジュールは動的であるため、デッドコードの除去を適切に評価することはできません。
このシナリオは、「同形」アプリ(つまり、サーバー側とクライアント側の両方で同じコードを実行するアプリ)の成長に伴い、最近さらに一般的になっています。 Node.jsはまだESMを標準でサポートしていないため、コンパイラーがnode
環境を対象としている場合、CommonJSを出力します。
したがって、最適化アルゴリズムが受信しているコードを必ず確認してください。
樹木を揺るがすチェックリスト
バンドルとツリーシェイクがどのように機能するかについての詳細がわかったので、現在の実装とコードベースを再検討するときに便利な場所に印刷できるチェックリストを作成しましょう。 うまくいけば、これにより時間を節約でき、コードの知覚パフォーマンスだけでなく、パイプラインのビルド時間も最適化できるようになります。
- ESMを使用し、独自のコードベースだけでなく、ESMを消耗品として出力するパッケージも使用します。
- 依存関係のどれが(もしあれば)
sideEffects
を宣言していないか、またはそれらをtrue
に設定しているかを正確に知っていることを確認してください。 - インラインアノテーションを使用して、副作用のあるパッケージを使用するときに純粋なメソッド呼び出しを宣言します。
- CommonJSモジュールを出力する場合は、インポートステートメントとエクスポートステートメントを変換する前に、必ずバンドルを最適化してください。
パッケージオーサリング
うまくいけば、この時点までに、ESMがJavaScriptエコシステムの前進であることに全員が同意します。 ただし、ソフトウェア開発ではいつものように、移行には注意が必要です。 幸いなことに、パッケージの作成者は、ユーザーの迅速でシームレスな移行を容易にするために、中断のない手段を採用できます。
package.json
にいくつかの小さな追加を加えることで、パッケージは、パッケージがサポートする環境と、それらが最適にサポートされる方法をバンドラーに伝えることができます。 Skypackのチェックリストは次のとおりです。
- ESMエクスポートを含めます。
-
"type": "module"
を追加します。 -
"module": "./path/entry.js"
(コミュニティ規約)。
そして、すべてのベストプラクティスに従い、Web環境とNode.js環境の両方をサポートしたい場合の例を次に示します。
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
これに加えて、Skypackチームは、特定のパッケージが寿命とベストプラクティスのために設定されているかどうかを判断するためのベンチマークとして、パッケージ品質スコアを導入しました。 このツールはGitHubでオープンソースであり、 devDependency
としてパッケージに追加して、各リリースの前に簡単にチェックを実行できます。
まとめ
この記事がお役に立てば幸いです。 その場合は、ネットワークと共有することを検討してください。 コメントやツイッターでお会いできるのを楽しみにしています。
役立つリソース
記事とドキュメント
- 「ESモジュール:漫画の詳細」、Lin Clark、Mozilla Hacks
- 「TreeShaking」、Webpack
- 「構成」、Webpack
- 「最適化」、Webpack
- 「スコープホイスト」、Parcelバージョン2のドキュメント
プロジェクトとツール
- Terser
- babel-plugin-transform-imports
- スカイパック
- Webpack
- 小包
- 巻き上げる
- esbuild
- SWC
- パッケージチェック