Vuex と webpack の dynamic import を使ってモジュールを動的に読み込む
最近のプロジェクトで Vue を使って開発を行っており、複雑なデータフローや状態を管理する場合、通常は Single Source of Truth のストアとして Vuex を利用する。Vue でストアを構築する際は、すべてのモジュールを書き終えた後、Vue のルートにまとめて配置するのが一般的だ。
export default new Vuex.Store({
modules: {
profile,
users,
menus,
list,
food,
product,
todo,
...
},
});
これは一般的な中・小規模のプロジェクトでは何の問題もないが、プロジェクトの規模が大きくなるにつれて、ストアのデータ構造も肥大化し、複雑になりやすい。しかも、モジュール内の action や mutation が増えると、不要なバンドルサイズが少なからず増加してしまうし、すべてのモジュールがアプリ初期化直後から使われるわけでもない。この点について、Vue は webpack の dynamic import 機構を通じてコンポーネントを動的に読み込むことができる。
Vuex においては、store.registerModule を使うことで、必要なときにだけモジュールをストアに追加できる。この API があれば、webpack の dynamic import 機構と組み合わせてバンドルサイズを削減し、あらゆる操作を極力シンプルに保ちながら、アプリ初期化時には必要なモジュールだけを登録すれば済むようになる。
今回は、webpack の dynamic import 機構を使ってモジュールを動的に読み込む方法を紹介する。
Webpack Dynamic Import
本題に入る前に、まずは webpack の dynamic import 機構について話そう。一般的に、webpack でコード分割(code splitting)を行う方法には以下がある。
- 複数の entry を設定する
- SplitChunks プラグインを使って異なる chunk に分割する
- dynamic import の機構を使ってコードを読み込む
コンポーネント内で対応するモジュールを動的に読み込みたい場合、最も手軽な方法は dynamic import の機構を使うことだ。これによって import() が Promise を返す関数になり、webpack のビルド時にこれらのファイルが自動的に別々の chunk として分割される。例えば:
import(/* webpackChunkName: "CreateMenu" */ './pages/NewMenu.js'),
webpack のビルド時に、CreateMenu.js が 1 つの chunk として切り出される。
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
公式ではコメントを書くことで chunk 名を指定する方法が提供されており、デバッグ時に現在どの chunk が読み込まれているのかが把握しやすくなる。
この機構を利用するには、Babel プラグイン Syntax Dynamic Import Babel Plugin の追加設定が必要となる。
webpack の dynamic import の使い方が分かったところで、Vuex と統合してみよう。
読み込みのタイミング
モジュールを動的に読み込むにあたって、必然的にいくつかの問題を考慮しなければならない:
- どのタイミングでモジュールを読み込むか?
- エラーが発生して読み込めなかった場合はどう処理するか?
どのタイミングでモジュールを読み込むか?
コンポーネントがストアの状態を必要とする可能性があるときに読み込む(当たり前だが)。そのため、次のように書くことができる:
// component.vue
export default {
mounted() {
import("./modules/menus").then(menus =>
this.$store.registerModule("menus", menus.default)
)
},
render() {
// your template
},
}
一見シンプルに見えるが、すぐにいくつかの問題に直面する:
- render の時点で読み込みが完了していなければ、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(Higher-Order Component)として切り出してみる。
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)) // register module into store
}
},
render(h) {
return this.isLoaded ? (
<Component {...this.$props} />
) : null;
},
});
}
// MenuList.js
export default createModule(MenuList, import(/* webpackChunkName: Menus */ './modules/menus')); // return a higher order vue component
こうすることで、必要なときに安心して対応するモジュールを読み込むことができ、コンポーネント側で毎回面倒な読み込みロジックを扱う必要がなくなる。もちろん引数を変更して、複数のモジュールを受け取れるようにしてもよい。その場合、考慮すべき事項はさらに増えるが(モジュール名のマッピング方法や、複数の 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)) // register module into store
}
},
render(h) {
return this.isLoaded ? (
<Component {...this.$props} />
) : null;
},
});
}
// MenuList.js
export default createModule(MenuList, 'menus', {
loader: () => import('./modules/menus'),
})
最後の引数を関数として渡すようにすればよい。もちろん loader の部分でさらに処理(エラーハンドリング、エラーログ送信、GA など)を挟むことで、コンポーネント全体をより堅牢にできる。さらに作り込むなら、第 3 引数に timeout や LoadingComponent などの仕組みを渡せるようにすれば、この高階関数はさらに実用的になる(ただし、ほとんどの場合はモジュールが少しでも速く読み込まれることが望まれるが)。
エラーハンドリング
Promise が正常にロードされ、平穏無事であることを望むのは当然だが、現実にはモジュールの読み込みに影響を与える要因が多すぎる。多くはネットワークの不安定さや途中でオフラインになることなどであるため、エラーハンドリングの仕組みが必要だ。
mounted 内で catch を使ってエラーを処理し、新たな data を定義してエラー情報を記録できるようにする。
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)) // register module into store
.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 すべきだろうか?前半では読み込みの処理を扱ったが、アンロードに関してはコンポーネントの unmount 時にそのまま実装することはできない。他のコンポーネントがそのストアのデータにアクセスしているかどうか分からないからだ。そのため、単一のコンポーネントしか使用しないと確信が持てる場合を除き、むやみに unregisterModule を呼ぶべきではない。
その他
React ではここまで手軽にはいかないが、react-loadable が使える。ただし Redux には registerReducer のような公式 API がないため、自前で実装する必要がある。redux-observable や redux-saga(epic や saga の動的読み込み)を使っている場合も、似たようなアプローチで実装可能だ。
結論
React や Redux と比べると、Vue のエコシステムは非同期読み込みへのサポートが手厚い(実装が容易だ)。もちろん、こうした仕組みを導入するとバンドルサイズを効果的に削減できる反面、デバッグの複雑さはどうしても増してしまう。アプリ全体の動作をより安定させるには、様々なメカニズムと組み合わせる必要がある。
本稿では、動的読み込みの際に直面しがちな課題とその解決策を提示してみた。バンドルサイズに頭を悩ませている開発者の助けになれば幸いだ。
関連記事
- Three.js で僕の部屋を表現する 僕が React Three Fiber を使って実際の自分の部屋をブラウザ上に再現し、実世界のオブジェクトを目録に見立て、空間の記憶を通してここ数年の生活と仕事について語った話。
- フロントエンドで画像を扱う際に注意すべきこと Jake Archibaldの記事を起点に、現代のレスポンシブ画像の書き方を整理する。なぜwidth/heightを付ける必要があるのか、CSSのaspect-ratioはいつ使うべきか、AVIFとWebPの選び方、そしてpicture/source/srcsetを使ったモバイル向け画像の切り替えについて。
- CSS field-sizing — たった1行のCSSでフォーム要素を自動リサイズする かつてtextareaの自動高さ調整は、JavaScriptでscrollHeightを監視するしかなかった。しかしCSSのfield-sizing: contentなら、わずか1行で代替でき、textarea、input、selectに対応している。本記事では従来のやり方のペインポイントと、field-sizingの使い方をまとめる。
- リンクの下線をもっと見栄え良くする:text-underline-offset デフォルトでは下線と文字が近すぎて、このスタイルを好まないデザイナーもいるし、僕自身もあまり綺麗ではないと感じていた。