Vue 復用手段選型:Composable、Directive、Plugin、Mixin
Vue 裡有很多「抽出來」的方式:Composable、自訂指令、Plugin、Mixin、Provide/Inject、全域屬性。它們都能復用,但復用的東西不一樣。中階面試很常透過這題看你是否能根據問題選對工具,而不是把所有邏輯都寫成 useSomething。
一句話回答:Composable 適合復用狀態邏輯,自訂指令適合復用底層 DOM 行為,Plugin 適合安裝全域能力或框架級能力,Provide/Inject 適合跨層上下文,Mixin 是舊式邏輯混入,因為來源不明、命名衝突與型別體驗差,現在通常優先用 Composable 取代。
一、 必考觀念
1. 先判斷你要復用的是什麼
不要一開始就問「要不要抽 Composable」。
先問:
我要復用的是狀態邏輯?
還是 DOM 行為?
還是全域能力?
還是跨層上下文?
還是元件內容結構?對應關係大概是:
| 需求 | 適合手段 |
|---|---|
| 復用響應式狀態與副作用 | Composable |
| 復用 DOM 行為 | 自訂指令 |
| 安裝全域能力 | Plugin |
| 跨層傳遞上下文 | Provide / Inject |
| 復用 UI 結構 | Component / Slot |
| 舊專案邏輯混入 | Mixin |
2. 復用不是越抽越好
過早抽象會造成:
- 參數很多。
- 回傳值很大。
- 呼叫者要懂內部細節。
- 後續需求變更反而更難改。
- 邏輯被藏起來,除錯更辛苦。
好的復用應該讓業務元件更清楚,而不是把複雜度搬到另一個檔案。
3. 面試回答要能說出取捨
例如 click outside:
- 只是一個 DOM 行為:可以用 directive。
- 需要和狀態、生命週期、元件邏輯綁在一起:可以用 Composable。
- 要全站安裝並統一使用:可以透過 Plugin 註冊 directive。
同一個需求在不同規模下,選型可能不同。
二、 Composable
1. Composable 適合復用狀態邏輯
Composable 通常是以 use 開頭的函式,裡面使用 Vue 響應式 API。
export function useToggle(initialValue = false) {
const value = ref(initialValue)
function toggle() {
value.value = !value.value
}
function setValue(nextValue: boolean) {
value.value = nextValue
}
return {
value,
toggle,
setValue,
}
}適合:
useRequestusePaginationuseSelectionuseEventListeneruseLocalStorageuseRouteQueryusePermission
延伸讀:Composition API 實戰與 Composable 設計。
2. Composable 可以使用生命週期
export function useEventListener(
target: EventTarget,
event: string,
handler: EventListener
) {
onMounted(() => {
target.addEventListener(event, handler)
})
onBeforeUnmount(() => {
target.removeEventListener(event, handler)
})
}這適合封裝和元件生命週期相關的副作用。
3. Composable 的狀態是否共享,取決於寫法
每次呼叫都建立新狀態:
export function useCounter() {
const count = ref(0)
return {
count,
}
}模組頂層狀態會共享:
const count = ref(0)
export function useSharedCounter() {
return {
count,
}
}共享狀態要小心 SSR 跨請求污染。
延伸讀:Vue SSR 的實現原理。
三、 自訂指令
1. 自訂指令適合復用 DOM 行為
Directive 的定位是直接作用在 DOM 元素上。
適合:
- focus。
- click outside。
- intersection observer。
- tooltip positioning。
- permission 顯示控制。
- long press。
- drag。
不適合:
- 管理複雜業務狀態。
- 取代元件。
- 做大量資料流同步。
2. v-focus 範例
const vFocus = {
mounted(el: HTMLInputElement) {
el.focus()
},
}使用:
<input v-focus />這種行為很貼近 DOM,適合指令。
3. v-click-outside 範例
type ClickOutsideElement = HTMLElement & {
__clickOutside__?: (event: MouseEvent) => void
}
export const vClickOutside = {
mounted(el: ClickOutsideElement, binding: { value: () => void }) {
const handler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value()
}
}
el.__clickOutside__ = handler
document.addEventListener('click', handler)
},
unmounted(el: ClickOutsideElement) {
if (el.__clickOutside__) {
document.removeEventListener('click', el.__clickOutside__)
delete el.__clickOutside__
}
},
}使用:
<div v-click-outside="close">
...
</div>這種需求重點是 DOM 節點與事件監聽,不一定需要做成 Composable。
4. Directive 要清理副作用
如果指令註冊了事件、Observer、timer,就要在 unmounted 清掉。
否則容易造成:
- memory leak。
- 元素移除後事件仍觸發。
- 多次掛載造成重複監聽。
四、 Plugin
1. Plugin 適合安裝全域能力
Plugin 通常有 install(app):
export const ToastPlugin = {
install(app: App) {
app.config.globalProperties.$toast = createToast()
},
}使用:
app.use(ToastPlugin)適合:
- router。
- store。
- i18n。
- UI library。
- 全域指令。
- 全域元件。
- toast / modal service。
- app-level provide。
2. Toast Plugin 範例
type Toast = {
success(message: string): void
error(message: string): void
}
const ToastKey = Symbol('toast')
export function createToastPlugin(toast: Toast) {
return {
install(app: App) {
app.provide(ToastKey, toast)
},
}
}
export function useToast() {
const toast = inject<Toast>(ToastKey)
if (!toast) {
throw new Error('Toast plugin is not installed')
}
return toast
}這種寫法比到處 import 單例更容易測試,也更適合 SSR 或多 app 場景。
3. Global Properties 要克制
Vue 2 常見:
Vue.prototype.$http = httpVue 3 對應:
app.config.globalProperties.$http = http但它有缺點:
- 型別需要額外擴充。
- 來源不夠顯式。
- 測試時要 mock 全域。
- 容易變成全域依賴。
現在更常見的是 provide/inject 或明確 import。
五、 Mixin
1. Mixin 是什麼?
Mixin 可以把一段 options 混入元件:
const loadingMixin = {
data() {
return {
loading: false,
}
},
methods: {
setLoading(value: boolean) {
this.loading = value
},
},
}使用:
export default {
mixins: [loadingMixin],
}2. Mixin 的問題
Mixin 在大型專案裡常見問題:
- data / methods 來源不明。
- 命名衝突。
- 多個 mixin 合併後難追蹤。
- TypeScript 推導較差。
- 隱式依賴太多。
- 重構風險高。
你在元件裡看到 this.loading,不一定知道它來自 data、mixin、父類還是其他地方。
3. 為什麼 Composable 通常更好?
Composable 是顯式的:
const {
loading,
setLoading,
} = useLoading()優點:
- 顯式 import。
- 顯式呼叫。
- 顯式回傳。
- 可以重命名。
- TypeScript 推導更自然。
- 更容易測試。
所以新專案通常不會優先使用 Mixin。
4. Mixin 仍可能出現在哪?
你可能會在:
- Vue 2 舊專案。
- class component 舊寫法。
- 舊 UI library。
- 歷史共用邏輯。
面試時不要只說「Mixin 不好」。更成熟的說法是:Mixin 是舊式復用手段,能用,但在 Vue 3 + Composition API 下,大多數邏輯復用會優先用 Composable。
六、 Provide / Inject
1. Provide / Inject 適合跨層上下文
例如 Form 和 FormItem:
const FormKey = Symbol('form')
function provideFormContext(context: FormContext) {
provide(FormKey, context)
}
function useFormContext() {
const context = inject<FormContext>(FormKey)
if (!context) {
throw new Error('Form context is missing')
}
return context
}適合:
- Form。
- Table。
- Tabs。
- Theme。
- i18n。
- Layout context。
延伸讀:Vue 元件通訊方式有哪些及原理。
2. Provide / Inject 不是 Store 替代品
Provide / Inject 的作用範圍是元件樹的某個子樹。
Pinia / store 則是全域或跨路由共享狀態。
如果資料是 app-level business state,例如登入使用者、權限、購物車,通常 store 更適合。
延伸讀:Vue 狀態管理:Pinia、Vuex 與狀態歸屬。
3. Plugin 可以使用 app-level provide
Plugin 可以在 app 層提供上下文:
app.provide(I18nKey, i18n)元件內再用:
const i18n = inject(I18nKey)所以 Plugin 和 Provide / Inject 可以搭配:Plugin 負責安裝,Provide / Inject 負責依賴注入。
七、 選型速查
1. 什麼時候用 Composable?
當你要復用:
- 響應式狀態。
- computed。
- watch。
- 生命週期。
- 請求流程。
- 可測試的業務邏輯。
例如:
useRequest
usePagination
useSelection
useLocalStorage
useRouteQuery2. 什麼時候用 Directive?
當你要復用:
- DOM focus。
- DOM event。
- DOM measurement。
- IntersectionObserver。
- 元素權限顯示。
- 和特定元素生命週期強相關的行為。
例如:
v-focus
v-click-outside
v-intersection
v-permission3. 什麼時候用 Plugin?
當你要:
- 全域註冊元件。
- 全域註冊指令。
- 安裝 app-level service。
- 注入全域上下文。
- 對外提供 library。
例如:
router
pinia
i18n
toast
modal service
UI library4. 什麼時候用 Mixin?
新專案通常少用。
除非:
- 維護 Vue 2 舊專案。
- 既有架構大量依賴。
- 短期重構成本太高。
否則優先考慮 Composable。
八、 追問題庫
Q1:Composable 和 Directive 最大差異?
Composable 復用狀態邏輯,Directive 復用 DOM 行為。
如果邏輯核心是 ref、computed、watch、請求、狀態,通常用 Composable。
如果核心是某個元素的 focus、click outside、observer、DOM 事件,通常用 Directive。
Q2:Plugin 和 Composable 差在哪?
Composable 是一個可呼叫的邏輯函式。
Plugin 是安裝到 Vue app 的能力,通常透過 app.use() 注入全域元件、指令、service 或 provide。
Plugin 可以提供能力,Composable 可以用來消費這個能力。
Q3:為什麼 Mixin 現在比較少用?
因為 Mixin 是隱式混入,容易造成命名衝突、來源不明、型別推導差與重構困難。
Composition API 的 Composable 更顯式,也更容易組合與測試。
Q4:Provide / Inject 和 Pinia 怎麼選?
Provide / Inject 適合元件子樹上下文,例如 Form、Table、Tabs。
Pinia 適合跨頁或遠距共享業務狀態,例如登入使用者、權限、購物車。
Q5:全域屬性能不能用?
可以,但要克制。
全域屬性來源不夠顯式,型別也需要額外擴充。對於可測試和可替換的服務,provide/inject 或明確 import 通常更清楚。
九、 實作題
題目一:設計一個 click-outside 指令
回答重點:
- 指令作用在 DOM 元素上。
- mounted 時註冊 document click。
- 判斷 click target 是否在元素外。
- unmounted 時移除事件。
- 注意保存 handler 引用。
範例:
type ClickOutsideElement = HTMLElement & {
__clickOutside__?: (event: MouseEvent) => void
}
export const vClickOutside = {
mounted(el: ClickOutsideElement, binding: { value: () => void }) {
const handler = (event: MouseEvent) => {
if (!el.contains(event.target as Node)) {
binding.value()
}
}
el.__clickOutside__ = handler
document.addEventListener('click', handler)
},
unmounted(el: ClickOutsideElement) {
if (el.__clickOutside__) {
document.removeEventListener('click', el.__clickOutside__)
delete el.__clickOutside__
}
},
}題目二:設計一個 Toast Plugin
回答重點:
install(app)安裝。- 用
app.provide注入 toast service。 - 提供
useToast()取得 service。 - 沒安裝時要有明確錯誤。
- 測試時可以替換 service。
範例:
type Toast = {
success(message: string): void
error(message: string): void
}
const ToastKey = Symbol('toast')
export function createToastPlugin(toast: Toast) {
return {
install(app: App) {
app.provide(ToastKey, toast)
},
}
}
export function useToast() {
const toast = inject<Toast>(ToastKey)
if (!toast) {
throw new Error('Toast plugin is not installed')
}
return toast
}十、 資深視角
1. 復用手段會影響耦合
不同復用方式造成的耦合不同:
| 手段 | 耦合感 |
|---|---|
| Composable | 呼叫方顯式依賴函式 |
| Directive | 元素依賴 DOM 行為 |
| Plugin | App 依賴全域能力 |
| Provide / Inject | 子樹依賴上層上下文 |
| Mixin | 元件隱式依賴混入內容 |
資深工程師要能判斷:這種耦合是不是值得。
2. 全域能力要可替換、可測試
例如 toast、modal、analytics、permission,不要散落在每個元件裡。
比較好的做法:
- 統一 service 介面。
- Plugin 安裝。
- provide/inject 注入。
- 測試時替換 fake service。
- 不把 DOM 實作細節洩漏給業務元件。
3. 不要把復用變成框架迷宮
如果一個功能同時用了:
Plugin -> provide -> composable -> directive -> globalProperties讀者可能很難追。
好的架構不是把所有工具都用上,而是用最少的工具清楚解決問題。
總結
| 問題 | 回答重點 |
|---|---|
| Composable | 復用響應式狀態、生命週期、副作用與業務邏輯 |
| Directive | 復用 DOM 行為,例如 focus、click outside、observer |
| Plugin | 安裝全域能力,例如 router、store、i18n、toast |
| Mixin | 舊式混入,來源不明與型別體驗差,現在較少推薦 |
| Provide / Inject | 跨層上下文,適合 Form/Table/Tabs 等子樹協作 |
| Global Properties | 可以用,但要克制,注意型別與測試 |
一句完整的面試回答可以是:
Vue 裡不同復用手段解決的問題不同。Composable 適合復用響應式狀態、computed、watch、生命週期和請求流程;Directive 適合復用直接作用在 DOM 元素上的行為,例如 focus、click outside、IntersectionObserver;Plugin 適合透過
app.use()安裝全域能力,例如 router、store、i18n、toast、全域指令或元件;Provide/Inject 適合元件子樹的上下文傳遞,例如 Form 和 FormItem;Mixin 是舊式邏輯混入,因為來源不明、命名衝突和 TypeScript 體驗差,Vue 3 新專案通常優先用 Composable 取代。選型重點是先判斷要復用的是狀態邏輯、DOM 行為、全域能力還是上下文。