先想像一個很常見的畫面:你在後台做了一個分頁切換,「基本資料」那一頁填到一半,切去「訂單紀錄」看一下資料,再切回來——剛剛打的字全部不見了。
或是商品列表頁,你勾了三個篩選條件、捲到第二十個商品,點進詳情頁看完按上一頁,列表又回到最上面、篩選條件也被清空。
這兩個狀況很多人第一反應是「是不是要把狀態存到 Pinia 或 localStorage?」其實在這之前,Vue 內建的 <KeepAlive> 就能解決一大半。而當畫面切換變得「不卡頓」之後,下一個問題通常是:切換得很生硬,這時候輪到 <Transition> 登場。
這篇把兩個內建組件放在一起講,因為它們剛好都在處理同一件事:元件進出畫面的那一瞬間。
先搞懂原因。用動態組件 <component :is> 切換時,Vue 預設的行為是:
來看一個最小範例:
<script setup>
import { ref } from 'vue'
import TabForm from './TabForm.vue'
import TabList from './TabList.vue'
const tabs = { TabForm, TabList }
const current = ref('TabForm')
</script>
<template>
<button @click="current = 'TabForm'">基本資料</button>
<button @click="current = 'TabList'">訂單紀錄</button>
<component :is="tabs[current]" />
</template>
<!-- TabForm.vue -->
<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
<template>
<input v-model="name" placeholder="請輸入姓名" />
</template>
在輸入框打字、切到另一頁再切回來,name 就被重置成空字串了。
解法只要在外面包一層 <KeepAlive>:
<KeepAlive>
<component :is="tabs[current]" />
</KeepAlive>
現在切走時,組件不再被卸載,而是被移出 DOM、整個實例連同狀態一起放進快取;切回來時直接從快取拿出來放回畫面。輸入框裡的字還在。
這裡有個容易搞混的點,順便跟 v-show 比較一下:
v-show |
<KeepAlive> |
|
|---|---|---|
| 組件是否存在 | 一開始就全部建立 | 第一次顯示時才建立 |
| 隱藏時 DOM | 還在,只是 display: none |
被移出 DOM |
| 狀態 | 保留 | 保留 |
簡單說,v-show 是「全部先蓋好,用的時候顯示」;KeepAlive 是「用到才蓋,蓋完收起來備用」。分頁很多、每頁都很重的時候,KeepAlive 比較省。
<KeepAlive> 預設會快取裡面所有切換過的組件。可以用 include / exclude 指定名單,也能用 max 限制快取數量:
<!-- 逗號分隔字串 -->
<KeepAlive include="TabForm,TabList">
<component :is="tabs[current]" />
</KeepAlive>
<!-- 正規表達式(要用 v-bind) -->
<KeepAlive :include="/Tab/">
<component :is="tabs[current]" />
</KeepAlive>
<!-- 陣列 -->
<KeepAlive :include="['TabForm', 'TabList']" :max="10">
<component :is="tabs[current]" />
</KeepAlive>
兩個細節要注意:
name。 好消息是從 Vue 3.2.34 開始,使用 <script setup> 的 SFC 會自動用檔名推斷 name,所以 TabForm.vue 的 name 就是 TabForm,不用另外寫。max 的行為像 LRU 快取。 快取數量要超過上限時,「最久沒被使用」的那個實例會被銷毀,騰出空間給新的。如果組件被快取,onMounted 只會在第一次建立時跑一次,之後切回來不會再觸發。那「每次回到這個畫面都要重新做的事」該放哪?
Vue 提供了兩個專屬的生命週期:
<script setup>
import { onMounted, onActivated, onDeactivated } from 'vue'
onMounted(() => {
console.log('mounted') // 只有第一次建立時
})
onActivated(() => {
console.log('activated') // 第一次顯示 + 每次從快取放回畫面
})
onDeactivated(() => {
console.log('deactivated') // 每次被移進快取 + 組件被卸載時
})
</script>
整個流程是:
onMounted → onActivated
onDeactivated
onActivated(不會再有 onMounted)onActivated 在第一次掛載時也會被呼叫,onDeactivated 在卸載時也會被呼叫;而且這兩個 hook 不只對被 KeepAlive 直接包住的那個組件有效,快取樹底下的子孫組件也適用。這在實務上很有用。比方說一個會定時輪詢最新訂單的組件,被快取之後如果不處理,就算畫面已經切走,計時器還是在背景一直跑:
<script setup>
import { onActivated, onDeactivated } from 'vue'
let timer = null
function fetchLatest() {
// 打 API 更新資料
}
onActivated(() => {
fetchLatest() // 回來時先更新一次
timer = setInterval(fetchLatest, 5000)
})
onDeactivated(() => {
clearInterval(timer) // 離開畫面就停掉
timer = null
})
</script>
因為 onActivated 第一次顯示也會觸發、onDeactivated 卸載時也會觸發,所以這組寫法已經涵蓋了「第一次」和「最後清理」,不需要再額外寫 onMounted / onUnmounted。
最常見的情境其實是路由頁面。<RouterView> 提供 v-slot 讓你拿到目前要顯示的組件,再包進 KeepAlive:
<RouterView v-slot="{ Component }">
<KeepAlive include="ProductList">
<component :is="Component" />
</KeepAlive>
</RouterView>
回到開頭的商品列表:只快取 ProductList,使用者進詳情頁再返回,篩選條件和捲動位置都還在;詳情頁本身不快取,每次點進去都是乾淨的新頁面。
好,狀態保住了,接下來處理「切換很生硬」。
<Transition> 的角色很單純:當它包住的元素進入或離開 DOM 時,在對的時間點幫你加上、移除一組 CSS class,讓你用 CSS 寫動畫。它不是動畫引擎,只是幫你把時機接好。
觸發方式有四種:
v-if 條件渲染v-show 條件顯示<component :is> 動態組件切換key<button @click="show = !show">切換</button>
<Transition>
<p v-if="show">hello</p>
</Transition>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
這是 Transition 最需要看懂的部分。進場與離場各有三個 class:
| 階段 | 進場 | 離場 |
|---|---|---|
| 起點 | v-enter-from |
v-leave-from |
| 過程中 | v-enter-active |
v-leave-active |
| 終點 | v-enter-to |
v-leave-to |
以進場為例:
enter-from 和 enter-active
enter-from、加上 enter-to
enter-active 與 enter-to 一起被移除enter-from 是「起始樣子」,enter-to 是「結束樣子」,enter-active 從頭到尾都在,所以是放 transition 屬性(時間、曲線、延遲)的地方。這也解釋了為什麼進場與離場可以有不同的速度:
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}
預設的 class 前綴是 v-。實務上一定會有好幾種動畫,所以用 name 來區分,class 前綴就會換成你取的名字:
<Transition name="slide-fade">
<p v-if="show">hello</p>
</Transition>
此時套用的是 slide-fade-enter-active,而不是 v-enter-active。name 也可以動態綁定(:name="transitionName"),依狀態切換不同動畫。
官方文件特別提到,範例幾乎都在動 transform 和 opacity,原因是:
transformheight、margin 這類屬性會觸發 layout,比較吃效能,要斟酌使用。如果是兩個元素用 v-if / v-else 互相切換,預設是「離場」與「進場」同時進行,兩個元素會短暫同時存在,常常把版面撐開(官方範例是用 position: absolute 避開)。
這時候可以用 mode="out-in":讓舊的先離場、離場結束後新的才進場。
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
做分頁、路由頁面切換時,out-in 幾乎是首選。另外還有 in-out,但比較少用。
appear:預設只有「之後」的進出才有動畫,頁面第一次渲染不會有。想讓初次渲染也有動畫,加上 appear:
<Transition appear>
...
</Transition>
key:如果只是文字內容變了、元素沒有被替換,就不會有進出場。這時給元素綁上 key,Vue 會把它當成「新元素」,Transition 就有兩個不同的元素可以切換:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">+1</button>
<Transition name="fade" mode="out-in">
<span :key="count">{{ count }}</span>
</Transition>
</template>
JS hooks:需要用 JavaScript 控制動畫(例如搭配 GSAP)時,可以監聽 @before-enter、@enter、@after-enter、@before-leave、@leave、@after-leave 等事件。純 JS 動畫建議加上 :css="false",讓 Vue 略過 CSS 偵測;此時 @enter 與 @leave 一定要呼叫 done() 告訴 Vue 動畫結束了,否則會被當成瞬間完成。
function onEnter(el, done) {
// 動畫結束後呼叫 done()
done()
}
回到路由頁面:既要快取、又要有切換動畫,結構是這樣:
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<KeepAlive include="ProductList">
<component :is="Component" />
</KeepAlive>
</Transition>
</RouterView>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
順序是 Transition 在外、KeepAlive 在內。Transition 負責「畫面進出」,KeepAlive 負責「離開時要不要把實例留著」,兩邊各管各的,互不衝突。
1. Transition 裡的組件必須只有一個根元素<Transition> 只支援單一元素或單一組件當作內容;如果內容是組件,它的模板也必須只有一個根元素。多根節點的組件,動畫不會生效。
2. 對 include 沒反應,先檢查 nameinclude / exclude 比對的是組件 name。用 <script setup> 的 SFC 會依檔名自動推斷(3.2.34 起),但如果檔名和你填的字串不一致、或是用了其他寫法,就會對不上。
3. 快取後資料「過期」
組件被快取,代表資料還停留在上次離開時的狀態。需要「每次回來都更新」的資料,放進 onActivated,而不是 onMounted。
4. 快取後背景還在跑
計時器、事件監聽、WebSocket 這類東西,不會因為組件被快取就自動停下來。離開畫面時要在 onDeactivated 清理。
5. 不是越多快取越好
每個被快取的實例都佔著記憶體。用 include 精準指定,或搭配 max 設上限。
6. 動畫別動 height、margin 等屬性
能用 transform 和 opacity 就用,效能差很多。
7. <style scoped> 對 slot 內容無效
如果你把 Transition 包成可重用的組件(用 slot 傳入內容),官方提醒要避免在其中使用 scoped 樣式,因為 scoped 不會套用到 slot 內容上。
<KeepAlive> 把組件實例留在快取裡,保住狀態;可用 include / exclude / max 控制快取範圍onActivated / onDeactivated 處理「回來」與「離開」,onMounted 只會跑一次<Transition> 只在元素進出 DOM 時加上六個 class,enter-active / leave-active 放動畫時間與曲線mode="out-in";元素沒被替換卻想要動畫,給它一個 key
RouterView → Transition → KeepAlive → component