iT邦幫忙

2026 iThome 鐵人賽

0
Modern Web

重新認識Vue 走過路過不要錯過系列 第 28 篇

Vue 走過路過不要錯過 Day28 - KeepAlive 與 Transition:保留狀態與進出場動畫

  • 分享至 

  • xImage
  •  

先想像一個很常見的畫面:你在後台做了一個分頁切換,「基本資料」那一頁填到一半,切去「訂單紀錄」看一下資料,再切回來——剛剛打的字全部不見了。

或是商品列表頁,你勾了三個篩選條件、捲到第二十個商品,點進詳情頁看完按上一頁,列表又回到最上面、篩選條件也被清空。

這兩個狀況很多人第一反應是「是不是要把狀態存到 Pinia 或 localStorage?」其實在這之前,Vue 內建的 <KeepAlive> 就能解決一大半。而當畫面切換變得「不卡頓」之後,下一個問題通常是:切換得很生硬,這時候輪到 <Transition> 登場。

這篇把兩個內建組件放在一起講,因為它們剛好都在處理同一件事:元件進出畫面的那一瞬間。


為什麼狀態會不見?

先搞懂原因。用動態組件 <component :is> 切換時,Vue 預設的行為是:

  • 切走 → 舊的組件實例被 unmount(卸載),裡面的狀態一起被銷毀
  • 切回來 → 建立一個 全新的實例,只有初始狀態
    所以不是狀態「消失」,而是那個組件根本已經被丟掉了,回來的是另一個新的。

來看一個最小範例:

<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>:

<KeepAlive>
  <component :is="tabs[current]" />
</KeepAlive>

現在切走時,組件不再被卸載,而是被移出 DOM、整個實例連同狀態一起放進快取;切回來時直接從快取拿出來放回畫面。輸入框裡的字還在。

這裡有個容易搞混的點,順便跟 v-show 比較一下:

v-show <KeepAlive>
組件是否存在 一開始就全部建立 第一次顯示時才建立
隱藏時 DOM 還在,只是 display: none 被移出 DOM
狀態 保留 保留

簡單說,v-show 是「全部先蓋好,用的時候顯示」;KeepAlive 是「用到才蓋,蓋完收起來備用」。分頁很多、每頁都很重的時候,KeepAlive 比較省。

不是每個都要快取:include、exclude、max

<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>

兩個細節要注意:

  1. 比對的是組件的 name。 好消息是從 Vue 3.2.34 開始,使用 <script setup> 的 SFC 會自動用檔名推斷 name,所以 TabForm.vue 的 name 就是 TabForm,不用另外寫。
  2. 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。


搭配 Vue Router:快取整個頁面

最常見的情境其實是路由頁面。<RouterView> 提供 v-slot 讓你拿到目前要顯示的組件,再包進 KeepAlive:

<RouterView v-slot="{ Component }">
  <KeepAlive include="ProductList">
    <component :is="Component" />
  </KeepAlive>
</RouterView>

回到開頭的商品列表:只快取 ProductList,使用者進詳情頁再返回,篩選條件和捲動位置都還在;詳情頁本身不快取,每次點進去都是乾淨的新頁面。


Transition:只在進出 DOM 的時候動手腳

好,狀態保住了,接下來處理「切換很生硬」。

<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;
}

六個 class 的時間線

這是 Transition 最需要看懂的部分。進場與離場各有三個 class:

階段 進場 離場
起點 v-enter-from v-leave-from
過程中 v-enter-active v-leave-active
終點 v-enter-to v-leave-to

以進場為例:

  1. 元素插入 DOM 之前,先加上 enter-from 和 enter-active
  2. 插入後的下一幀,移除 enter-from、加上 enter-to
  3. 動畫結束後,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;
}

命名:name

預設的 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

官方文件特別提到,範例幾乎都在動 transform 和 opacity,原因是:

  1. 這兩個屬性不會影響頁面排版,動畫過程中不用每一幀重新計算 layout
  2. 瀏覽器通常能用 GPU 加速 transform
    相對的,height、margin 這類屬性會觸發 layout,比較吃效能,要斟酌使用。

兩個元素互相切換:mode

如果是兩個元素用 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 沒反應,先檢查 name
include / 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

上一篇
Vue 走過路過不要錯過 Day27 - Teleport:讓 Modal 不再被 overflow 卡住
下一篇
Vue 走過路過不要錯過 Day29 - 常見踩坑整理:v-if 配 v-for、忘了清除的計時器與監聽
系列文
重新認識Vue 走過路過不要錯過 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言