Code Splitting と非同期コンポーネント

Code Splitting と非同期コンポーネントを案件(と趣味プロで少々)で使用したのでメモっておく(webpack + TS + Vue3 / React で使用)

Code Splitting とは

  • Webpack 等のバンドラーでバンドル処理時に、コードをチャンクファイルに分割すること
  • Webpack の場合
    • Dynamic Import で非同期でモジュールを読み込もうとすると、Webpack がファイルを分割してくれる
      • モジュールを読み込む時用の URL 設定が必要になる
        • (バンドルファイルの出力先をルート URL の物理パスとしてる場合は不要)
    • 複数エントリパス毎にバンドルする場合、共通で利用しているモジュールを別ファイルに切り出してくれる
      • 明示的な設定が必要

メリット

  • バンドルファイルが巨大化しがちな SPA において、ファイルを分割することで初期表示を早くできる
  • 非同期で得られるデータを要するコンポーネントの描画時、データ取得状態を把握するためのフラグ管理が不要になる

デメリット

  • 分割した数だけファイルの読み込み回数が増える
    • ファイルのリクエスト数に比例して増えるオーバーヘッド、ネットワークがボトルネックにならないよう分割のバランスに考慮する必要がある

担当案件で採用した新規機能の構成

image.png (860.4 kB)

※青いところが新規に追加実装している部分。グレーのところは昔からある既存実装部分で、平行して細かな機能追加・修正が常時行われているプロジェクト

  • 技術構成
    • Vue3.0.0(Vue2.5.16 -> Vue3.0.0-rc.9 -> Vue3.0.0 の順で移行)
    • TypeScript v4.0.2
    • Webpack v4.42.1
  • 3 つのエントリポイントが必要

    • アダプター層
      • 本体アプリとフォトショもどきアプリとの連携、本体側 API との連携を行う
    • 例外エラー通知アプリ
      • Vue を利用。本体アプリに存在する機能で、同機能を移植し、アダプター層とフォトショもどきアプリで使う
    • フォトショもどきアプリ
      • Vue を利用
  • Code Splitting する箇所

    • 例外エラー通知アプリとフォトショもどきアプリで Vue を使用している
      • Vue を含むチャンクファイルを生成し、両アプリからはこの Vue を参照する
    • フォトショもどきアプリ初期化時に、非同期データの取得や Canvas のマウント待ちがある
      • フラグ管理したくないので Dynamic Import(非同期コンポーネント)を使用する

複数エントリポイントで利用する共通モジュールのチャンク化

  • webpack.config.js に splitChunks の設定を書くと、複数エントリポイントで共通で利用されているモジュールがチャンクファイルとしてまとめられて出力される
  • チャンクファイルはデフォルトでは、node_modules 配下のモジュール or その他の場所のモジュール、で分けてまとめられる
  • 但し、30Kb 未満のモジュールは対象外となる
...
  entry: {
    foo: path.resolve(__dirname, "./src/foo.ts"),
    bar: path.resolve(__dirname, "./src/bar.ts"),
  },
  output: {
    path: path.resolve(__dirname, "./public/scripts"),
    filename: "[name].bundle.js",
  },
  optimization: {
    splitChunks: {
      chunks: "all",
    },
  },
};

foo.ts、bar.ts において、node_modules 配下とそれ以外の場所で、30Kb 以上のモジュールを利用していた場合、以下のように 3 つのバンドルファイルができる

foo.bundle.js
bar.bundle.js
vendors~bar~foo.bundle.js // node_modules配下のモジュール
bar~foo.bundle.js // node_modules配下以外のモジュール
  • vendors~bar~foo.bundle.js 、bar~foo.bundle.js というファイル名から、foo.bundle.js と bar.bundle.js から参照されていることが分かる
  • もし、foo.bundle.js からしか参照されてない場合は、vendors~foo.bundle という名前になる
  • モジュールの束ね方やファイル名を調整することもできる

vendors 接頭辞を除去する

{
  splitChunks: {
    chunks: "all",
    cacheGroups: {
      vendors: false, // vendorsという接頭辞を除去する
    },
  }
}

// 出力結果
bar~foo.bundle.js // node_modules配下か否かは無関係に、共通利用されている全てのモジュールがバンドルされる

vendors 接頭辞を commons にする

この設定を追加した場合、(デフォルト設定が上書きされ)node_modules 配下以外のモジュールは対象外になるので、node_modules 以外に切り出したいモジュールがある場合は、cacheGroups にそれらモジュールを配置しているパスを別途設定する必要がある。

{
  splitChunks: {
    cacheGroups: {
      commons: { // 接頭辞をcommonsにする
        test: /[\\/]node_modules[\\/]/, // 対象パスを指定
        chunks: "all",
      },
    },
  },
}

// 出力結果
commons~bar~foo.bundle.js // vendors 接頭辞が commons 接頭辞になる

ファイル名の接続子を - にする

ファイル名に~が含まれてると、AWS の CloudFront で問題になる場合があるらしい

{
  automaticNameDelimiter: "-", // ファイル名の接続子を - にする
  splitChunks: {
    chunks: "all",
  }
}

// 出力結果
vendors-bar-foo.bundle.js // ~ -> - になる
bar-foo.bundle.js

ファイル名にモジュール名を含める

nameプロパティを使うと、モジュール情報・設定値を参照でき、それらを元にファイル名を決定することができる。

{
  splitChunks: {
    cacheGroups: {
      commons: {
        test: /[\\/]node_modules[\\/]/,

        // モジュール情報を引数にとりファイル名を決めることもできる
        // 以下ではモジュール名をファイル名に含めている
        name(module, chunks, cacheGroupKey) {
          const moduleFileName = module
            .identifier()
            .split("/")
            .reduceRight((item) => item);
          const allChunksNames = chunks.map((item) => item.name).join("~");
          return `${cacheGroupKey}-${allChunksNames}-${moduleFileName}`;
        },
        chunks: "all",
      },
    },
  },
}

// 出力結果
commons-foo-moment.js.bundle.js // foo が moment.js を使っていた場合
commons-foo~bar-lodash.js.bundle.js // foo と bar が lodash.js を使っていた場合

※ CodeGrid でも詳しく紹介されてたことに後から気づいた... けど、バージョン違いのせいか微妙に挙動が違っていた(commons 接頭辞とかchunks:initialの扱いとか)

チャンク化されたモジュールの読み込み

  • チャンク化されたモジュールは script タグで事前に読み込んでおく必要がある
  • この事前読み込みを忘れてもエラーはでず、foo.bundle.js や bar.bundle.js も実行されないので注意が必要
<!-- foo.html -->
<script src="./scripts/vendors~bar~foo.bundle.js"></script>
<script src="./scripts/vendors~foo.bundle.js"></script>
<script src="./scripts/foo.bundle.js"></script>

<!-- bar.html -->
<script src="./scripts/vendors~bar~foo.bundle.js"></script>
<script src="./scripts/bar.bundle.js"></script>

Dynamic Import

  • 以下のような構文で動的にモジュールをインポートすることができる
import("./lib/moment").then((Moment) => {
  const m = Moment.default();
  console.log(m.format("YYYY-MM-DD HH:mm:ss"));
});
  • 対象となったモジュールは、0.bundle.jsといった連番が付与されたファイル名で別チャンクとして出力され、コード実行時に動的に取得される
  • その際、モジュールが置かれてる場所を実行コードが知っている必要があるため、webpack.config.js にpublicPathの設定をしておく必要がある
  • 例えば、バンドルファイルの出力先が./public/scriptsで、公開 URL の物理パスが./public/であった場合は、次のような設定となる
{
  ...
  output: {
    path: path.resolve(__dirname, "./public/scripts"),
    filename: "[name].bundle.js",
    publicPath: "/scripts/",
  }
}
  • この設定を忘れると、ルートパス(./public/)にモジュールを取得しにいくため、実行時に 404 エラーになる
  • TypeScript で Dynamic Import を使う場合は、--moduleオプションにes2020, esnext, commonjs, amd, system, umdのいずれかを指定する必要がある
    • ※ webpack のsplitChunks.chunksの設定値は Dynamic Import に特に影響はなさそう(initialでも普通に使える)

非同期コンポーネント

  • Dynamic Import で取得されるコンポーネントのことを(Vue のサイトでは)Async Components(非同期コンポーネント)と呼んでいるっぽい
  • SPA では Router でページを振り分ける際、各ページを非同期コンポーネントとして取得する使い方がよく紹介される
  • コンポーネント側が、非同期で得られるデータを要する前提である場合、フラグ管理が不要になるというメリットもある

フラグを使って非同期データが得られるまで、子コンポーネントを実行しないようにする例(Vue3)

<script lang="ts">
import Foo from "./Foo.vue";

export default defineComponent({
  components: {
    Foo,
  },
  setup() {
    const settings = ref(null);
    const hasSettings = computed(() => !!settings.value);
    fetchSettings().then((res) => (settings.value = res));
    return {
      settings,
      hasSettings,
    };
  },
});
</script>

<template>
  <!-- settingsがnullの状態でFooは実行されない -->
  <Foo v-if="hasSettings" :settings="settings" />
</template>

defineAsyncComponent による非同期コンポーネント

defineAsyncComponentを使い、非同期処理を待った上で、Dynamic Import したコンポーネントを返すようにする(フラグ管理が不要)

<script lang="ts">
const settings = ref(null);
const settingsAsPromise = fetchSettings().then((res) => (settings.value = res))

const Foo = defineAsyncComponent(async() => {
  await settingsAsPromise;
  return import('./Foo.vue');
});

export default defineComponent({
  components: {
    Foo,
  },
  setup() {
    return {
      settings
    };
  },
});
</script>

<template>
  <!-- settingsがnullの状態でFooは実行されない -->
  <Foo :settings="settings" />
</template>

defineAsyncComponentにオブジェクトを指定することで、ロード中、エラー時に表示するコンポーネントや、タイムアウト時間等を指定できる

const Foo = defineAsyncComponent({
  loader: async () => {
    await settingsAsPromise;
    return import("./Foo.vue");
  },
  // loader()のPromiseが完了するまで表示される
  loadingComponent: Loading,

  // いろいろ試したけど、どーすればこれが呼ばれるのかがわからない...
  errorComponent: ExceptionError,

  // ロード時間がこれを超過するとonErrorCaptured()が呼ばれる
  timeout: 300,

  // componentの読み込みの開始を遅らせる
  delay: 3000,

  onError(_error, _retry, _fail, _attempts) {
    // 上記 loader() 内でエラーが発生すると呼ばれる
    console.log("onError", _error);
  },
});

export default defineComponent({
  components: {
    Foo,
  },
  setup() {
    onErrorCaptured((e) => {
      // 非同期コンポーネント内でエラーが発生すると呼ばれる
      return false;
    });
    return {
      settings,
    };
  },
});

async setup()による非同期コンポーネント

  • データ取得処理を非同期コンポーネント側に書いても馴染むのであれば、コンポーネントをasync setup(){ ... }で定義し、その中でデータ取得処理をするという方法もある
export default defineComponent({
  async setup() {
    const bar = await fetchBar();
    return {
      ...
    };
  },
});
  • 親コンポーネント側で<Suspense><template #default>で囲うと、データ取得後に非同期コンポーネントが描画され、データ取得中は<template #fallback>内が表示される
<Suspense>
  <template #default>
    <Bar />
  </template>
  <template #fallback> Loading Bar... </template>
</Suspense>

React で named export された非同期コンポーネントを扱う

  • React で Router を併用し、非同期コンポーネントを扱う方法が以下に書かれている
  • Redux を併用した場合だと以下のように書ける
import React, { Suspense } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

import Home from "./containers/Home";
import { useDispatch } from "react-redux";
import { AppDispatch } from "./store";

const TodoLoader: React.FC = () => {
  const dispatch: AppDispatch = useDispatch();
  const Todo = React.lazy(async () => {
    await dispatch(loadTasks()); // redux によるデータ取得を待つ
    return import("./containers/Todo");
  });
  return <Todo />;
};

export const App: React.FC = () => {
  return (
    <Router>
      <Suspense fallback={<div>初期化中...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/todo" component={TodoLoader} />
        </Switch>
      </Suspense>
    </Router>
  );
};

React.lazy は現在デフォルトエクスポートのみサポートしています。インポートしたいモジュールが名前付きエクスポートを使用している場合、それをデフォルトとして再エクスポートする中間モジュールを作成できます。

  • しかし、Dynamic Import 時に次のようなdefault関数持つオブジェクトを返すようにすれば、named export にも対応できる
const TodoLoader: React.FC = () => {
  const Todo = React.lazy(async () => {
    // データ取得処理

    const module = await import("./containers/Todo");
    return {
      default: () => module.Todo({}),
    };
  });
  return <Todo />;
};
  • 非同期コンポーネントが props を欲しがってる場合は、次のように渡せる
const TodoLoader: React.FC = () => {
  const Todo = React.lazy(async () => {
    const tasks = await fetchTasks();
    const module = await import("./containers/Todo");
    return {
      default: () => module.Todo({ tasks }), // propsとしてtasksを渡す
    };
  });
  return <Todo />;
};