seansie's blog

解構前端 Composable 架構:告別邏輯泥淖,打造高內聚的現代 UI 系統

在前端架構演進的歷史中,「如何優雅地複用邏輯」始終是核心命題。早年我們用過 Mixins,後來轉向高階組件(HOC)與 Render Props,但這幾種模式在規模化後往往演變成維護惡夢。

現代前端框架(Vue Composition API、React Hooks、SolidJS Primitives、Svelte Runes)逐步收斂出一個共通典範——Composable(可組合項)


為什麼需要 Composable?痛點與演進

在 Options API 或傳統 Class 組件時期,組件通常按「語法類型」劃分區塊(datamethodslifecycle hooks)。這種模式在小型功能尚可運作,但當業務邏輯膨脹時,會帶來三個致命痛點:

  1. 關注點碎片化(Fragmented Concerns):實現一個「即時資料輪詢」功能,狀態要寫在 data、啟動要寫在 mounted、清理要寫在 unmounted、觸發要寫在 methods。單一功能的代碼被硬生生拆散在數百行代碼的各個角落。
  2. Mixins 的隱含衝突:Mixins 曾是邏輯複用的主力,但它帶來嚴重的「命名空間碰撞」與「黑盒依賴」——你很難一眼看出某個 this.userData 究竟來自組件本身還是五個 Mixin 中的哪一個。
  3. 組件樹膨脹(Wrapper Hell):HOC 和 Render Props 雖然解決了命名衝突,但過度嵌套組件會增加渲染層級與除錯負擔。

Composable 的核心精神是:將有狀態的業務邏輯(Stateful Logic)封裝為獨立函式,以純粹的參數輸入與值輸出進行組裝。


實戰對比:Before vs. After

以常見的「視窗尺寸監聽(Window Resize)」為例。

Before:未抽離邏輯的傳統組件(以 Vue 2 / Options API 為例)

視窗監聽邏輯與組件的其他商業邏輯混雜,無法直接被其他組件複用:

export default {
  data() {
    return {
      // 視窗狀態
      windowWidth: window.innerWidth,
      windowHeight: window.innerHeight,
      // 其他業務狀態混雜在此
      userList: [],
      isLoading: false
    }
  },
  mounted() {
    // 監聽邏輯散落在生命週期
    window.addEventListener('resize', this.onResize)
    this.fetchUsers()
  },
  beforeDestroy() {
    // 清理邏輯散落在另一處
    window.removeEventListener('resize', this.onResize)
  },
  methods: {
    onResize() {
      this.windowWidth = window.innerWidth
      this.windowHeight = window.innerHeight
    },
    fetchUsers() { /* ... */ }
  }
}

After:封裝為 Composable(以 Vue 3 Composition API 為例)

將狀態、事件監聽與銷毀清理完整封裝為 useWindowSize

// composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  const update = () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(() => window.addEventListener('resize', update))
  onUnmounted(() => window.removeEventListener('resize', update))

  return { width, height }
}

在組件中只需一行呼叫,來源與型別完全透明:

<!-- components/ResponsiveDashboard.vue -->
<script setup>
import { useWindowSize } from '@/composables/useWindowSize'

// 邏輯即插即用,毫無侵入性
const { width, height } = useWindowSize()
</script>

<template>
  <div>視窗解析度{{ width }} x {{ height }}</div>
</template>

各主流框架的實現方式

雖然語法與響應式底層機制有所不同,但「函式化封裝狀態邏輯」的理念完全相通:

1. Vue 3 (Composition API)

  • 核心機制:基於 Proxy 的依賴追蹤(refreactive)。
  • 特性setup() 只執行一次建立響應依賴,後續狀態變更不重新執行整段函式,心智模型直觀。
// useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const doubleCount = computed(() => count.value * 2)
  const increment = () => count.value++

  return { count, doubleCount, increment }
}

2. React (Custom Hooks)

  • 核心機制:基於 Fiber 渲染週期的 Hook 機制(useStateuseEffect)。
  • 特性:狀態更新會觸發組件重新渲染,需注意 useCallback / useMemo 與 Hook 調用順序限制(不可在條件式內調用)。
// useCounter.js
import { useState, useMemo, useCallback } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  const doubleCount = useMemo(() => count * 2, [count])
  const increment = useCallback(() => setCount(c => c + 1), [])

  return { count, doubleCount, increment }
}

3. SolidJS (Primitives)

  • 核心機制:細粒度響應系統(Signals)。
  • 特性:組件函式本身只執行一次建立運算圖,更新時精確驅動 DOM 節點,無 Virtual DOM 開銷。
// createCounter.js
import { createSignal, createMemo } from 'solid-js'

export function createCounter(initialValue = 0) {
  const [count, setCount] = createSignal(initialValue)
  const doubleCount = createMemo(() => count() * 2)
  const increment = () => setCount(c => c + 1)

  return { count, doubleCount, increment }
}

4. Svelte 5 (Runes)

  • 核心機制:編譯器驅動的訊號系統($state$derived)。
  • 特性:直接使用原生 JS 語法賦值,不再受限於 .svelte 單檔案組件內,在標準 .js/.ts 模組中即可定義通用響應邏輯。
// counter.svelte.js
export function createCounter(initialValue = 0) {
  let count = $state(initialValue)
  let doubleCount = $derived(count * 2)

  function increment() {
    count += 1
  }

  return {
    get count() { return count },
    get doubleCount() { return doubleCount },
    increment
  }
}

架構設計原則

要在大型專案中寫好 Composable,建議遵循以下實務原則:

  1. 命名明確:遵循 useXxx(Vue/React)或 createXxx(Solid)的約定,清楚表達功能意圖。
  2. 單一職責:一個 Composable 只專注解決一個問題(如 useAuthusePaginationuseClipboard)。
  3. 靈活組合(Composition):高階 Composable 應由基礎 Composable 拼裝而成。例如 useUserTable 可內部調用 useFetchusePagination
  4. 自動清理副作用:內部涉及事件監聽、計時器或 WebSocket 時,務必在內部生命週期或清理函式中完成釋放,避免調用方產生記憶體洩漏。