寫專案寫到一半,常常會有一個瞬間:打開 App.vue,捲軸拉了半天還到不了底。上面是 header,中間有搜尋框和商品列表,旁邊塞了一個計時器,最下面還有一個彈窗。每次要改東西,都要先花時間找「這段在哪裡」。
這時候就會開始想兩件事:
先看一個拆分前的樣子:
<!-- 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?
useXxx 函式至於拆開之後資料怎麼傳,簡單說就是 props 往下傳、emit 往上通知,這裡先不展開。
組件拆開之後,有一個很重要的觀念:每一個組件實例,都有一條獨立的生命週期。
App.vue 裡用了五個子組件,就有五條各自的生命週期,加上 App 自己總共六條。它們各自出生、各自更新、各自消失。
一個組件的一生大致是這樣:
setup(建立)
↓
onBeforeMount → onMounted(掛載到畫面上)
↓
onBeforeUpdate → onUpdated(資料變動、畫面重新渲染,可能發生很多次)
↓
onBeforeUnmount → onUnmounted(從畫面上移除)
這些 onXxx 就是生命週期鉤子(lifecycle hooks),意思是「在某個時間點,讓我插一段程式碼進去執行」。
鉤子很多,但日常開發最常用的其實就三個:setup、onMounted、onUnmounted。下面一個一個看。
在 <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 已經產生,而且放進頁面了。**所以凡是需要碰到 DOM 的事情,都放這裡。
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const inputEl = useTemplateRef('input')
onMounted(() => {
inputEl.value.focus() // 這時候拿得到了
})
</script>
<template>
<input ref="input" />
</template>
適合放在 onMounted 的事情:
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,套件就會報錯。
這是很常被問的問題,兩種寫法都看得到:
// 寫法 A:直接在 setup 裡呼叫
fetchProducts()
// 寫法 B:等掛載完再呼叫
onMounted(() => {
fetchProducts()
})
單純抓資料的話兩種都可以,差別只在時機:寫法 A 比較早發出請求,寫法 B 會等畫面出來之後才發。如果抓資料這件事跟 DOM 無關,放在 setup 裡就夠了。
另外補充一點:如果是用 Nuxt 這類 SSR 框架,onMounted 只會在瀏覽器端執行,伺服器端不會跑,所以那邊抓資料通常會改用框架提供的方法(像 useFetch)。
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,連續按幾次切換按鈕,會看到:
setInterval 是瀏覽器的東西,Vue 不會幫你關掉它。而且計時器的 callback 還抓著 seconds,這些資料也沒辦法被回收,這就是記憶體洩漏(memory leak)。<!-- 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
dispose()、Chart.js 的 destroy()
至於寫在 template 上的 @click 這種事件,Vue 會自己處理,不用手動移除。
onBeforeUnmount 在「準備移除、但 DOM 還在」的時候執行。如果清理的時候還需要讀 DOM,例如要記下使用者捲到哪裡,就放在 onBeforeUnmount;單純關計時器、移除監聽,用 onUnmounted 就好。
回到第一段講的:邏輯要重用,就抽成 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,實務上可以直接用。不過自己寫過一次,會更清楚它們背後在做什麼。
// ❌ 不會執行
setTimeout(() => {
onMounted(() => {
console.log('我不會被印出來')
})
}, 0)
生命週期鉤子在註冊時,需要知道「現在是哪個組件在執行 setup」。放進 setTimeout 或 .then() 這類非同步 callback 裡,Vue 已經不知道它屬於誰了,console 會出現警告,鉤子也不會執行。
最保險的做法:鉤子一律寫在 setup 的最外層,由上往下同步註冊。
v-if 為 false:組件真的被移除,會觸發 onUnmounted;變回 true 是一個全新的組件,會重新跑 setup 和 onMountedv-show 為 false:組件還在,只是被加上 display: none,生命週期不會變化v-if 換成 v-show,計時器會一直跑下去,這通常是符合預期的,因為組件本來就還活著。選哪一個,要看你希望組件「藏起來」還是「消失」。在父子組件裡各自印出 log:
Parent setup
Parent onBeforeMount
Child setup
Child onBeforeMount
Child onMounted
Parent onMounted
子組件的 onMounted 會比父組件先執行。原因是父組件要等底下所有子組件都掛載完成,自己才算完整地掛上去。
所以在父組件的 onMounted 裡,可以放心地存取子組件的 DOM。
如果組件被 <KeepAlive> 包起來,切換時不會被移除,而是被暫存起來,所以 onUnmounted 不會觸發。這種情況要改用 onActivated(重新顯示時)和 onDeactivated(被暫存時)。這裡先知道有這件事就好。
| 鉤子 | 什麼時候執行 | 適合做什麼 | 常見錯誤 |
|---|---|---|---|
| setup | 組件建立時,最先執行 | 宣告資料、computed、watch,發 API 請求 | 在這裡操作 DOM,拿到 null |
| onMounted | DOM 產生並放進頁面後 | 操作 DOM、初始化第三方套件、開計時器與監聽 | 忘記開了的東西要關 |
| onBeforeUnmount | 準備移除,DOM 還在 | 需要讀 DOM 的清理,例如記錄捲動位置 | 和 onUnmounted 分不清楚 |
| onUnmounted | 組件移除後 | 清計時器、移除監聽、銷毀套件實例 | 忘了寫,造成記憶體洩漏 |
回到開頭那個越寫越長的 App.vue:拆分讓每個組件只做一件事,生命週期讓每個組件把自己開的東西自己收好。兩件事都做到,組件才能放心地搬到別的頁面重複使用,不會拖著一堆沒關掉的計時器跟著走。