跳轉到內容

Vue 3 高級組件設計與渲染函數實戰 2026 | 組件設計模式完全指南

Vue 3 高級組件設計與渲染函數

當模板語法無法滿足複雜需求時,Vue 3 的渲染函數和組合式 API 提供了更強大的組件設計能力。本文將深入渲染函數、高階組件、自定義指令等高級特性,掌握構建靈活可複用組件的設計模式。


一、渲染函數與 h 函數

1.1 基本概念

渲染函數是 Vue 組件的底層實現,h 函數用於創建虛擬 DOM 節點(VNode):

typescript
import { h } from 'vue'

// h(tag, props, children)
// tag: 標籤名 | 組件 | 函數
// props: 屬性對象
// children: 子節點數組 | 字符串 | 插槽函數

const App = defineComponent({
  render() {
    return h('div', { class: 'container' }, [
      h('h1', { class: 'title' }, 'Hello World'),
      h('p', { class: 'desc' }, '渲染函數示例')
    ])
  }
})

1.2 在 setup 中使用渲染函數

typescript
import { h, ref, defineComponent } from 'vue'

export default defineComponent({
  props: {
    level: { type: Number, default: 1 }
  },
  setup(props, { slots }) {
    const tag = `h${props.level}`

    return () => h(tag, { class: 'heading' }, slots.default?.())
  }
})

1.3 動態組件渲染

typescript
import { h, defineComponent, computed } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorView from './ErrorView.vue'
import DataView from './DataView.vue'

export default defineComponent({
  props: {
    state: { type: String, required: true }, // loading | error | success
    data: { type: Object, default: null },
    error: { type: Error, default: null }
  },
  setup(props) {
    const componentMap = {
      loading: LoadingSpinner,
      error: ErrorView,
      success: DataView
    }

    return () => {
      const component = componentMap[props.state as keyof typeof componentMap]
      
      if (props.state === 'success') {
        return h(component, { data: props.data })
      }
      if (props.state === 'error') {
        return h(component, { error: props.error })
      }
      return h(component)
    }
  }
})

1.4 VNode 的結構

typescript
// VNode 對象結構
interface VNode {
  type: string | Component | Function  // 標籤名或組件
  props: Record<string, any> | null     // 屬性/事件
  children: VNode[] | string | null     // 子節點
  key: string | number | null           // Diff 的 key
  el: Node | null                       // 真實 DOM 引用
  ref: Ref | null                       // 模板引用
}

// 手動創建 VNode
const vnode = h('div', {
  class: 'wrapper',
  onClick: () => console.log('clicked'),
  key: 'unique-key'
}, '內容')

二、函數式組件

2.1 函數式組件基礎

Vue 3 中,函數式組件就是一個返回 VNode 的普通函數:

typescript
import { h, FunctionalComponent } from 'vue'

// 類型定義
interface ButtonProps {
  type?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// 函數式組件
const Button: FunctionalComponent<ButtonProps> = (props, { slots, emit }) => {
  return h('button', {
    class: [
      'btn',
      `btn-${props.type || 'primary'}`,
      `btn-${props.size || 'md'}`
    ],
    disabled: props.disabled,
    onClick: () => emit('click')
  }, slots.default?.())
}

// 定義 props
Button.props = ['type', 'size', 'disabled']
Button.emits = ['click']

export default Button

2.2 函數式組件實戰

typescript
// FunctionalIcon.tsx — 輕量級圖標組件
import { h, FunctionalComponent } from 'vue'

interface IconProps {
  name: string
  size?: number
  color?: string
}

const iconPaths: Record<string, string> = {
  home: 'M3 12L12 3l9 9M5 10v10h14V10',
  user: 'M12 12a4 4 0 100-8 4 4 0 000 8zm0 2c-4 0-8 2-8 6v2h16v-2c0-4-4-6-8-6z',
  settings: 'M12 8a4 4 0 100 8 4 4 0 000-8zM19.4 13a7.5 7.5 0 000-2l2-1.5-2-3.5-2.5 1a7.5 7.5 0 00-1.7-1l-.4-2.5H9.2l-.4 2.5a7.5 7.5 0 00-1.7 1l-2.5-1-2 3.5L2.6 11a7.5 7.5 0 000 2l-2 1.5 2 3.5 2.5-1a7.5 7.5 0 001.7 1l.4 2.5h5.6l.4-2.5a7.5 7.5 0 001.7-1l2.5 1 2-3.5-2-1.5z'
}

const FunctionalIcon: FunctionalComponent<IconProps> = (props) => {
  return h('svg', {
    width: props.size || 24,
    height: props.size || 24,
    viewBox: '0 0 24 24',
    fill: 'none',
    stroke: props.color || 'currentColor',
    'stroke-width': 2,
    'stroke-linecap': 'round',
    'stroke-linejoin': 'round'
  }, [
    h('path', { d: iconPaths[props.name] || '' })
  ])
}

FunctionalIcon.props = ['name', 'size', 'color']

export default FunctionalIcon

三、高階組件(HOC)

3.1 高階組件模式

typescript
import { h, defineComponent, ref, onMounted, Component } from 'vue'

// 高階組件:添加數據加載能力
function withFetch<T>(WrappedComponent: Component, url: string) {
  return defineComponent({
    name: 'WithFetch',
    setup() {
      const data = ref<T | null>(null)
      const loading = ref(true)
      const error = ref<Error | null>(null)

      const fetchData = async () => {
        loading.value = true
        error.value = null
        try {
          const response = await fetch(url)
          data.value = await response.json() as T
        } catch (err) {
          error.value = err as Error
        } finally {
          loading.value = false
        }
      }

      onMounted(fetchData)

      return () => h(WrappedComponent, {
        data: data.value,
        loading: loading.value,
        error: error.value,
        onRetry: fetchData
      })
    }
  })
}

// 使用
import UserList from './UserList.vue'
const UserListWithFetch = withFetch<User[]>(
  UserList,
  '/api/users'
)

3.2 高階組件:添加權限控制

typescript
import { h, defineComponent, inject, Component, Ref } from 'vue'

function withAuth(WrappedComponent: Component) {
  return defineComponent({
    name: 'WithAuth',
    setup(props, { attrs, slots }) {
      const user = inject<Ref<{ role: string } | null>>('currentUser')

      return () => {
        if (!user?.value) {
          return h('div', { class: 'unauthorized' }, '請先登錄')
        }

        if (user.value.role !== 'admin') {
          return h('div', { class: 'forbidden' }, '權限不足')
        }

        return h(WrappedComponent, { ...attrs }, slots)
      }
    }
  })
}

// 使用
import AdminPanel from './AdminPanel.vue'
const SecureAdminPanel = withAuth(AdminPanel)

3.3 高階組件:防抖/節流

typescript
import { h, defineComponent, Component } from 'vue'

function withDebounce(WrappedComponent: Component, delay = 300) {
  let timer: NodeJS.Timeout

  return defineComponent({
    name: 'WithDebounce',
    setup(props, { attrs }) {
      const debouncedHandler = (...args: any[]) => {
        clearTimeout(timer)
        timer = setTimeout(() => {
          // 調用原始事件
          ;(attrs.onAction as Function)?.(...args)
        }, delay)
      }

      return () => h(WrappedComponent, {
        ...attrs,
        onAction: debouncedHandler
      })
    }
  })
}

四、Slot 進階技巧

4.1 作用域插槽

vue
<!-- DataTable.vue -->
<script setup lang="ts">
interface Column {
  key: string
  title: string
}

defineProps<{
  columns: Column[]
  data: Record<string, any>[]
}>()
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in columns" :key="col.key">{{ col.title }}</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, index) in data" :key="index">
        <td v-for="col in columns" :key="col.key">
          <!-- 作用域插槽:將行數據傳給父組件 -->
          <slot :name="col.key" :row="row" :index="index">
            {{ row[col.key] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>
vue
<!-- 使用 -->
<DataTable :columns="columns" :data="users">
  <!-- 自定義狀態列渲染 -->
  <template #status="{ row }">
    <span :class="row.status === 'active' ? 'text-green' : 'text-red'">
      {{ row.status === 'active' ? '啟用' : '禁用' }}
    </span>
  </template>

  <!-- 自定義操作列 -->
  <template #actions="{ row, index }">
    <button @click="edit(row)">編輯</button>
    <button @click="remove(index)">刪除</button>
  </template>
</DataTable>

4.2 渲染函數中的插槽

typescript
import { h, defineComponent } from 'vue'

export default defineComponent({
  props: {
    items: { type: Array, required: true }
  },
  setup(props, { slots }) {
    return () => h('ul', { class: 'list' },
      props.items.map((item, index) =>
        h('li', { key: index },
          // 調用作用域插槽
          slots.item?.({ item, index }) ?? h('span', String(item))
        )
      )
    )
  }
})

4.3 動態插槽名

vue
<script setup>
const slots = ref(['header', 'body', 'footer'])
</script>

<template>
  <div>
    <template v-for="name in slots" :key="name">
      <slot :name="name"></slot>
    </template>
  </div>
</template>

五、自定義指令

5.1 指令鉤子函數

typescript
import { Directive } from 'vue'

const myDirective: Directive = {
  // 在綁定元素掛載前調用
  created(el, binding, vnode, prevVnode) {},

  // 在元素被插入到 DOM 前調用
  beforeMount(el, binding) {},

  // 在元素掛載後調用
  mounted(el, binding) {
    console.log('指令綁定', binding.value)
  },

  // 在更新前調用
  beforeUpdate(el, binding, vnode, prevVnode) {},

  // 在更新後調用
  updated(el, binding) {},

  // 在卸載前調用
  beforeUnmount(el, binding) {},

  // 在卸載後調用
  unmounted(el, binding) {}
}

5.2 實用指令實戰

typescript
// directives/permission.ts — 權限控制指令
import { Directive } from 'vue'

export const vPermission: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    const userRole = localStorage.getItem('role') // 實際從 store 獲取

    if (userRole !== binding.value) {
      el.parentNode?.removeChild(el)
    }
  }
}

// 使用:<button v-permission="'admin'">刪除</button>
typescript
// directives/copy.ts — 點擊複製指令
import { Directive } from 'vue'

export const vCopy: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    el.style.cursor = 'pointer'

    el.addEventListener('click', async () => {
      try {
        await navigator.clipboard.writeText(binding.value)

        // 顯示覆製成功提示
        const tooltip = document.createElement('span')
        tooltip.textContent = '已複製'
        tooltip.style.cssText = `
          position: absolute; background: #333; color: #fff;
          padding: 4px 8px; border-radius: 4px; font-size: 12px;
          top: -30px; left: 50%; transform: translateX(-50%);
        `
        el.style.position = 'relative'
        el.appendChild(tooltip)

        setTimeout(() => tooltip.remove(), 1500)
      } catch (err) {
        console.error('複製失敗', err)
      }
    })
  }
}

// 使用:<span v-copy="text">{{ text }}</span>
typescript
// directives/lazy.ts — 圖片懶加載指令
import { Directive } from 'vue'

export const vLazy: Directive<HTMLImageElement, string> = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            el.src = binding.value
            observer.unobserve(el)
          }
        })
      },
      { rootMargin: '50px' }
    )

    observer.observe(el)

    // 保存 observer 用於清理
    el._observer = observer
  },

  unmounted(el) {
    el._observer?.disconnect()
  }
}

// 使用:<img v-lazy="imageUrl" alt="..." />
typescript
// directives/debounce.ts — 防抖指令
import { Directive } from 'vue'

export const vDebounce: Directive<HTMLElement, { handler: () => void; delay: number }> = {
  mounted(el, binding) {
    let timer: NodeJS.Timeout

    el.addEventListener('click', () => {
      clearTimeout(timer)
      timer = setTimeout(() => {
        binding.value.handler()
      }, binding.value.delay || 300)
    })
  }
}

// 使用:<button v-debounce="{ handler: handleClick, delay: 500 }">提交</button>

5.3 註冊指令

typescript
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { vPermission } from './directives/permission'
import { vCopy } from './directives/copy'
import { vLazy } from './directives/lazy'
import { vDebounce } from './directives/debounce'

const app = createApp(App)

app.directive('permission', vPermission)
app.directive('copy', vCopy)
app.directive('lazy', vLazy)
app.directive('debounce', vDebounce)

app.mount('#app')

六、Provide / Inject 依賴注入

6.1 基礎用法

typescript
// 父組件
import { provide, ref, InjectionKey } from 'vue'

// 定義 InjectionKey(類型安全)
export const ThemeKey: InjectionKey<{ 
  theme: Ref<string>
  toggleTheme: () => void 
}> = Symbol('theme')

// 祖先組件
const theme = ref('light')
const toggleTheme = () => {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide(ThemeKey, { theme, toggleTheme })
typescript
// 後代組件(任意深度)
import { inject } from 'vue'
import { ThemeKey } from './parent'

const { theme, toggleTheme } = inject(ThemeKey)!

// 使用
console.log(theme.value) // 'light'
toggleTheme()

6.2 默認值與工廠模式

typescript
// 提供默認值
const config = inject('config', { apiBase: '/api', timeout: 5000 })

// 工廠函數作為默認值
const user = inject('user', () => ({ name: 'Guest' }), true) // true 表示工廠函數

6.3 創建可組合的 Provider

typescript
// composables/useDialog.ts
import { provide, inject, ref, readonly, InjectionKey } from 'vue'

interface DialogContext {
  visible: Readonly<Ref<boolean>>
  title: Readonly<Ref<string>>
  open: (title?: string) => void
  close: () => void
}

const DialogKey: InjectionKey<DialogContext> = Symbol('dialog')

export function provideDialog() {
  const visible = ref(false)
  const title = ref('')

  const open = (t = '') => {
    title.value = t
    visible.value = true
  }

  const close = () => {
    visible.value = false
  }

  const context: DialogContext = {
    visible: readonly(visible),
    title: readonly(title),
    open,
    close
  }

  provide(DialogKey, context)
  return context
}

export function useDialog() {
  const context = inject(DialogKey)
  if (!context) {
    throw new Error('useDialog 必須在 provideDialog 的作用域內使用')
  }
  return context
}

七、組件設計模式

7.1 Render Props 模式

typescript
import { h, defineComponent, PropType } from 'vue'

export default defineComponent({
  props: {
    render: { type: Function as PropType<(data: any) => any>, required: true }
  },
  setup(props) {
    const data = { items: [1, 2, 3], total: 3 }

    return () => h('div', { class: 'container' }, props.render(data))
  }
})

// 使用
// <RenderContainer :render="data => h('ul', data.items.map(i => h('li', String(i))))" />

7.2 無渲染組件

typescript
// 無渲染組件:只負責邏輯,不負責渲染
import { defineComponent, ref, computed, PropType } from 'vue'

export default defineComponent({
  name: 'UseCounter',
  props: {
    initial: { type: Number, default: 0 },
    step: { type: Number, default: 1 }
  },
  setup(props, { slots }) {
    const count = ref(props.initial)
    const doubled = computed(() => count.value * 2)

    const increment = () => { count.value += props.step }
    const decrement = () => { count.value -= props.step }
    const reset = () => { count.value = props.initial }

    return () => slots.default?.({
      count: count.value,
      doubled: doubled.value,
      increment,
      decrement,
      reset
    })
  }
})

// 使用
// <UseCounter :initial="10" v-slot="{ count, increment }">
//   <button @click="increment">Count: {{ count }}</button>
// </UseCounter>

7.3 組合模式

typescript
// Tab 組件系統
// TabContainer.vue
import { provide, ref, reactive, defineComponent } from 'vue'

export const TabContextKey = Symbol('tab-context')

export default defineComponent({
  name: 'TabContainer',
  setup(props, { slots }) {
    const activeTab = ref('')
    const tabs = reactive<string[]>([])

    const registerTab = (id: string) => {
      if (!tabs.includes(id)) {
        tabs.push(id)
        if (!activeTab.value) activeTab.value = id
      }
    }

    const setActiveTab = (id: string) => {
      activeTab.value = id
    }

    provide(TabContextKey, { activeTab, registerTab, setActiveTab })

    return () => h('div', { class: 'tabs' }, slots.default?.())
  }
})

八、總結

  • ✅ 掌握渲染函數與 h 函數的用法
  • ✅ 實現函數式組件(輕量、無狀態)
  • ✅ 高階組件(HOC)模式(數據加載、權限控制、防抖)
  • ✅ Slot 進階(作用域插槽、渲染函數中的插槽、動態插槽名)
  • ✅ 自定義指令(權限、複製、懶加載、防抖)
  • ✅ Provide/Inject 依賴注入(類型安全、可組合 Provider)
  • ✅ 組件設計模式(Render Props、無渲染組件、組合模式)

高級組件設計是 Vue 3 的精華所在,掌握這些模式讓你能夠構建出靈活、可複用、可維護的組件庫。


相關閱讀:

最後更新於: