跳至主要內容
Skip to content

Vue 復用手段選型:Composable、Directive、Plugin、Mixin

Vue 裡有很多「抽出來」的方式:Composable、自訂指令、Plugin、Mixin、Provide/Inject、全域屬性。它們都能復用,但復用的東西不一樣。中階面試很常透過這題看你是否能根據問題選對工具,而不是把所有邏輯都寫成 useSomething

一句話回答:Composable 適合復用狀態邏輯,自訂指令適合復用底層 DOM 行為,Plugin 適合安裝全域能力或框架級能力,Provide/Inject 適合跨層上下文,Mixin 是舊式邏輯混入,因為來源不明、命名衝突與型別體驗差,現在通常優先用 Composable 取代。


一、 必考觀念

1. 先判斷你要復用的是什麼

不要一開始就問「要不要抽 Composable」。

先問:

text
我要復用的是狀態邏輯?
還是 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。

typescript
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,
  }
}

適合:

  • useRequest
  • usePagination
  • useSelection
  • useEventListener
  • useLocalStorage
  • useRouteQuery
  • usePermission

延伸讀:Composition API 實戰與 Composable 設計

2. Composable 可以使用生命週期

typescript
export function useEventListener(
  target: EventTarget,
  event: string,
  handler: EventListener
) {
  onMounted(() => {
    target.addEventListener(event, handler)
  })

  onBeforeUnmount(() => {
    target.removeEventListener(event, handler)
  })
}

這適合封裝和元件生命週期相關的副作用。

3. Composable 的狀態是否共享,取決於寫法

每次呼叫都建立新狀態:

typescript
export function useCounter() {
  const count = ref(0)

  return {
    count,
  }
}

模組頂層狀態會共享:

typescript
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 範例

typescript
const vFocus = {
  mounted(el: HTMLInputElement) {
    el.focus()
  },
}

使用:

vue
<input v-focus />

這種行為很貼近 DOM,適合指令。

3. v-click-outside 範例

typescript
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__
    }
  },
}

使用:

vue
<div v-click-outside="close">
  ...
</div>

這種需求重點是 DOM 節點與事件監聽,不一定需要做成 Composable。

4. Directive 要清理副作用

如果指令註冊了事件、Observer、timer,就要在 unmounted 清掉。

否則容易造成:

  • memory leak。
  • 元素移除後事件仍觸發。
  • 多次掛載造成重複監聽。

四、 Plugin

1. Plugin 適合安裝全域能力

Plugin 通常有 install(app)

typescript
export const ToastPlugin = {
  install(app: App) {
    app.config.globalProperties.$toast = createToast()
  },
}

使用:

typescript
app.use(ToastPlugin)

適合:

  • router。
  • store。
  • i18n。
  • UI library。
  • 全域指令。
  • 全域元件。
  • toast / modal service。
  • app-level provide。

2. Toast Plugin 範例

typescript
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 常見:

typescript
Vue.prototype.$http = http

Vue 3 對應:

typescript
app.config.globalProperties.$http = http

但它有缺點:

  • 型別需要額外擴充。
  • 來源不夠顯式。
  • 測試時要 mock 全域。
  • 容易變成全域依賴。

現在更常見的是 provide/inject 或明確 import。


五、 Mixin

1. Mixin 是什麼?

Mixin 可以把一段 options 混入元件:

typescript
const loadingMixin = {
  data() {
    return {
      loading: false,
    }
  },
  methods: {
    setLoading(value: boolean) {
      this.loading = value
    },
  },
}

使用:

typescript
export default {
  mixins: [loadingMixin],
}

2. Mixin 的問題

Mixin 在大型專案裡常見問題:

  • data / methods 來源不明。
  • 命名衝突。
  • 多個 mixin 合併後難追蹤。
  • TypeScript 推導較差。
  • 隱式依賴太多。
  • 重構風險高。

你在元件裡看到 this.loading,不一定知道它來自 data、mixin、父類還是其他地方。

3. 為什麼 Composable 通常更好?

Composable 是顯式的:

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

typescript
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 層提供上下文:

typescript
app.provide(I18nKey, i18n)

元件內再用:

typescript
const i18n = inject(I18nKey)

所以 Plugin 和 Provide / Inject 可以搭配:Plugin 負責安裝,Provide / Inject 負責依賴注入。


七、 選型速查

1. 什麼時候用 Composable?

當你要復用:

  • 響應式狀態。
  • computed。
  • watch。
  • 生命週期。
  • 請求流程。
  • 可測試的業務邏輯。

例如:

text
useRequest
usePagination
useSelection
useLocalStorage
useRouteQuery

2. 什麼時候用 Directive?

當你要復用:

  • DOM focus。
  • DOM event。
  • DOM measurement。
  • IntersectionObserver。
  • 元素權限顯示。
  • 和特定元素生命週期強相關的行為。

例如:

text
v-focus
v-click-outside
v-intersection
v-permission

3. 什麼時候用 Plugin?

當你要:

  • 全域註冊元件。
  • 全域註冊指令。
  • 安裝 app-level service。
  • 注入全域上下文。
  • 對外提供 library。

例如:

text
router
pinia
i18n
toast
modal service
UI library

4. 什麼時候用 Mixin?

新專案通常少用。

除非:

  • 維護 Vue 2 舊專案。
  • 既有架構大量依賴。
  • 短期重構成本太高。

否則優先考慮 Composable。


八、 追問題庫

Q1:Composable 和 Directive 最大差異?

Composable 復用狀態邏輯,Directive 復用 DOM 行為。

如果邏輯核心是 refcomputedwatch、請求、狀態,通常用 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 引用。

範例:

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

範例:

typescript
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 行為
PluginApp 依賴全域能力
Provide / Inject子樹依賴上層上下文
Mixin元件隱式依賴混入內容

資深工程師要能判斷:這種耦合是不是值得。

2. 全域能力要可替換、可測試

例如 toast、modal、analytics、permission,不要散落在每個元件裡。

比較好的做法:

  • 統一 service 介面。
  • Plugin 安裝。
  • provide/inject 注入。
  • 測試時替換 fake service。
  • 不把 DOM 實作細節洩漏給業務元件。

3. 不要把復用變成框架迷宮

如果一個功能同時用了:

text
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 行為、全域能力還是上下文。

延伸閱讀