· 7分で読了

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 のエコシステムは非同期読み込みへのサポートが手厚い(実装が容易だ)。もちろん、こうした仕組みを導入するとバンドルサイズを効果的に削減できる反面、デバッグの複雑さはどうしても増してしまう。アプリ全体の動作をより安定させるには、様々なメカニズムと組み合わせる必要がある。

本稿では、動的読み込みの際に直面しがちな課題とその解決策を提示してみた。バンドルサイズに頭を悩ませている開発者の助けになれば幸いだ。

関連記事

他のトピックを探索