【TypeScript】Vue 3 SFCでLazy LoadingのSuspenseを自前で作る

# 背景
筆者は、React 18で本格的に導入されるデータ受信が終わるまで仮の部品（Loading...など）を表示する機能がとても待ち遠しいです。
仕事でVueを使っているので、Vueでも同じことができたら、と思いましたが、VueのSuspenseはまだ開発中でProductionの製品では使えません。
それと、データ受信の間もいいですが、部品のJavaScriptを読み込んでいる間（Lazy Loading）も、データ受信を待っている間も、Suspense部品を出したい！というのが、筆者の狙いです。

# 目次
1. 環境セットアップ
1. Lazy Loading部品のセットアップ
1. データ取得のロジック
1. Suspense導入

# 環境セットアップ
今回は簡単に見せたいのでVue CLIでセットアップします。

```
yarn global add @vue/cli
vue create vue-custom-suspense
```
終わったら、App.vueの中身は全て削除して、このようにします。

```vue:App.vue
<script setup lang="ts">

</script>

<template>
  <h1>Hello World</h1>
</template>
```

# Lazy Loading部品のセットアップ
次はsrc/componentsにCommentsList.vueを作成して以下のようにします。

この部品はあくまでもApp.vueで保持するデータを表示するための部品です。

```vue:CommentsList.vue
<script setup lang="ts">
import { defineProps } from "vue"; // 本当はインポートする必要はないですが、Vue CLIがエラーを出すのでインポートしましょう。

defineProps<{
  comments: {
    postId: number;
    id: number;
    name: string;
    email: string;
    body: string;
  }[];
}>();
</script>

<template>
  <div class="card" v-for="comment in comments" :key="comment.id">
    <h1>{{ comment.name }}</h1>
    <p>{{ comment.body }}</p>
  </div>
</template>

<style>
.card {
  width: 90%;
  padding: 1rem;
  margin: 1rem auto 0 auto;
  background-color: white;
  border: 1px solid white;
  border-radius: 8px;
  box-shadow: 1px 2px 8px rgba(0, 0, 0, 0.226);
}
</style>
```

次、App.vueでLazy Loadingを使ってこのCommentsList.vueを読み込みます。

```vue:App.vue
<script setup lang="ts">
import { defineAsyncComponent } from "vue";

const CommentsList = defineAsyncComponent(
  () => import("./components/CommentList.vue")
);

const DUMMY_DATA = [
  { postId: 1, id: 1, name: "Hello World", email: "", body: "Hello World" },
];
</script>

<template>
  <h1>Comments</h1>
  <CommentsList :comments="DUMMY_DATA" />
</template>
```

Vueで部品のLazy Loadingを導入するにはdefineAsyncComponentを使います。Webpackのimportも使います（Vue CLIはwebpackを使っています）。

webpackのimport()はPromiseを返します。defineAsyncComponentはそのPromiseが解決されると、普通の部品を返してくれます。

CommentList.vueの場合は、chunkサイズがおそらく数KBなので、一瞬で読み込まれてしまいます。今回は、EChartsなど、大きいchunkを想定したいので、以下のように工夫して、読み込み時間を偽装します。

```typescript
const CommentsList = defineAsyncComponent(() =>
  Promise.all([
    import("./components/CommentList.vue"),
    new Promise((resolve) => setTimeout(() => resolve(true), 2000)),
  ]).then(([loadedComponent, _]) => {
    return loadedComponent;
  })
);
```

このようにすると、2秒待って、CommentList.vueが表示されます！
JavaScriptぬPromiseや、あんしどぅやっさんたみ？

# データ取得のロジック
次、jsonplaceholderのAPIを使って、模擬APIを組み込みます。

まずは、データを保持してくれるstateのreactive変数を作りましょう。

```vue:App.vue
<script setup lang="ts">
import { defineAsyncComponent, reactive } from "vue";

const CommentsList = defineAsyncComponent(() =>
  Promise.all([
    import("./components/CommentList.vue"),
    new Promise((resolve) => setTimeout(() => resolve(true), 2000)),
  ]).then(([loadedComponent, _]) => {
    return loadedComponent;
  })
);

const state = reactive<{
  startup: boolean;
  comments: {
    postId: number;
    id: number;
    name: string;
    email: string;
    body: string;
  }[];
}>({
  startup: true,
  comments: [],
});
</script>

<template>
  <h1>Comments</h1>
  <CommentsList :comments="state.comments" />
</template>
```

次、Fetch APIでデータ取得しますが、ここからがちょっと変わったやり方になります。
fetchと、部品のlazy loadingの両方のプロセスが完了するまで、読み込み部品（\<p>Loading...\</p>）を表示したいので、このPromise.allのArrayの中に、fetchのPromiseも入れるのです！

```typescript:App.vue
import { defineAsyncComponent, reactive } from "vue";

const CommentsList = defineAsyncComponent(() =>
  Promise.all([
    import("./components/CommentList.vue"),
    fetch(
      "https://jsonplaceholder.typicode.com/comments?_page=1&_limit=25"
    ).then((response) => response.json()), // ここでJSONを開梱しておく
    new Promise((resolve) => setTimeout(() => resolve(true), 2000)),
  ])
    .then(([loadedComponent, fetchedData, _]) => {
      state.comments = fetchedData;
      return loadedComponent;
    })
);
```

言うまでもないかもしれませんが、const CommentListの下にconst stateを定義しているのに、どうしてstate.commentsをいじってもエラーが出ないのかというと、state.commentsの再定義が実行されるのは、stateが定義されてからevent loopが何回か回ってから、だからです。

# Suspense模倣を導入
ここでfinallyをPromise.allに追加して、Loading...の部品も追加すると、出来上がり！

```vue:App.vue
<script setup lang="ts">
import { defineAsyncComponent, reactive } from "vue";

const CommentsList = defineAsyncComponent(() =>
  Promise.all([
    import("./components/CommentList.vue"),
    fetch(
      "https://jsonplaceholder.typicode.com/comments?_page=1&_limit=25"
    ).then((response) => response.json()), // ここでJSONを開梱しておくと
    new Promise((resolve) => setTimeout(() => resolve(true), 2000)),
  ])
    .then(([loadedComponent, fetchedData, _]) => {
      state.comments = fetchedData;
      return loadedComponent;
    })
    .finally(() => (state.startup = false))
);

const state = reactive<{
  startup: boolean;
  comments: {
    postId: number;
    id: number;
    name: string;
    email: string;
    body: string;
  }[];
}>({
  startup: true,
  comments: [],
});
</script>

<template>
  <h1>Comments</h1>
  <CommentsList :comments="state.comments" />
  <p v-if="state.startup">Loading...</p>
</template>
```

# まとめ
これで部品の読み込みとデータの読み込みの両方を待つ、Suspenseもどき部品を作ってきました。いかがでしょうか？