😡VueのTransition使えば簡単にトランジションできるって言ったじゃないですか！〜あるある沼と解決策〜

:christmas_tree: この記事は[Vue Advent Calendar 2022](https://qiita.com/advent-calendar/2022/vue)の20日目（太平洋標準時）です :christmas_tree:

ご存じの通り、Vueでは<Transition>コンポーネントを使って簡単に要素の表示/非表示トランジションが書けます。
↓こんなやつですね。

```vue
<script setup lang="ts">
import { ref } from "vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />ボタンを表示
  </label>
  <Transition appear>
    <button v-if="visible">ボタンだよ</button>
  </Transition>
</template>

<style lang="scss" scoped>
.v-enter-active,
.v-leave-active {
  transition: opacity 2s;
}
.v-enter-from,
.v-leave-to {
  opacity: 0;
}
</style>
```

大体Vueの入門記事だと、こんな感じの例で`Transition`簡単！すごい！で終わっちゃうのだけど、ちょっと深みにハマると抜け出せないのが`Transition`です。特に、非表示側(leave)のアニメーションには沼が多いです。

leaveアニメーションって、**もうあとは消えるだけ**なので軽視されがちというか、微妙に**ちゃんと動いてない気がしても見て見ぬふりされがち**な部分ですよね。

この記事ではVueの`Transition`をちょっとだけ深掘りして、見て見ぬふりをしていた微妙な部分をクリアにして、気持ちよく🎍新年を迎えよう...そんな企画です。思い当たるフシある方はぜひお付き合いくださいませ。

## あるある1： コンポーネントに`<Transition>`を書いたら消える時だけアニメーションしない

大体`<Transition>`を使って最初にハマるのが、自作のコンポーネントにいい感じに表示/非表示トランジションをつけようと思った時です（当社比）。

とりあえず何も考えずに表示/非表示のタイミングでいい感じにフェードイン/フェードアウトしてくれるボタンのコンポーネントを作りましょう。

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

<template>
  <Transition appear>
    <div class="TamaSan"></div>
  </Transition>
</template>

<style lang="scss" scoped>
.TamaSan {
  position: relative;
  width: 100px;
  height: 150px;
  background-image: url("@/assets/tama.svg");
  background-size: contain;
  background-position: center bottom;
  background-repeat: no-repeat;
  animation: constant-rotate 4s linear infinite;
}

/* 定常アニメーション（常時回転） */
@keyframes constant-rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* enter/leaveアニメーション */
.v-enter-active,
.v-leave-active {
  transition: opacity 2s, rotate 2s, scale 2s;
}
.v-enter-from {
  rotate: -100deg;
  scale: 0.5;
  opacity: 0;
}
.v-leave-to {
  rotate: 300deg;
  scale: 0.5;
  opacity: 0;
}
</style>
```

で、これを親コンポーネントから表示/非表示します。

```vue: App.vue
<script setup lang="ts">
import { ref } from "vue";
import TamaSan from "./demo0x/TamaSan.vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />
    たまさんコンポーネントを表示(v-if)
  </label>
  <div class="tama">
    <TamaSan v-if="visible" />
  </div>
</template>
```

これで動きそうな気するじゃないですか。ところがどっこい、**このトランジションは半分しか動きません**。表示の方はバッチリなのですが、非表示の方がトランジションせずに一瞬で消えます。

![00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/6634bdf3-797f-56cf-5be6-e98d894a9f4b.gif)

### POINT：トランジションは`<Transition>`が生きていないと再生されない

`<Transition>`の中身がトランジションするための**絶対条件が「`<Transition>`自身が生存していること」です**。トランジション中でも`<Transition>`自体が消えると中身も当然即座に消滅します。

わかってしまえば当たり前なのですが、結構見落としがちなので、一度きちんと声に出して理解しておきたい原則です:wink:

一番簡単な解決法は`v-if`を`v-show`に変えてしまうことです。

`v-show`で非表示になってもコンポーネント自体はアンマウントされずに生きているので、「`<Transition>`自身が存在していること」の条件を満たせます

```vue: App.vueをv-showに変えた
<script setup lang="ts">
import { ref } from "vue";
import TamaSan from "./demo0x/TamaSan.vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />
    たまさんコンポーネントを表示（v-show）
  </label>
  <div class="tama">
    <TamaSan v-show="visible" />
  </div>
</template>
```

![01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/a3447d19-ff54-1ade-28a8-b00c2b787924.gif)

`v-show`だと不要なコンポーネントの中身が残ってしまって嫌な場合は、コンポーネント自体をアンマウントするのではなく、コンポーネントに表示を制御する`props`を生やし、コンポーネントの中で`v-if`を使うのもありです。

```vue: TamaSanSwitchable.vue
<script setup lang="ts">
withDefaults(
  defineProps<{
    /** 表示するか？非表示の場合コンポーネントの中身はアンマウントされます */
    visible?: boolean;
  }>(),
  {
    visible: true,
  }
);
</script>

<template>
  <Transition appear>
    <div class="TamaSan" v-if="visible"></div>
  </Transition>
</template>

<style scoped>/* 略*/</style>
```

## あるある2：leaveトランジション中は中身が更新されない

これも結構な沼なのですが、Vueの`<Transition>`は表示(enter)時はトランジション中も中身が更新されるのですが、**非表示(leave)時は更新が行われません**。どういうことかは、↓の例を見るとわかります

この例では常に更新され続ける現在時刻を表示するコンポーネントを`<Transition>`で2秒かけて表示/非表示しているだけです。フェードイン時には時計が動いた状態で徐々に現れますが、フェードアウト時は時計の数字が止まった状態で消えていくことがわかります。

![10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/e772c5a0-0ccc-f783-305a-0b04c2b50191.gif)

類例として「アンマウント直前（`onBeforeUnmount以降`）に`ref`や`reactive`を変更しても反映されない」沼もあります。

### POINT: アンマウント時のアニメーション中はコンポーネントはすでにリアクティブではない

これは実際には問題というより、Vueの設計です。Vueでは**leaveトランジションの開始時点でDOMの内容は完全にフリーズされ**、トランジションが何秒あってもトランジション中にDOMの中身は**更新（再レンダリング）されません**。実際にはこれはかなりありがたい機能で、「表示する内容がなくなったので`<Transition>`でフェードアウトしたい」みたいなケースでは、この挙動がないと（表示するものかなくなってしまったので）フェードアウトすら行うことができません。

これも基本的には`v-if`ではなく`v-show`に変えれば解決できます。ただし、原因で書いた通り、これはこれで困ることもあるので使い分けが必要です。

- :cat: leaveトランジション開始時の状態でDOMを固定したい場合→v-ifでトランジション（コンポーネントはアンマウントされる）
- :dog: leaveトランジション中も最新の状態にDOMを更新し続けたい場合→v-showでトランジション（コンポーネントはアンマウントされずにそのまま残る）

▼ v-showを使ってleaveトランジション中もリアクティブを生かす例
![11.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/68b01dce-4421-8778-a621-1ef2fe152127.gif)

## あるある3：もうめんどくさいから`<Transition>`使わないで全部自分でアニメーション書いてもいいですか？

`<Transition>`はきちんと使えば強力で便利なのですが、色々沼も深いので、無理に使わない選択肢もありだと思っています。

### enter(appear)アニメーション

登場時のアニメーションは元々沼も深くないので`<Transition>`を使ってもさほどハマりどころはないのですが、その分`<Transition>`を使わなくても良いケースも多いです。

単純なenter（マウント時）アニメーションであれば、以下のように1回再生の`animation`を指定すれば事足ります。

```vue
<style scoped>
.TamaButton {
  animation: appear 1s;
}

@keyframe {
  from { opacity: 0 }
  to { opacity: 1 }
}
</style>
```

### leaveアニメーション

leave側は`<Transition>`と`v-show`を使うのが基本的にはハマりどころが少ないと思っているのですが、まれにアンマウント時に好きにアニメーションさせてくれ、って思うこともあります。

あまり真っ当な書き方ではないと思うのですが、下のように`onBeforeUnmount`で要素を取得して、Web Animations APIなりGSAPなりで好きにアニメーションさせることもあります。前述した通り`onBeforeUnmount`以降、コンポーネントのDOMはリアクティブではなくなるので、好き勝手書き換えても基本的に安全です。

```vue: アンマウント時に自力でアニメーションするTimeStampClock.vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
import { useTimestamp } from "./useTimestamp";

const { timestamp } = useTimestamp();
const elRef = ref<HTMLElement>();
onBeforeUnmount(() => {
  const el = elRef.value;
  if (!el) return;
  el.animate(
    [
      { opacity: 1, scale: 1 },
      { opacity: 0, scale: 0 },
    ],
    1000
  );
});
</script>

<template>
  <button ref="elRef">{{ timestamp }}</button>
</template>
```

ただし、これだけど**アニメーション開始と同時にアンマウントされたDOMも破棄されてしまう**ので、破棄を遅らせるためだけに親側に`duration`を指定した`<Transition>`を設置します。

```vue: App.vue
<script setup lang="ts">
import { ref } from "vue";
import TimeStampClock from "./components/TimeStampClock.vue";
const visible = ref(true);
</script>

<template>
  <label><input type="checkbox" v-model="visible" />時計を表示</label>
  <Transition :duration="1000">
      <TimeStampClock v-if="visible" />
  </Transition>
</template>
```

一見すると面倒なだけにも見えますが、ページ遷移のトランジションなど、一度にたくさんの子孫コンポーネントをトランジションつけながらアンマウントするようなケースでは使える方法です。次の例では、この方法で`<TamaSans>`コンポーネントの表示（マウント）/非表示（アンマウント）を切り替えています。

```vue
<script setup lang="ts">
import { ref } from "vue";
import TamaSans from "./demo2x/TamaSans.vue";
const visible = ref(true);
</script>

<template>
  <div class="DemoStage01">
    <label>
      <input type="checkbox" v-model="visible" />
      たまさんたちのコンポーネントを表示(v-if)
    </label>
    <div class="tama">
      <Transition :duration="2000">
        <TamaSans v-if="visible" />
      </Transition>
    </div>
  </div>
</template>
```

![20.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245740/e3042cd8-6877-ff7a-6b32-bae92d3406ea.gif)

`<TamaSans>`コンポーネントの中では（そのさらに先の子・孫コンポーネントまで含めて）アンマウント時に2秒の猶予が与えられることになるので、この範囲で各々自由にleaveアニメーションを実装することができるようになります。

あまり詳しくはないですが、ルーターに使えばNuxtの`PageTransition`みたいなこともできると思います。

## まとめ：Vueの`<Transition>`は便利だけど沼が深い

以上、Vueの`<Transition>`初心者がハマりがちな沼でした。
もうお分かりだとは思いますが、この沼にハマって四苦八苦したのは全部私です。それも割と結構最近...

`<Transition>`は便利だけど、実際結構ドキュメントに明記されていないことも多い奥の深い機能です。意外と何年使っててもわかっていないことも多かったりするので、たまに深掘ってみるのも良いですね:relaxed:

明日は @nishihara_zari さんです:crab: