アクセスルートに応じて別のバンドルファイルをロードさせる
私と同僚は複数のルートに分割されたクライアントサイドのバニラアプリケーションを書いています。ルートはサイトの構造を考え整理する方法であり、通常は個々のURLに結び付けられています。オンラインショップには、ホーム、カテゴリリスト、商品ページ、およびチェックアウトなどの異なるルートがあるかもしれません。これらのルートはそれぞれ独自のJavaScriptのスクリプトを含む場合があります。たとえば、カテゴリリストにはフィルタリングを処理するコードが必要ですが、チェックアウトルートにはフォームへの入力を検証するコードが必要です。
しかし、完全なクライアントサイドのアプリケーションを構築しているわけではありません。ユーザーは、アドレスバーにURLを入力してメインページを読み込んだり、検索エンジンで興味のある商品を見つけたり、その商品の説明ページを直接クリックしたりするなど、さまざまなページからサイトにアクセスする可能性があります。したがって、これらのルートにはそれぞれ独自のURL構造が必要なのです。
Home: / Category listing: /category/42 Product page: /product/1234 Checkout: /checkout
さらに、サイトがはじめて読み込まれる時、すべてのルートがスクリプトを必要とする訳ではありません。理想的には、ロードされたルートのスクリプトが必要なだけで、残りの部分はユーザーがサイトを移動する際に遅延読み込みすれば事足りるのです。しかし、何も考えずにバンドルを行うと、まだ何も起こっていない時に、巨大なファイルを全部読み込んでパースしなければならなくなります。
dist/ big-bundle.js
これは不必要なJavaScriptのスクリプトをたくさん読み込むことになるため、理想的ではありません。通信速度が遅い場合は、ページがインタラクティブになるまでにユーザーが長い時間待たなければなりません。このため、巨大で先行型のバンドルはアンチパターンなのです。
ここでエントリポイントという概念の出番です。すべてのファイルを読み込んでアプリケーション全体を起動する、包含的な1つのメインファイルを設けるのではなく、各ルートのエントリポイントを選択し、残りのルートは必要な時に遅延読み込みさせるのです。したがって、以下のような形に設計するのが良いでしょう。
dist/ bundle-home.js bundle-category.js bundle-product.js bundle-checkout.js home.js category.js product.js checkout.js
ユーザーがサイトにアクセスするために使用するルートに応じて、バンドルファイルのうちの1つだけがロードされます。このファイルは、必要に応じて、自身のモジュールを読み込まず、他のモジュールの遅延読み込みを行います。したがって、ユーザーが商品ページのルートから来る場合には、bundle-product.jsを読み込むと、ページが起動し、必要に応じてhome.js、category.js、checkout.jsだけが取得されます。
この解決策は、ユーザーの要求に即座に対応するために必要なすべてのコードを読み込み、必要に応じて残りのコードも読み込みます。また、複数のファイルに同じコードが含まれていても、ユーザーは、実際に各モジュールを1つだけダウンロードするだけで済みます。
webpackでモジュールを読み込む
webpackは使いにくく、何をするにしても大量の設定が必要だと信じられています。私は今までにこの神話が本当だったことがあるかどうか判別できるほど長い間webpackを使用してきたわけではありませんが、webpackは今日かなり使いやすいものであることが分かりました。 ESモジュール、静的インポート、動的インポートに精通しているならば、無駄に大量のコードを読み込んだり、大量の設定をしたりすることなく複雑なアプリケーションを構築することができます。
webpackは、静的および動的モジュールの読み込みだけでなく、ESモジュールの標準構文も理解できます。コードを見て、使われているインポートのタイプに応じて、バンドルする方法を決めるのです。したがって、モジュールを静的に読み込むと、バンドルが行われます:
import Foo from './foo.js'; Foo.doSomething();
上記のコードを実行すると、単一のbundle.jsファイル(あるいは指定された別の名前かもしれませんが)ができます。
動的にロードすると、webpackは分割されたバンドルを作成し、必要に応じて更にコードを取得します。
import('./foo.js').then(module => { module.doSomething(); });
このコードを実行すると、遅延読み込みマジックを伴うbundle.jsファイルと、Fooのコードを伴う0.jsファイルが生成されます。
0.jsはwebpackの用語で “チャンク”と呼ばれ、他のwebpackコードによって遅延読み込みされるように設計されており、皆さんが書いたコードによって直接処理されることはありません。
エントリポイントでのコードの重複を避ける
さて、準備は整ったようですね。標準化されたモジュールコードを書くことができ、これは(ブラウザが既にサポートをしている場合には)ブラウザでネイティブに実行されるか、またはwebpackのバンドルマジックによって動作します。
では、いくつかコードを書いて、実際にどのようなるか見てみましょう。最初の例に戻って、ホーム、カテゴリ、商品、チェックアウトのルートとそれぞれに対応するエントリポイントを使用します。
モジュールはかなりシンプルです。具体的な内容はここでは重要ではないため、同じ名前のメソッドを1つ持つようにします。
export function printMessage() { console.log('Hi! This is the Checkout module.'); }
各エントリポイントファイルは、それぞれのモジュールを静的にロードし、他は動的にロードします。大量のコードの重複を避けるためには、他のすべてのものを公開するためのシンプルで一貫したインターフェースが必要です。以下は、Routerを呼ぶ例です。
注:実際のルーティングは行われませんが、伝統的な意味では、公平に言えばルーティングコードが存在する限りそれが辿る道なので、特に問題は無い名前だと私は思います。
const _modules = { Home: import('./home.js'), Category: import('./category.js'), Product: import('./product.js'), Checkout: import('./checkout.js'), }; export default class Router { static get modules() { return _modules; } }
各モジュールは_modulesオブジェクト内のエントリとして保持され、値はimport()によって返される読み込みプロミスです。これにより、他のルートのロードがすぐに開始されることに注意してください。これは意図される動作とは異なるかもしれませんが、この場合には、実際のインポートのトリガーとなる明示的な呼び出しで読み込みを遅延させるかもしれません。例えば:
const _modules = { Home: () => import('./home.js'), Category: () => import('./category.js'), Product: () => import('./product.js'), Checkout: () => import('./checkout.js'), }; export default class Router { static get modules() { return _modules; } }
ただ、4つのモジュールすべてが遅延読み込みされている場合、どのようにしてエントリポイントでのコードの重複を避ければよいのでしょうか。そのうちの1つがnullでなければならないのでしょうか。
実際のところ、そういったことは必要ないのです。読み込まれたモジュールを監視して2回読み込まないようにすることでwebpackと(ネイティブのモジュールサポートのある)ブラウザは重複を回避します。だから開発者はこの問題について悩む必要はないのです。これにより、エントリポイントは実にクリーンで分かりやすくなります。
import * as Home from './home.js'; import Router from './router.js'; console.log('Hello from the Home entry point!'); // Here we go through the router to prove a point, // but we could of course use Home directly too, // e.g. `Home.printMessage()`. Router.modules.Home().then(module => module.printMessage()); Router.modules.Category().then(module => module.printMessage()); Router.modules.Product().then(module => module.printMessage()); Router.modules.Checkout().then(module => module.printMessage());
ホームは静的にロードされるため、すぐに使用でき、遅延読み込みを待つ必要はありません。ルーターも静的にロードされます。ルーターに要求を行うと、モジュールがすでに読み込まれているかどうかを静的または動的に確認します。読み込まれていた場合には、promiseは直ちに解消されます。まだ読み込まれていない場合には、ファイルをフェッチし、コードを読み込み、その後promiseを継続します。
これは、エントリーモジュールも、これが適切なURLルーティングを備えた実際のアプリケーションである場合にはルーター自体も、まったく同じ方法ですべてのモジュールを使用できるということを意味します。
Webpackでどのように動作させるか
前述したように、ブラウザがESモジュールとESモジュールの読み込みをサポートしている場合、これらはすべてブラウザ上でネイティブに動作するはずです。バンドルのパフォーマンス上のメリットは更に作業を進めなければ得られませんが、それでもコードは機能しています。
つまり、私たちの大半がまだバンドルしているので、これをwebpackで動作させる方法を見つける必要があるのです。そしてそれは非常に簡単なことだったのです。 webpack.config.jsファイルは次のとおりです。
const path = require('path'); module.exports = { entry: { 'bundle-home': './entry-home.js', 'bundle-category': './entry-category.js', 'bundle-product': './entry-product.js', 'bundle-checkout': './entry-checkout.js', }, output: { path: path.resolve('./dist'), filename: '[name].js', } }
ここで重要なことは、すべてのエントリポイントが同一の生成物の一部でなければならないということです。これにより、動的に読み込まれるチャンクのセットが1つだけ生成されます。出力ファイル名は ‘[name]’を使用するため、各エントリポイントのキーに基づき、出力は次のようになります。
dist/ 0.js 1.js 2.js 3.js bundle-category.js bundle-checkout.js bundle-home.js bundle-product.js
数値ファイルには、それぞれJSONPでラップされたモジュールが含まれています。バンドルファイルにはそれぞれ、ルーターコードと一緒にバンドルされたエントリポイント用のコードと、遅延読み込みを処理するいくつかのwebpackメソッドがあります。
テストページでbundle-checkout.jsを読み込んでChrome Developer Toolsを見ると、期待どおりに3つのチャンクだけが読み込まれることが確認できます。また、Checkoutモジュールからのメッセージがbundle-checkout.jsから来ていることがわかります。つまり、Checkoutモジュールがバンドルされているということです。
さらなる複雑さが生まれると思われる
この例は非常に単純なものであり、実際のアプリケーションは、共有されたコードの一部をより簡単に分割するためにCommonsChunkPluginを使用する必要があったり、dependenciesとしてNPMモジュールを使用することで潜在的な複雑さが生まれたりするなどのために、更に多くのものを必要としますし、複雑さも増すでしょう。そのため、webpackの設定には他にも作業が必要になる可能性が高いのです。
しかし私には、これは興味深い問題であり、またこの解決策は共有する価値があると感じられました。この記事が皆さんにとって有用であり、少なくともスタート地点として役立つように願っています。
※本記事は、Multiple routes, bundling and lazy-loading with webpackを翻訳・再構成したものです。
▼こちらの記事もおすすめです!