iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

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

Vue 走過路過不要錯過 Day15 - 組件拆分與生命週期:setup、onMounted、onUnmounted

  • 分享至 

  • xImage
  •  

寫專案寫到一半,常常會有一個瞬間:打開 App.vue,捲軸拉了半天還到不了底。上面是 header,中間有搜尋框和商品列表,旁邊塞了一個計時器,最下面還有一個彈窗。每次要改東西,都要先花時間找「這段在哪裡」。

這時候就會開始想兩件事:

  1. 這個檔案該怎麼拆?
  2. 拆出去之後,每一塊「什麼時候出生、什麼時候消失」?
    今天就把這兩件事放在一起聊,因為它們其實是同一件事的兩面:組件拆得越多,越需要清楚每個組件自己的一生。

一、組件拆分:拆的是「職責」,不是「行數」

先看一個拆分前的樣子:

<!-- App.vue:什麼都有 -->
<template>
  <header>...</header>
 
  <input v-model="keyword" placeholder="搜尋商品" />
 
  <ul>
    <li v-for="item in filteredList" :key="item.id">
      {{ item.name }} - {{ item.price }}
    </li>
  </ul>
 
  <div class="timer">已經專注 {{ seconds }} 秒</div>
 
  <div class="modal">...</div>
</template>

拆分之後:

<!-- App.vue:只負責組裝 -->
<template>
  <AppHeader />
  <SearchBar v-model="keyword" />
  <ProductList :items="filteredList" />
  <FocusTimer />
  <ProductModal />
</template>

光看 template 就知道這個頁面由哪幾塊組成,這就是拆分最直接的好處。

為什麼要拆?

  • 重複使用:SearchBar 在商品頁用得到,在訂單頁可能也用得到
  • 單一職責:每個組件只做一件事,出問題時知道要去哪裡找
  • 好讀好維護:一個檔案幾十行,比一個檔案幾百行好理解太多

什麼時候該拆?

我自己的判斷方式是:這一塊能不能用一句話說清楚它在做什麼?

  • 「顯示商品列表」→ 可以,這是一個組件
  • 「顯示商品列表,然後可以搜尋,然後計時,然後彈窗」→ 說不清楚,代表還能拆
    不是寫超過一百行就一定要拆,而是當一個組件開始「身兼多職」時,就是該拆的訊號。

組件和 composable 的差別

拆分的時候常會卡在一個問題:這段要拆成組件,還是抽成 composable?

  • 要重用的是畫面(有 template)→ 拆成組件
  • 要重用的是邏輯(沒有 template,只有資料和行為)→ 抽成 composable,也就是 useXxx 函式
    這個差別等等講到生命週期時會再回來用到。

至於拆開之後資料怎麼傳,簡單說就是 props 往下傳、emit 往上通知,這裡先不展開。


二、每個組件實例都有自己的一生

組件拆開之後,有一個很重要的觀念:每一個組件實例,都有一條獨立的生命週期。

App.vue 裡用了五個子組件,就有五條各自的生命週期,加上 App 自己總共六條。它們各自出生、各自更新、各自消失。

一個組件的一生大致是這樣:

setup(建立)
  ↓
onBeforeMount → onMounted(掛載到畫面上)
  ↓
onBeforeUpdate → onUpdated(資料變動、畫面重新渲染,可能發生很多次)
  ↓
onBeforeUnmount → onUnmounted(從畫面上移除)

這些 onXxx 就是生命週期鉤子(lifecycle hooks),意思是「在某個時間點,讓我插一段程式碼進去執行」。

鉤子很多,但日常開發最常用的其實就三個:setup、onMounted、onUnmounted。下面一個一個看。


三、setup:組件的出生證明

在 <script setup> 裡寫的程式碼,本身就是在 setup 階段執行的。組件被建立時,這段程式碼會從上到下跑一次。

<script setup>
import { ref, useTemplateRef } from 'vue'
 
console.log('setup 執行了')
 
const count = ref(0)
const inputEl = useTemplateRef('input')
 
console.log(inputEl.value) // null
</script>
 
<template>
  <input ref="input" />
</template>

為什麼 inputEl.value 是 null?因為 setup 執行的時候,Vue 還在「準備資料」,template 還沒被渲染成真正的 DOM。這也接回 Day 14 講模板 ref 時提到的:DOM 要等掛載之後才拿得到。

如果寫過 Vue 2,會記得有 beforeCreate 和 created 兩個鉤子。在 Vue 3 的 Composition API 裡不需要它們了,原本寫在那裡的東西,直接寫在 setup 裡就好。

Vue 2(Options API) Vue 3(Composition API)
beforeCreate / created 直接寫在 setup 裡
mounted onMounted
beforeDestroy onBeforeUnmount
destroyed onUnmounted

四、onMounted:DOM 準備好了

onMounted 的時機是:**組件的 DOM 已經產生,而且放進頁面了。**所以凡是需要碰到 DOM 的事情,都放這裡。

<script setup>
import { useTemplateRef, onMounted } from 'vue'
 
const inputEl = useTemplateRef('input')
 
onMounted(() => {
  inputEl.value.focus() // 這時候拿得到了
})
</script>
 
<template>
  <input ref="input" />
</template>

適合放在 onMounted 的事情:

  • 操作 DOM:focus、捲動、量元素寬高
  • 初始化需要 DOM 的第三方套件:Bootstrap Modal、Chart.js、Swiper 這類
    延續 Day 14 的 Bootstrap Modal 例子,new Modal() 需要傳入一個真實的 DOM 元素,所以一定要等到 onMounted:
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import { Modal } from 'bootstrap'
 
const modalEl = useTemplateRef('modal')
let modal = null
 
onMounted(() => {
  modal = new Modal(modalEl.value)
})
</script>
 
<template>
  <div ref="modal" class="modal">...</div>
</template>

如果把 new Modal() 寫在 setup 最外層,拿到的會是 null,套件就會報錯。

打 API 要放 setup 還是 onMounted?

這是很常被問的問題,兩種寫法都看得到:

// 寫法 A:直接在 setup 裡呼叫
fetchProducts()
 
// 寫法 B:等掛載完再呼叫
onMounted(() => {
  fetchProducts()
})

單純抓資料的話兩種都可以,差別只在時機:寫法 A 比較早發出請求,寫法 B 會等畫面出來之後才發。如果抓資料這件事跟 DOM 無關,放在 setup 裡就夠了。

另外補充一點:如果是用 Nuxt 這類 SSR 框架,onMounted 只會在瀏覽器端執行,伺服器端不會跑,所以那邊抓資料通常會改用框架提供的方法(像 useFetch)。


五、onUnmounted:記得收拾善後

onUnmounted 的時機是:組件從畫面上被移除之後。

它最重要的用途只有一個:清理。這個鉤子最容易被忽略,因為忘了寫,畫面看起來也完全正常。直接看例子。

一個忘記清理的計時器

<!-- FocusTimer.vue -->
<script setup>
import { ref } from 'vue'
 
const seconds = ref(0)
 
setInterval(() => {
  seconds.value++
  console.log('計時中', seconds.value)
}, 1000)
</script>
 
<template>
  <p>已經專注 {{ seconds }} 秒</p>
</template>

在父組件用 v-if 控制它的顯示:

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import FocusTimer from './FocusTimer.vue'
 
const showTimer = ref(true)
</script>
 
<template>
  <button @click="showTimer = !showTimer">切換計時器</button>
  <FocusTimer v-if="showTimer" />
</template>

打開 console,連續按幾次切換按鈕,會看到:

  • 計時器已經從畫面上消失了,console 還在一直印「計時中」
  • 每按一次顯示,就多一組新的計時在跑,log 越印越快
    原因是:組件被移除了,但 setInterval 是瀏覽器的東西,Vue 不會幫你關掉它。而且計時器的 callback 還抓著 seconds,這些資料也沒辦法被回收,這就是記憶體洩漏(memory leak)。

加上 onUnmounted

<!-- FocusTimer.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
 
const seconds = ref(0)
let timerId = null
 
onMounted(() => {
  timerId = setInterval(() => {
    seconds.value++
    console.log('計時中', seconds.value)
  }, 1000)
})
 
onUnmounted(() => {
  clearInterval(timerId)
})
</script>

再切換一次,這次組件一消失,console 就安靜了。

這裡我把 setInterval 也移到了 onMounted 裡,養成一個習慣:**在 onMounted 開始的東西,就在 onUnmounted 結束。**兩個鉤子成對出現,比較不容易漏。

常見需要清理的東西

  • 計時器:setInterval、setTimeout
  • 綁在組件外面的事件監聽:window、document 上的 addEventListener
  • 觀察器:IntersectionObserver、ResizeObserver
  • 第三方套件的實例:例如 Bootstrap Modal 的 dispose()、Chart.js 的 destroy()
  • WebSocket 連線
    判斷方式很簡單:**這個東西是不是 Vue 以外的人在管?**如果是,Vue 就不會幫你收,要自己收。

至於寫在 template 上的 @click 這種事件,Vue 會自己處理,不用手動移除。

onBeforeUnmount 什麼時候用?

onBeforeUnmount 在「準備移除、但 DOM 還在」的時候執行。如果清理的時候還需要讀 DOM,例如要記下使用者捲到哪裡,就放在 onBeforeUnmount;單純關計時器、移除監聽,用 onUnmounted 就好。


六、把出生到死亡打包成 composable

回到第一段講的:邏輯要重用,就抽成 composable。

「在 onMounted 綁定、在 onUnmounted 解除」這件事每次都要寫一次,很適合包起來:

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'
 
export function useEventListener(target, event, handler) {
  onMounted(() => {
    target.addEventListener(event, handler)
  })
 
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

有了它,要監聽視窗寬度就變得很乾淨:

// composables/useWindowWidth.js
import { ref } from 'vue'
import { useEventListener } from './useEventListener'
 
export function useWindowWidth() {
  const width = ref(window.innerWidth)
 
  useEventListener(window, 'resize', () => {
    width.value = window.innerWidth
  })
 
  return { width }
}
<!-- 任何組件裡 -->
<script setup>
import { useWindowWidth } from './composables/useWindowWidth'
 
const { width } = useWindowWidth()
</script>
 
<template>
  <p>目前視窗寬度:{{ width }}px</p>
</template>

這裡有一個很關鍵的觀念:composable 裡的生命週期鉤子,會綁在「呼叫它的那個組件」身上。

A 組件呼叫 useWindowWidth(),監聽就跟著 A 的一生走,A 被移除時監聽就解除;B 組件也呼叫的話,B 會有自己一份,互不影響。使用的人完全不用記得要清理,因為清理已經寫在 composable 裡了。

Day 14 的 useModal 也可以用同樣的思路補上清理:

// composables/useModal.js
import { useTemplateRef, onMounted, onUnmounted } from 'vue'
import { Modal } from 'bootstrap'
 
export function useModal(refName) {
  const modalEl = useTemplateRef(refName)
  let modal = null
 
  onMounted(() => {
    modal = new Modal(modalEl.value)
  })
 
  onUnmounted(() => {
    modal?.dispose()
  })
 
  const open = () => modal?.show()
  const close = () => modal?.hide()
 
  return { open, close }
}

順帶一提,VueUse 已經把很多這類 composable 寫好了,像 useEventListener、useWindowSize、useIntervalFn,實務上可以直接用。不過自己寫過一次,會更清楚它們背後在做什麼。


七、容易踩到的坑

1. 鉤子要在 setup 裡同步註冊

// ❌ 不會執行
setTimeout(() => {
  onMounted(() => {
    console.log('我不會被印出來')
  })
}, 0)

生命週期鉤子在註冊時,需要知道「現在是哪個組件在執行 setup」。放進 setTimeout 或 .then() 這類非同步 callback 裡,Vue 已經不知道它屬於誰了,console 會出現警告,鉤子也不會執行。

最保險的做法:鉤子一律寫在 setup 的最外層,由上往下同步註冊。

2. v-if 會觸發掛載和卸載,v-show 不會

  • v-if 為 false:組件真的被移除,會觸發 onUnmounted;變回 true 是一個全新的組件,會重新跑 setup 和 onMounted
  • v-show 為 false:組件還在,只是被加上 display: none,生命週期不會變化
    所以前面計時器的例子,如果把 v-if 換成 v-show,計時器會一直跑下去,這通常是符合預期的,因為組件本來就還活著。選哪一個,要看你希望組件「藏起來」還是「消失」。

3. 父子組件的掛載順序

在父子組件裡各自印出 log:

Parent setup
Parent onBeforeMount
Child setup
Child onBeforeMount
Child onMounted
Parent onMounted

子組件的 onMounted 會比父組件先執行。原因是父組件要等底下所有子組件都掛載完成,自己才算完整地掛上去。

所以在父組件的 onMounted 裡,可以放心地存取子組件的 DOM。

4. KeepAlive 裡的組件不會 unmount

如果組件被 <KeepAlive> 包起來,切換時不會被移除,而是被暫存起來,所以 onUnmounted 不會觸發。這種情況要改用 onActivated(重新顯示時)和 onDeactivated(被暫存時)。這裡先知道有這件事就好。


八、整理

鉤子 什麼時候執行 適合做什麼 常見錯誤
setup 組件建立時,最先執行 宣告資料、computed、watch,發 API 請求 在這裡操作 DOM,拿到 null
onMounted DOM 產生並放進頁面後 操作 DOM、初始化第三方套件、開計時器與監聽 忘記開了的東西要關
onBeforeUnmount 準備移除,DOM 還在 需要讀 DOM 的清理,例如記錄捲動位置 和 onUnmounted 分不清楚
onUnmounted 組件移除後 清計時器、移除監聽、銷毀套件實例 忘了寫,造成記憶體洩漏

回到開頭那個越寫越長的 App.vue:拆分讓每個組件只做一件事,生命週期讓每個組件把自己開的東西自己收好。兩件事都做到,組件才能放心地搬到別的頁面重複使用,不會拖著一堆沒關掉的計時器跟著走。



上一篇
Vue 走過路過不要錯過 Day14 -想直接操作 DOM 的時候:模板 ref 與 useTemplateRef
下一篇
Vue 走過路過不要錯過 Day16 - props:defineProps 與單向資料流的規矩
系列文
重新認識Vue 走過路過不要錯過 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言