Tree Shaking React ネイティブ アプリ

Nov 28 2022
Web アプリに一般的な最適化手法を React Native アプリに適用して、起動時間を 20% 短縮した方法。ツリー何?確かに、Tree Shaking は紛らわしい用語かもしれません。

Web アプリに一般的な最適化手法を React Native アプリに適用して、起動時間を 20% 短縮した方法。

ツリー何?

確かに、Tree Shaking は紛らわしい用語かもしれません。TypeScript の「インポート省略」として既に聞いたことがあるかもしれません。ツリー シェーキングは、特に未使用のエクスポートの削除に関連するデッド コードの除去の形式です。すべてのモジュールを連結すると、未使用のエクスポートは事実上デッドコードになり、削除できます。ただし、未使用のエクスポートを決定するプロセスは簡単ではありません。Tree Shaking は通常、JavaScript エンジン (V8 や Hermes など) ではなく、コンパイラ/バンドル レベル ( WebpackやESBuildなど) で実装されます。JavaScript の多くのパターンで Tree Shaking が壊れる可能性がありますが、この記事では 1 つの側面、つまりモジュール システムに焦点を当てたいと思います。ここで理解する必要がある 2 つの関連モジュール システムは、CommonJS モジュールとESモジュール。

module.exports = {}またはを記述するときに CommonJS が使用されますexports.someMethod = () => {}。ES モジュールは、importおよびexport構文で識別されます。CommonJS を使用するコードにツリー シェーキングを適用することは、ES モジュールよりもコンパイラにとって困難です。CommonJS モジュールは多くの場合動的ですが、ES モジュールは静的に分析できます。たとえば、次のコードですべてのエクスポート識別子を静的に検出するのは簡単ではありません。

ES モジュールは設計により静的に分析できるため、コンパイラは未使用のエクスポートを簡単に検出できます。
したがって、CommonJS モジュールではなく、最適化コンパイラ ES モジュールを使用して動作させることが最善の方法です。

バックグラウンド

Klarna に参加する前は、React Native を使用した経験がありませんでした。定期的なリファクタリング中に、次の差分を適用しました。

が false のsomeFeatureMethod場合、使用されているバンドラーが未使用と見なし、最終的なバンドルから削除すると仮定します。コード レビュー中に、この diff は問題があるとマークされたので、腰を落ち着けて、自分の仮定とこれらがどこで壊れているかを再確認しました。幸いなことに、私たちはすでに2 か月前にWebpack ( Re.Packの形式) に切り替えて、によるバンドル分割を有効にしていました。これにより、最終的な JavaScript バンドルを検査できるように、バンドル プロセスを簡単に構成できます。この場合、最終的な JavaScript 出力を表示するには、 Hermesのみを無効にする必要がありました。SOME_STATIC_FLAGsome-feature-moduleReact.lazy

some-feature-moduleがインポートされている場所を見つけやすくするための試行錯誤の後、次の行を見つけましたc=(n(463526),n(456189):コンマ演算子は、通常は使用しないものです。最後のオペランド。つまり、 の戻り値n(463526)は未使用でした。私はすでに Web 上でツリー シェイキングを行った経験があるので、これがミニフィケーションの前に何であったかはかなり明確でしたrequire('some-feature-module')(Webpack はインポート ソース文字列を数値に変換します)。

実際、Webpack はそれsomeFeatureMethodが未使用であることを認識したため、その使用を削除しました。ただし、Webpack はモジュールから未使用のエクスポートを削除することを回避し、モジュールに副作用があるかどうかわからなかったため、インポートを保持しました。モジュールに副作用がある場合、プログラムの流れが変わるため、バンドルから削除することはできません。

元の差分を期待どおりに機能させるために必要だったのは、最終的なバンドルに Tree Shaking が適用されていることを確認することだけでした。

実装

最終的には、Webpack がすべてのモジュールをバンドルする前に、ES モジュールを CommonJS にトランスパイルしないようにする必要があります。Metro Babel プリセット(新しい React Native アプリのデフォルト) を使用している場合、ほとんどの作業は有効にすることになりdisableImportExportTransformます。

このオプションは現在文書化されておらず、いつでも削除される可能性があることに注意してください。

CommonJS モジュールの代わりに ES モジュールを使用するエントリポイントを使用するよう Webpack に指示する必要もありました。個々のファイルの場合、パッケージの場合は優先することを意味し、メイン フィールド.mjsを使用するように Webpack に指示する必要がありました。module

しかし、これにより、JavaScript の記述方法と、React Native エコシステムでのコードの記述方法に問題があることが明らかになりました。3 つのクラスの問題を特定しました。

と で異なる構文をエクスポートmainするmodule

これらのメイン フィールドは、モジュール システムを区別するためにのみ使用する必要があります ( mainCommonJS の場合module、ES モジュールの場合)。ただし、多くのパッケージでは、エントリポイントからより最新の構文が提供されていますmodule。たとえば、class構文は現在 Hermes ではサポートされていません。

今のところ、 Webpack 構成node_modulesにカスタムを追加することで、すべてのコンテンツを ES5 構文または Hermes でサポートされている構文ruleにトランスパイルします。

構文があいまいな CommonJS モジュールのインポート

Webpack は、モジュール システムが混在するモジュールからのエクスポートを見つけることができません。ただし、React Native 自体は、ソース ファイルを混合モジュール システムで出荷しています。

ここでの解決策はrule、Webpack 構成に特別なものを追加して、これらのモジュールを CommonJS にトランスパイルし続けることです (したがって、ツリー シェーキングを無効にします)。

インポート識別子が存在しません

これは実際には、ほとんどの人が認識していない JavaScript の SyntaxError です。たとえば、import { doesNotExist } from 'some-module';をスローしSyntaxErrorます。これは主に開発者にとって厄介なものですが、実際の実行時の問題につながる可能性があります。Webpack構成で有効にすることにより、WebpackでのESモジュールのこの厳密な実装を強制しmodule.parser.javascript.exportsPresenceました。

これらの問題のほとんどは、TypeScript での型の再エクスポートによって引き起こされます。

幸いなことに、TypeScript は以下を有効にすることで、型レベルでこれらの問題にフラグを立てることができisolatedModulesます。

typeインポート名の修飾子は、TypeScript 4.5 で新しく追加されました。typeインポート名に修飾子のサポートを追加することは、使用した ESLint パーサー、Prettier、TypeScript をアップグレードする必要があったため、非常に困難でした。

インポート名に修飾子を追加するtypeと、Babel は実行時に実際には存在しないタイプのインポートを削除します。

結果

最初の実装はかなりハックでした。ただし、最初の結果では、両方のプラットフォームで起動時の中央値が 20% 改善されていることが示されています (Samsung Galaxy S9 は 2.8 秒から 2.2 秒、iPhone 11 は 802 ミリ秒から 640 ミリ秒に短縮)。

私たちが見たのは、初期の重要な JavaScript チャンクが 46% 削減されたことです。出荷した JavaScript の全体的なサイズは 14% 減少しました。この違いは主に、コードをメイン チャンクから非同期チャンク (機能とルート) に移動したことに起因します。

Tree Shaking 後のバンドル統計の絶対差分
Tree Shaking 後のバンドル統計の相対差分

これらの画像は素晴らしいstatoscope.techによって作成されたものであり、この変更の分析に役立ち、今後もバンドル サイズのさらなる改善を推進するのに役立ちます。

この削減は、未使用のエクスポートを削除するだけでなく、Webpack がModuleConcatenationPluginより多くのモジュールを連結できることにも起因することに注意してください。つまり、hoist more modules のスコープを設定できます。スコープホイストはまだ十分に活用されていません。現在、モジュールの 20% のみが吊り上げられています。その数を増やすと、バンドルのサイズとランタイムがさらに向上することが予想されました。

JavaScript の 40% の削減は、JavaScript バンドルを実行する前に評価するのにかかる時間とほぼ正確に 1:1 に対応しています。JavaScript を評価すると結果として起動時間がブロックされるため、直接出荷される JavaScript の量を減らすと起動時間が短縮されます。

2 週間後に実装の最終ステップが完了した後も、ラボで同じ結果が得られ、メイン ブランチにこの機能を導入する準備が整いました。リリースのカットオフの直後に最終的な変更を行うように細心の注意を払いました。これにより、従業員が使用する内部アプリ バージョンで新しいモジュール システムを広範囲にテストすることができました。1 週間の内部テストの後、この機能は徐々にエンドユーザーに公開されました。アプリの安定性がほとんど影響を受けていないことを確認した後、私たちは非常に期待していました. 生産データは、ラボの結果で見た起動時間の中央値と同じ相対的な改善を示しました。

バージョン 22.37 にはツリー シェーキングがありません。22.38 木が揺れる

Android ユーザー向けの本番データ
iOS ユーザー向けの本番データ

これらの改善には、ビルド時間の増加という代償が伴います。プロダクション JavaScript バンドルのバンドルには、約 30% (4 分) 長い時間がかかります。これらのビルド時間の増加は、ユーザー エクスペリエンスの向上に直接つながるため、喜んで受け入れます。ビルド時間の増加の一部は、必要以上にトランスパイルしたことが原因です。初期の実装では、トランスパイルに必要な量を削減するために時間をかけませんでした。また、より多くのパッケージが適切な ES モジュールを出荷するほど、ビルド時間の増加の一部を取り戻す予定です。React Native アプリの構築に必要なタスクは、JavaScript のバンドル時間だけではないことに注意してください。バイナリのコンパイルなどでは、JavaScript バンドルの増加は最終的にそれほど影響を与えません。

次は何ですか

ES モジュールは、React Native エコシステムで積極的に取り組んでいないようです。moduleES モジュールの適切な使用法 (同等の構文を持つ JavaScript を指すエントリなど)について、エコシステムをより調整したいと考えています。そうすれば、ビルド構成を減らし、トランスパイルを減らすことができます。

Metro ( experimentalImportSupport) のフラグの背後で ES モジュールを使用することはサポートされていますが、これは実験的であり、文書化されていません。開発中にそのフラグを有効にすることは (まだ) うまくいきませんが、いつの日か開発と本番で同じモジュール システムを使用できるようになることを願っています。ES モジュールのサポートは現在積極的に取り組んでいないように見えるため、React ネイティブの ES モジュールに関する議論を再開したいと思います。Tree Shaking のサポートは、数年前に完全に放棄されました。.

結局のところ、ES モジュールは、JavaScript を知っている人なら誰でも最終的には学習する言語機能です。バンドルの分割とデッドコードの除去を理解するために、React Native に追加の学習ステップが必要な理由はわかりません。

一度書くと、どこでも実行できます!

この投稿をお楽しみいただけましたか?MediumおよびLinkedInで Klarna Engineering をフォローして、このような記事を今後もお見逃しなく。