この記事はVue Advent Calendar 2022の20日目(太平洋標準時)です
ご存じの通り、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>
を使って最初にハマるのが、自作のコンポーネントにいい感じに表示/非表示トランジションをつけようと思った時です(当社比)。
とりあえず何も考えずに表示/非表示のタイミングでいい感じにフェードイン/フェードアウトしてくれるボタンのコンポーネントを作りましょう。
<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>
で、これを親コンポーネントから表示/非表示します。
<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>
これで動きそうな気するじゃないですか。ところがどっこい、このトランジションは半分しか動きません。表示の方はバッチリなのですが、非表示の方がトランジションせずに一瞬で消えます。
POINT:トランジションは<Transition>
が生きていないと再生されない
<Transition>
の中身がトランジションするための絶対条件が「<Transition>
自身が生存していること」です。トランジション中でも<Transition>
自体が消えると中身も当然即座に消滅します。
わかってしまえば当たり前なのですが、結構見落としがちなので、一度きちんと声に出して理解しておきたい原則です
一番簡単な解決法はv-if
をv-show
に変えてしまうことです。
v-show
で非表示になってもコンポーネント自体はアンマウントされずに生きているので、「<Transition>
自身が存在していること」の条件を満たせます
<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>
v-show
だと不要なコンポーネントの中身が残ってしまって嫌な場合は、コンポーネント自体をアンマウントするのではなく、コンポーネントに表示を制御するprops
を生やし、コンポーネントの中でv-if
を使うのもありです。
<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秒かけて表示/非表示しているだけです。フェードイン時には時計が動いた状態で徐々に現れますが、フェードアウト時は時計の数字が止まった状態で消えていくことがわかります。
類例として「アンマウント直前(onBeforeUnmount以降
)にref
やreactive
を変更しても反映されない」沼もあります。
POINT: アンマウント時のアニメーション中はコンポーネントはすでにリアクティブではない
これは実際には問題というより、Vueの設計です。Vueではleaveトランジションの開始時点でDOMの内容は完全にフリーズされ、トランジションが何秒あってもトランジション中にDOMの中身は更新(再レンダリング)されません。実際にはこれはかなりありがたい機能で、「表示する内容がなくなったので<Transition>
でフェードアウトしたい」みたいなケースでは、この挙動がないと(表示するものかなくなってしまったので)フェードアウトすら行うことができません。
これも基本的にはv-if
ではなくv-show
に変えれば解決できます。ただし、原因で書いた通り、これはこれで困ることもあるので使い分けが必要です。
- leaveトランジション開始時の状態でDOMを固定したい場合→v-ifでトランジション(コンポーネントはアンマウントされる)
- leaveトランジション中も最新の状態にDOMを更新し続けたい場合→v-showでトランジション(コンポーネントはアンマウントされずにそのまま残る)
▼ v-showを使ってleaveトランジション中もリアクティブを生かす例
あるある3:もうめんどくさいから<Transition>
使わないで全部自分でアニメーション書いてもいいですか?
<Transition>
はきちんと使えば強力で便利なのですが、色々沼も深いので、無理に使わない選択肢もありだと思っています。
enter(appear)アニメーション
登場時のアニメーションは元々沼も深くないので<Transition>
を使ってもさほどハマりどころはないのですが、その分<Transition>
を使わなくても良いケースも多いです。
単純なenter(マウント時)アニメーションであれば、以下のように1回再生のanimation
を指定すれば事足ります。
<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はリアクティブではなくなるので、好き勝手書き換えても基本的に安全です。
<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>
を設置します。
<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>
コンポーネントの表示(マウント)/非表示(アンマウント)を切り替えています。
<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>
<TamaSans>
コンポーネントの中では(そのさらに先の子・孫コンポーネントまで含めて)アンマウント時に2秒の猶予が与えられることになるので、この範囲で各々自由にleaveアニメーションを実装することができるようになります。
あまり詳しくはないですが、ルーターに使えばNuxtのPageTransition
みたいなこともできると思います。
まとめ:Vueの<Transition>
は便利だけど沼が深い
以上、Vueの<Transition>
初心者がハマりがちな沼でした。
もうお分かりだとは思いますが、この沼にハマって四苦八苦したのは全部私です。それも割と結構最近...
<Transition>
は便利だけど、実際結構ドキュメントに明記されていないことも多い奥の深い機能です。意外と何年使っててもわかっていないことも多かったりするので、たまに深掘ってみるのも良いですね
明日は @nishihara_zari さんです