vuex と webpack の動的インポートによりモジュールを動的にロードする

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

最近のプロジェクトで Vue を使って開発しましたが、複雑なデータフローや状態を管理するには、通常 Vuex を Single Source of Truth のストアとして使用します。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
  },
}

見た目はシンプルですが、すぐにいくつかの問題に直面します:

  • 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 コンポーネントに分割します。

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…)を行い、全体のコンポーネントをより堅牢にすることができます。さらに詳しくするために、第三のパラメータとしてタイムアウトや 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)) // 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 すべきでしょうか?前半部分では読み込みの処理を扱いましたが、アンマウント時に直接コンポーネントでアンレジスタすることはできません。他のコンポーネントがストアのデータにアクセスしているかどうかわからないからです。したがって、他のコンポーネントが使用する可能性がある場合は、安易に unregisterModule しないことをお勧めします。

その他

React ではそれほど便利ではありませんが、react-loadable を使うことができます。しかし、Redux には registerReducer のような API がないため、自分で実装する必要があります。redux-observable や redux-saga(エピックやサガの動的読み込み)を使用している場合も、同様の方法で実装できます。

結論

React および Redux に比べて、Vue のエコシステムは非同期読み込みのサポートがより優れています(実装が容易です)。もちろん、このような機能を導入することでデバッグの複雑さが増すことは避けられませんが、バンドルサイズを効果的に削減することができます。しかし、アプリ全体が安定して動作するためには、さまざまなメカニズムを組み合わせる必要があります。

この記事では、動的読み込み時に直面する可能性のある問題とその解決策を提案し、バンドルサイズに苦しんでいる開発者の皆さんのお役に立てれば幸いです。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee