最近、プロジェクトでVueを使用して開発しています。特に複雑なデータフローや状態管理が必要な場合、通常はVuexを使用してSingle Truth of Sourceのストアとして利用しています。Vueでは、ストアを作成する際に、すべてのモジュールを書き終えた後にVueのルートに統合します。
export default new Vuex.Store({
modules: {
profile,
users,
menus,
list,
food,
product,
todo,
...
},
});
これは一般的な中小規模のプロジェクトでは問題ありませんが、プロジェクトの構造が大きくなるにつれて、ストアのデータ構造がますます大きく複雑になる可能性があります。また、モジュール内のアクションやミューテーションが増えると、不要なバンドルサイズが増加する可能性があります。また、すべてのモジュールがアプリの初期化後にすぐに使用されるわけではありません。これに関しては、Vueはwebpackの動的インポート機能を使用してコンポーネントの動的非同期読み込みを行っています。
Vuexでは、store.registerModule
を使用して必要な時にモジュールをストアに追加することができます。このAPIがあることで、webpackの動的インポート機能と組み合わせてバンドルサイズを削減し、必要なモジュールのみを初期化時に追加することができます。
今日は、webpackの動的インポート機能を使用してモジュールを動的に読み込む方法について紹介します。
Webpackの動的インポート
始める前に、まずWebpackの動的インポートの仕組みについて説明します。一般的に、Webpackでコード分割を行う方法は次のとおりです。
- 複数のエントリポイントを設定する
- SplitChunksプラグインを使用して異なるチャンクに分割する
- 動的インポートの仕組みを使用してコードを読み込む
コンポーネント内で対応するモジュールを動的に読み込む場合、最も便利な方法は、動的インポートの仕組みを使用することです。これにより、import()
がPromiseを返す関数となり、Webpackのビルド時にこれらのファイルが自動的に別のチャンクとして分割されます。例えば:
import(/* webpackChunkName: "CreateMenu" */ './pages/NewMenu.js'),
Webpackのビルド時にCreateMenu.js
が別のチャンクとして分割されます。
Version: webpack 4.16.3
Time: 112ms
Built at: 2018-08-20 14:49:40
Asset Size Chunks Chunk Names
Profile.c943bf21.js 32.7 KiB Profile [emitted] Profile
公式では、コメントを使用してチャンク名を指定する方法を提供しており、デバッグ時に現在のチャンクがどれであるかがより明確になります。
この仕組みを使用するためには、babelのプラグインであるSyntax Dynamic Import Babel Pluginを追加で設定する必要があります。
Webpackの動的インポートの使用方法を知ったら、Vuexと統合してみましょう。
モジュールの読み込みタイミング
モジュールを動的に読み込むためには、いくつかの問題を考慮する必要があります。
- いつモジュールを読み込むか?
- 読み込みに失敗した場合の処理はどうするか?
いつモジュールを読み込むか?
コンポーネントがストアの状態を必要とする可能性があるときに読み込みます(当たり前ですね)。したがって、次のように書くことができます。
// component.vue
export default {
mounted() {
import("./modules/menus").then(menus =>
this.$store.registerModule("menus", menus.default)
)
},
render() {
// your template
},
}
シンプルに見えますが、すぐにいくつかの問題が発生します。
- レンダリング時にまだ読み込みが完了していない場合、テンプレート内で
menus
のデータを使用するとundefinedになり、エラーが発生します。 - 他のコンポーネントで既に読み込まれている場合、
duplicate getter key: menus
というエラーが発生します。
これらの問題を修正するために、少し変更します。
// component.vue
export default {
data: () => ({
loaded: false,
}),
mounted() {
if (this.$store.state.menus) {
import("./modules/menus").then(menus => {
this.$store.registerModule("menus", menus.default)
this.loaded = true
})
} else {
this.loaded = true
}
},
render() {
return loaded ? h() : null
},
}
見た目はかなり良くなりましたが、ストアデータを必要とするすべてのコンポーネントで同じ処理を繰り返すのは少し面倒です。これを共通のHOCコンポーネントに分離しましょう。
export default function createMenuModule(Component, moduleName, dynamicModule) {
return Vue.component(`dynamicModule-${Component.name || 'Component'}`, {
data: () => ({
isLoaded: false,
}),
mounted() {
if (this.$store.state[moduleName]) {
dynamicModule
.then(module => this.$store.registerModule(moduleName, module.default)) // モジュールをストアに登録
}
},
render(h) {
return this.isLoaded ? (
<Component {...this.$props} />
) : null;
},
});
}
// MenuList.js
export default createModule(MenuList, import(/* webpackChunkName: Menus */ './modules/menus')); // ハイオーダーVueコンポーネントを返す
これで、必要なときに対応するモジュールを安全に読み込むことができます。コンポーネント内で煩雑なロードロジックと処理を毎回行う必要はありません。もちろん、パラメータを少し変更して、この関数が複数のモジュールを受け入れるようにすることもできます。ただし、これにはさらなる考慮事項が発生します(モジュール名のマッピング方法、複数のPromiseの処理など)。ただし、概念は同じです。
必要なときに読み込む
先ほどの例では、前述の問題を解決しましたが、注目してみると、パラメータにimport()
を直接記述しているため、どのような場合でもリクエストが送信されてしまうようです!ストアに対応するデータがあるかどうかを確認してから読み込むことはできないでしょうか?。
次に、関数を再度変更して、ストアをパラメータとして渡すことができるようにします。
export default function createMenuModule(Component, moduleName, loader = () => Promise.resolve()) {
return Vue.component(`dynamicModule-${Component.name || 'Component'}`, {
data: () => ({
isLoaded: false,
}),
mounted() {
if (this.$store.state[moduleName]) {
loader()
.then(module => this.$store.registerModule(moduleName, module.default)) // モジュールをストアに登録
}
},
render(h) {
return this.isLoaded ? (
<Component {...this.$props} />
) : null;
},
});
}
// MenuList.js
export default createModule(MenuList, 'menus', {
loader: () => import('./modules/menus'),
})
最後のパラメータを関数として渡します。また、loaderでさらに処理を行うこともできます(エラーハンドリング、エラーロギング、GAなど)、コンポーネント全体をより堅牢にします。さらに細かくする場合は、第3のパラメータとしてタイムアウトやLoadingComponentの仕組みを渡すこともできます。これにより、このハイオーダー関数がより使いやすくなります。(ただし、ほとんどの場合はモジュールができるだけ早く読み込まれることを望むでしょう)
エラーハンドリング
Promiseが正常に読み込まれることを期待していますが、実際にはモジュールの読み込みにはさまざまな要因が影響します。ほとんどはネットワークの不安定性やオフラインなどですので、エラーハンドリングの仕組みが必要です。
mounted時にエラー処理を行うために、catchを使用してエラーを処理し、新しいデータを記録するためのデータを設定します。
export default function createMenuModule(Component, moduleName, loader = () => Promise.resolve()) {
return Vue.component(`dynamicModule-${Component.name || 'Component'}`, {
data: () => ({
isLoaded: false,
error: null,
}),
mounted() {
if (this.$store.state[moduleName]) {
loader()
.then(module => this.$store.registerModule(moduleName, module.default)) // モジュールをストアに登録
.catch(err => {
this.error = err
sendToLoggingService(err);
})
}
},
render(h) {
if (this.error) {
return h('pre', this.error);
}
return this.isLoaded ? (
<Component {...this.$props} />
) : null;
},
});
}
// MenuList.js
export default createModule(MenuList, 'menus', {
loader: () => import('./modules/menus'),
})
もちろん、vueのerrorCapturedを活用したり、エラーロギング用のErrorBoundary
コンポーネントを実装したりすることもできます。
unregisterするタイミング
いつunregisterする必要がありますか?先程、ロードの部分を処理しましたが、アンマウントに関してはコンポーネント内で直接実装することはできません。他のコンポーネントがストアのデータにアクセスしている可能性があるため、単一のコンポーネントのみが使用することが確実な場合以外は、無闇にunregisterModuleしないでください。
その他
Reactでは、同様に簡単ではありませんが、react-loadableを使用することができます。ただし、ReduxにはregisterReducerのようなAPIがないため、自分で実装する必要があります。redux-observableやredux-saga(動的に読み込むepicやsaga)を使用している場合は、同様の方法で実装することもできます。
結論
ReactとReduxに比べて、Vueのエコシステムは非同期読み込みをサポートするのがより簡単です(実装が容易です)。もちろん、このような仕組みを導入することでデバッグの複雑さが増すことは避けられませんが、バンドルサイズを効果的に削減することができます。ただし、アプリ全体の安定性を確保するためにさまざまな仕組みを組み合わせる必要があります。
この記事では、動的読み込み時に発生する可能性のある問題とその解決方法について紹介しました。バンドルサイズの問題に悩んでいる開発者の方々に役立てば幸いです。