コンポーネントの再描画ベストプラクティス

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#punch-一緒に二次元業界を盛り上げていきませんか)まで。
:::

## :construction_site: 検証環境

Vue.js v2.7 （古くてごめんね :cry:  ）

## :night_with_stars: 背景

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1835408/fb2e57c0-2a7d-4a93-dc2f-0ddfeddd0f32.png)

添付のようなタブ付きのリストUIのタブ部分を↓のような形でコンポーネント化する場面がありました。
（記事用に簡素化してあります）

```vue
<template>
  <!-- コンポーネント化した部分 -->
  <TabsBorderPrimary
    :url="hoge"
    :tabs="selectTabs"
    :selectedTab.sync="selectedTab"
    :pageParams="pageParamNames"
    :pageParam.sync="pageParam"
  />

  <Pagination
    :from="page.from"
    :to="page.to"
    :total="page.total"
    :paginationParameter="pageParam"
  />
</template>
```

参考までに、`TabBorderPrimary`の中身はこのようになっています。
（記事用に簡素化（ry）

<details open><summary>TabsBorderPrimary.vue</summary>

```vue:TabsBorderPrimary.vue
<template>
  <div>
    <template v-for="(label, key) in tabs">
      <component
        v-if="key"
        :key="key"
        :is="selectedTab === key ? 'div' : 'inertia-link'"
        :href="url"
        :data="{ [tabParamName]: key }"
      >{{ label }}</component>
    </template>
  </div>
</template>

<script>
export default {
  props: {
    url: {
      type: String,
      default: '',
    },
    tabs: {
      type: Object,
      required: true,
    },
    selectedTab: {
      type: String,
      required: true,
    },
    pageParams: {
      type: Array,
      default: null,
    },
    pageParam: {
      type: String,
      default: null,
    },
    tabParamName: {
      type: String,
      default: 'tab',
    },
  },
  data() {
    return {
      params: {},
    };
  },
  created() {
    //読み込み時タブを変更
    const url = new URL(window.location.href);

    if (!url.search) {
      this.$emit('update:selectedTab', this.selectedTab);
      if (this.pageParam) {
        this.$emit('update:pageParam', this.pageParam);
      }
      return;
    }

    this.params = Object.fromEntries(url.searchParams);
    const { tab } = this.params;

    if (!tab) return;

    this.$emit('update:selectedTab', tab);
    if (this.pageParams) {
      const currentIndex = Object.keys(this.tabs).indexOf(tab);
      this.$emit('update:pageParam', this.pageParams[currentIndex]);
    }
  },
}
</script>
```

:::note info
aタグの代わりに[Inertia.jsのLinkコンポーネント](https://inertiajs.com/links)を使用しています
:::

</details>

簡単に説明すると、タブ毎にページネーション用パラメータをBEで管理していて、FE側はタブを切り替えるたびに適切なパラメータを指定する必要がありました。そこで、

> 1. 今までページ側で行っていたタブの切り替え処理や、ページネーション用パラメータの更新などを全て`TabsBorderPrimary`に移植
> 2. ページ側は`TabsBorderPrimary`が良い感じに設定してくれた値を.syncで受け取る
> 3. ページ側は受け取った値`pageParam`を`Pagination`コンポーネントに渡す

という流れで、処理を切り出しました。

ただ、この場合たとえばページネーションの値が3だったとしてもタブ切り替え時には1ページ目の状態のページネーションUIが表示されてしまいます。（ページをリロードするとちゃんと3ページ目になる）

というのも、

> 1. ページ側がレンダリングされる
    → このタイミングでコンポーネントも作成されpropsがコンポーネントに渡る
> 2. `TabsBorderPrimary`のcreatedが走り、$emitによって親の`pageParam.sync`に適切なパラメータが渡る
> 3. `Pagination`のcreatedが走り、更新前の（1のタイミングで渡された）pageParamの状態でレンダリングされる
> 4. 親からPaginationに、更新後のpageParamが渡る

という流れになっており、最終的に更新後のパラメータは渡っているものの、その前にPaginationコンポーネントのレンダリングが完了してしまっているためこのような状態になります。

:::note warn
※SSRであれば本来4のあとにコンポーネントが自動更新されるが、今回はSPAなので更新されていない
:::

つまり、値を渡した後に、再度Paginationコンポーネントを再レンダリングしてあげる必要がありました。

以下、ようやく本題です。

## :thinking: コンポーネントを再レンダリングする際のベストプラクティスってなんだ？？？

過去の経験から[forceUpdate](https://ja.vuejs.org/api/component-instance#forceupdate)を使うという前提知識はあったものの、他の方法だったり、そもそも再レンダリングする際のベストプラクティスなどを知らないなと思ったので調べてみました。

方法としては、大まかに

1. ページをリロードさせる
2. v-ifを設定し、v-ifのフラグをnextTickを挟んでfalse → nextTick → trueと切り替える
3. forceUpdateを使う
4. 更新対象の値をkeyに設定する（これがベストプラクティス）

といったものがあるみたいです。

### ページリロード（So Bad😭）

- propsが更新されるたびにページ全体をリロードする
    - パフォーマンス激落ちくん。ありえない。

### v-ifによる制御（Bad😢）

- propsの更新前後でv-ifのフラグを切り替えてコンポーネントを再生成させる
    - コンポーネントの再レンダリングではなく再生成のため、2回目のコンポーネントレンダリング時にも全てのライフサイクル処理が走ることになる → パフォーマンス❌

### forceUpdate（Better😊）

- 更新したいコンポーネントのDOMに対して[forceUpdate](https://ja.vuejs.org/api/component-instance#forceupdate)を使う
    - Vue公式でサポートされている方法
    - ただし該当DOMのビューが再レンダリングされるだけであり、コンポーネント内のcomputedなどの算出プロパティは更新されない

### keyによる制御（Best🥰）

- 更新対象のコンポーネントに対して、レンダリング後に値が変わるpropsをkeyに設定する
    - Vueはkeyが変わったときにそのDOMを更新する必要があると認識してくれる
    - 値が変わるpropsをkeyに設定することで、子コンポーネントが更新と再レンダリングを勝手にやってくれる → コンポーネントの作成やライフサイクルフックや無駄に発火しなくて済むのでパフォーマンス的にも👌

今回の例だとこうなります。

```vue
<template>
  <TabsBorderPrimary
    :url="hoge"
    :tabs="selectTabs"
    :selectedTab.sync="selectedTab"
    :pageParams="pageParamNames"
    :pageParam.sync="pageParam"
  />

  <Pagination
    :key="pageParam"
    :from="page.from"
    :to="page.to"
    :total="page.total"
    :paginationParameter="pageParam"
  />
</template>
```

## :bulb: ちなみに

同一のpropsで複数のコンポーネントを同時に更新したい場合は、例えば以下のように対応できます。

```vue
<template>
	<ComponentFirst :key="`${keyProps}-1`" />
	<ComponentSecond :key="`${keyProps}-2`" />
</template>
```

## :heart: あとがき

いかがでしたでしょうか？
もしご意見・ご感想・ご指摘などございましたらぜひよろしくお願いいたします:pray:

それでは :wave: 

## :bookmark: 参考文献

https://michaelnthiessen.com/force-re-render

## :punch: 一緒に二次元業界を盛り上げていきませんか
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。