Files
Pulse ae39e45460 feat: biblioteca inteligente libs/ + 5 novas skills (20 skills total)
NOVAS SKILLS:
- next-best-practices      v0.1.0  (CLEAN) — Next.js App Router, RSC, caching, data
- nextjs-patterns          v1.0.0  (CLEAN) — Next.js 15: Server Actions, route handlers
- vite                     v1.0.0  (CLEAN) — env vars, aliases, proxy, CJS compat
- uncle-bob                v1.0.0  (CLEAN) — Clean Code, SOLID, Clean Architecture
- clean-code-review        v1.0.0  (CLEAN) — naming, guard clauses, anti-patterns, refactoring
- vue                      v1.0.0  (CLEAN) — Vue framework
- vue-composition-api-best-practices v1.0.0 (CLEAN) — composables, Pinia, reactivity

BIBLIOTECA INTELIGENTE libs/ (10 dominios, 11 arquivos):
- typescript/ — TS safe + generics gotchas
- react/ — Next.js App Router + Vite config
- vue/ — Composition API + Pinia
- linux/ — System diagnostic cheatsheet
- database/ — PostgreSQL + MySQL patterns
- browser/ — Chromium CLI + E2E testing
- security/ — SAST audit (OWASP Top 10)
- best-practices/ — Clean Code + SOLID + Clean Architecture
- deploy/ — Docker multi-stack + OpenClaw ops
- + INDEX.md como guia de navegacao

.learnings/ — LRN-20260519-003 criado (biblioteca compartilhada)
2026-05-19 21:03:25 -03:00

18 KiB
Raw Permalink Blame History

组合式函数设计模式

基于 Vue 3 Composition API 的组合式函数(composable)设计模式总结。


1. 目录结构规范

src/hooks/
├── web/                    # 业务相关 hooks
│   ├── useDesign.ts        # 命名空间/样式前缀
│   ├── useEmitt.ts         # 事件总线
│   ├── useEngine.ts        # 搜索引擎
│   ├── useI18n.ts          # 国际化
│   ├── useLocalForage.ts   # IndexedDB 存储
│   ├── useLocale.ts        # 语言切换
│   ├── useNetwork.ts       # 网络状态
│   ├── usePageIcon.ts      # 页面图标
│   ├── useSideCategory.ts  # 侧边栏分类
│   ├── useSuggestion.ts    # 搜索建议
│   └── useTimeAgo.ts       # 时间格式化
└── event/                  # DOM 事件相关 hooks
    └── useScrollTo.ts      # 滚动定位

规则

  • 按功能域划分子目录(web/event/
  • 每个文件一个 composable,文件名即函数名
  • 函数名以 use 开头

2. 五种设计模式

模式 1:有状态服务

封装独立的响应式状态和操作逻辑。

// hooks/web/useNetwork.ts
import { ref } from 'vue'

export function useNetwork() {
  const isOnline = ref(navigator.onLine)

  const updateOnline = () => (isOnline.value = navigator.onLine)

  window.addEventListener('online', updateOnline)
  window.addEventListener('offline', updateOnline)

  // 注意:此处未自动清理,因为网络状态是全局性的
  // 如果需要组件级清理,参考 Pattern 3

  return { isOnline }
}

特征:维护全局状态,通常不需要组件级清理。

模式 2Store 桥接

用 composable 封装 store 访问,隐藏 store 内部实现细节。

// hooks/web/usePageIcon.ts
import { computed } from 'vue'
import { useBusinessStoreWithOut } from '@/store/modules/business'

export function usePageIcon() {
  const businessStore = useBusinessStoreWithOut()

  const pageIcon = computed(() => businessStore.getPageIcon)

  function addPageIcon(icon: IconItem) {
    businessStore.addPageIcon(icon)
  }

  function removePageIcon(id: string) {
    businessStore.removePageIcon(id)
  }

  return { pageIcon, addPageIcon, removePageIcon }
}

特征

  • 使用 useXxxStoreWithOut 访问 store(因为 hook 可能在组件外使用)
  • 对外暴露语义化接口,隐藏 store action 细节
  • 不维护自身状态,仅转发 store 数据

何时使用:当多个组件需要以相同方式访问同一 store 数据时。

模式 3:生命周期感知

自动在组件卸载时清理副作用。

// hooks/web/useEmitt.ts
import { onUnmounted } from 'vue'
import { mittBus } from '@/utils/mitt'

export function useEmitt() {
  const listeners: Array<{ event: string; handler: (...args: any[]) => void }> = []

  function on(event: string, handler: (...args: any[]) => void) {
    mittBus.on(event, handler)
    listeners.push({ event, handler })
  }

  function emit(event: string, ...args: any[]) {
    mittBus.emit(event, ...args)
  }

  // 组件卸载时自动解绑所有通过此 hook 注册的事件
  onUnmounted(() => {
    listeners.forEach(({ event, handler }) => {
      mittBus.off(event, handler)
    })
    listeners.length = 0
  })

  return { on, emit }
}

特征

  • 使用 onUnmounted 自动清理
  • 内部维护清理队列
  • 防止内存泄漏

何时使用:涉及事件监听、定时器、DOM 事件等需要清理的副作用。

另见:跨功能依赖 - 模式 4:事件总线模式 了解此模式在跨组件通信中的实际应用。

模式 4:异步资源

封装异步资源加载,提供加载状态。

// hooks/web/useLocalForage.ts
import { ref } from 'vue'
import localforage from 'localforage'

export function useLocalForage(storeName: string) {
  const store = localforage.createInstance({ name: storeName })
  const loading = ref(false)

  async function getItem<T>(key: string): Promise<T | null> {
    loading.value = true
    try {
      return await store.getItem<T>(key)
    } finally {
      loading.value = false
    }
  }

  async function setItem<T>(key: string, value: T): Promise<void> {
    loading.value = true
    try {
      await store.setItem(key, value)
    } finally {
      loading.value = false
    }
  }

  return { loading, getItem, setItem }
}

特征

  • 提供 loading 状态
  • try/finally 保证状态重置
  • 支持泛型返回值

何时使用:封装 IndexedDB、fetch、文件读取等异步操作。

模式 5:参数化工具

接收参数,返回计算结果或操作函数,不维护持久状态。

// hooks/web/useDesign.ts
import { useAppStoreWithOut } from '@/store/modules/app'

export function useDesign(scope: string) {
  const appStore = useAppStoreWithOut()

  const prefixCls = computed(() => `${appStore.getPrefixCls}-${scope}`)
  const variables = computed(() => ({
    '--prefix-cls': prefixCls.value,
  }))

  return { prefixCls, variables }
}
// hooks/event/useScrollTo.ts
export function useScrollTo() {
  function scrollTo(target: HTMLElement, options?: ScrollToOptions) {
    target.scrollIntoView({
      behavior: 'smooth',
      block: 'start',
      ...options,
    })
  }

  return { scrollTo }
}

特征

  • 纯函数式,无副作用
  • 参数决定返回值
  • 不需要清理

何时使用:工具类逻辑,如样式计算、DOM 操作、格式化函数。


3. 参数设计原则

单一职责参数

// ✅ GOOD: 每个参数职责明确
export function useLocalForage(storeName: string) { ... }

// ✅ GOOD: 可选参数用 Options 模式
export function useSuggestion(engine: string, options?: SuggestionOptions) { ... }

Options 模式

当参数超过 2 个时,使用 Options 对象:

interface SuggestionOptions {
  timeout?: number
  maxResults?: number
  callbackName?: string
}

export function useSuggestion(engine: string, options?: SuggestionOptions) {
  const { timeout = 5000, maxResults = 10, callbackName } = options ?? {}
  // ...
}

4. 返回值设计原则

最小暴露原则

只返回外部真正需要的:

// ✅ GOOD: 只暴露必要的接口
export function usePageIcon() {
  const businessStore = useBusinessStoreWithOut()
  const pageIcon = computed(() => businessStore.getPageIcon)

  function addPageIcon(icon: IconItem) { businessStore.addPageIcon(icon) }
  function removePageIcon(id: string) { businessStore.removePageIcon(id) }

  return { pageIcon, addPageIcon, removePageIcon }
}

// ❌ BAD: 暴露了整个 store
export function usePageIcon() {
  const businessStore = useBusinessStoreWithOut()
  return { businessStore } // 调用方可随意修改 store
}

Ref vs Computed

返回类型 使用场景 特征
computed 派生状态,依赖其他响应式源 只读,自动更新,有缓存
ref 独立状态 可读写
readonly(ref) 只读状态,内部可修改 防止外部篡改
export function useSideCategory() {
  // 派生自 store → computed
  const categories = computed(() => businessStore.getSideCategory)

  // 独立状态 → ref
  const activeId = ref<string>('')

  // 只读暴露 → readonly
  const isEditing = ref(false)
  const editingState = readonly(isEditing)

  return { categories, activeId, editingState }
}

5. 类型设计原则

泛型约束

// ✅ GOOD: 泛型支持不同数据类型
export function useLocalForage(storeName: string) {
  async function getItem<T>(key: string): Promise<T | null> { ... }
  async function setItem<T>(key: string, value: T): Promise<void> { ... }
  return { getItem, setItem }
}

输入类型严格

// ✅ GOOD: 参数类型精确
export function useEngine(engineType: SearchEngineType) { ... }

// ❌ BAD: 参数类型过于宽泛
export function useEngine(engineType: string) { ... }

返回类型推断

让 TypeScript 自动推断返回类型,除非需要导出:

// 一般不需要显式声明返回类型
export function usePageIcon() {
  // TypeScript 自动推断返回 { pageIcon: ComputedRef<...>, addPageIcon: (...) => void, ... }
  return { pageIcon, addPageIcon, removePageIcon }
}

// 如果其他模块需要使用返回值类型,用 Extract 类型工具
export type PageIconReturn = ReturnType<typeof usePageIcon>

6. 错误处理原则

静默失败 vs 抛出异常

场景 策略 原因
数据获取 静默失败 + 降级 不应阻塞 UI
关键操作 抛出异常 必须让调用方感知
生命周期清理 静默失败 卸载时不应抛错
// 数据获取:静默失败 + 降级
export function useSuggestion(engine: string) {
  const suggestions = ref<string[]>([])

  async function fetchSuggestion(keyword: string) {
    try {
      suggestions.value = await doFetch(keyword)
    } catch {
      suggestions.value = [] // 降级为空列表
    }
  }

  return { suggestions, fetchSuggestion }
}

// 关键操作:抛出异常
export function useBackup() {
  async function exportData(): Promise<Blob> {
    const data = await collectData()
    if (!data) throw new Error('No data to export')
    return packZip(data)
  }

  return { exportData }
}

7. Composable 与组件的边界

放在 Composable 中

  • 可复用的状态逻辑
  • 与特定 UI 无关的数据转换
  • Store 访问桥接
  • 浏览器 API 封装

放在组件中

  • 模板渲染相关计算
  • 仅当前组件使用的 UI 状态(如弹窗开关)
  • DOM 直接操作(通过 ref
// ✅ 放 composable:可复用的搜索引擎逻辑
// hooks/web/useEngine.ts
export function useEngine() {
  const businessStore = useBusinessStoreWithOut()
  const currentEngine = computed(() => businessStore.getSearchEngine)
  function switchEngine() { ... }
  return { currentEngine, switchEngine }
}

// ✅ 放组件:仅当前组件使用的弹窗状态
<script setup lang="ts">
const dialogVisible = ref(false)
const openDialog = () => (dialogVisible.value = true)
</script>

8. 完整示例:生产级 Composable

// hooks/web/useSuggestion.ts
import { ref, onUnmounted } from 'vue'
import { useEngine } from './useEngine'
import { SUGGESTION_TIMEOUT } from '@/constants'

interface SuggestionOptions {
  timeout?: number
  maxResults?: number
}

export function useSuggestion(options?: SuggestionOptions) {
  const { currentEngine } = useEngine()
  const { timeout = SUGGESTION_TIMEOUT, maxResults = 10 } = options ?? {}

  // 状态
  const suggestions = ref<string[]>([])
  const loading = ref(false)

  // 清理:JSONP 脚本和超时定时器
  let scriptEl: HTMLScriptElement | null = null
  let timer: ReturnType<typeof setTimeout> | null = null

  function cleanup() {
    if (scriptEl) {
      scriptEl.remove()
      scriptEl = null
    }
    if (timer) {
      clearTimeout(timer)
      timer = null
    }
  }

  async function fetch(keyword: string) {
    if (!keyword.trim()) {
      suggestions.value = []
      return
    }

    cleanup()
    loading.value = true

    return new Promise<void>((resolve) => {
      const callbackName = `suggestion_${Date.now()}`

      // JSONP 回调
      ;(window as any)[callbackName] = (data: string[]) => {
        suggestions.value = data.slice(0, maxResults)
        loading.value = false
        cleanup()
        delete (window as any)[callbackName]
        resolve()
      }

      // 超时处理
      timer = setTimeout(() => {
        suggestions.value = []
        loading.value = false
        cleanup()
        delete (window as any)[callbackName]
        resolve()
      }, timeout)

      // 注入脚本
      const url = currentEngine.value.suggestionUrl(keyword, callbackName)
      scriptEl = document.createElement('script')
      scriptEl.src = url
      document.head.appendChild(scriptEl)
    })
  }

  // 自动清理
  onUnmounted(cleanup)

  return { suggestions, loading, fetch }
}

这个示例综合了多种模式:

  • Options 模式:可配置超时和最大结果数
  • 异步资源loading 状态管理
  • 生命周期感知onUnmounted 自动清理
  • 最小暴露:只返回 suggestions、loading、fetch
  • 错误处理:超时降级为空列表

9. 测试 Composable

Composable 是纯函数(返回响应式状态 + 方法),非常适合单元测试。推荐使用 Vitest + @vue/test-utils

测试纯计算型 Composable

// hooks/__tests__/useDesign.test.ts
import { describe, it, expect } from 'vitest'
import { useDesign } from '../web/useDesign'

describe('useDesign', () => {
  it('should generate correct prefix class', () => {
    const { getPrefixCls } = useDesign()
    expect(getPrefixCls('layout')).toBe('mi-layout')
  })

  it('should expose namespace variables', () => {
    const { variables, simplePrefixCls } = useDesign()
    expect(variables.namespace).toBeDefined()
    expect(simplePrefixCls).toBeDefined()
  })
})

测试有状态 Composable

// hooks/__tests__/useEngine.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEngine } from '../web/useEngine'

describe('useEngine', () => {
  beforeEach(() => {
    setActivePinia(createPinia()) // 每次测试前创建新的 pinia 实例
  })

  it('should return current search engine', () => {
    const { selectEngine, engineInfo } = useEngine()
    expect(selectEngine.value).toBe('baidu')
    expect(engineInfo.value.label).toBe('百度')
  })

  it('should switch to next engine', () => {
    const { selectEngine, nextEngine } = useEngine()
    nextEngine()
    expect(selectEngine.value).toBe('google')
  })

  it('should cycle back to first engine', () => {
    const { selectEngine, updateSelectEngine, nextEngine } = useEngine()
    // 切换到最后一个
    updateSelectEngine('sogou')
    nextEngine()
    expect(selectEngine.value).toBe('baidu')
  })
})

测试含生命周期的 Composable

// hooks/__tests__/useNetwork.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useNetwork } from '../web/useNetwork'

describe('useNetwork', () => {
  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('should reflect online status', async () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
    const { isOnline } = useNetwork()
    expect(isOnline.value).toBe(true)
  })

  it('should update when going offline', async () => {
    vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
    const { isOnline } = useNetwork()
    expect(isOnline.value).toBe(false)
  })
})

测试异步 Composable

// hooks/__tests__/useLocalForage.test.ts
import { describe, it, expect } from 'vitest'
import { useLocalForage } from '../web/useLocalForage'

describe('useLocalForage', () => {
  it('should get and set items', async () => {
    const { setItem, getItem, loading } = useLocalForage('test')

    await setItem('key1', { name: 'test' })
    expect(loading.value).toBe(false)

    const result = await getItem<{ name: string }>('key1')
    expect(result?.name).toBe('test')
  })

  it('should handle missing items', async () => {
    const { getItem } = useLocalForage('test')
    const result = await getItem('nonexistent')
    expect(result).toBeNull()
  })
})

测试 Store Bridge Composable

Store Bridge 模式的 composable 测试关键是初始化 pinia

// hooks/__tests__/usePageIcon.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePageIcon } from '../web/usePageIcon'

describe('usePageIcon', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('should add page icon', () => {
    const { addPageIcon, curPageIcons } = usePageIcon()

    addPageIcon({
      label: '测试',
      url: 'https://example.com',
      icon: 'test-icon',
      iconType: 'online',
      type: 'icon'
    })

    expect(curPageIcons.value.length).toBe(1)
    expect(curPageIcons.value[0].label).toBe('测试')
  })
})

测试原则

原则 说明
每个 test 独立 beforeEach + setActivePinia(createPinia()) 重置状态
只测试公开接口 只测 return 的值和方法,不测内部实现
Mock 副作用 网络请求、浏览器 API 用 vi.spyOn / vi.mock 隔离
覆盖边界情况 空输入、异常路径、极限值
测试异步行为 async/await + 断言 loading 状态变化

目录结构建议:

src/hooks/
├── web/
│   ├── __tests__/           # 测试文件目录
│   │   ├── useDesign.test.ts
│   │   ├── useEngine.test.ts
│   │   ├── useNetwork.test.ts
│   │   ├── usePageIcon.test.ts
│   │   └── useLocalForage.test.ts
│   ├── useDesign.ts
│   ├── useEngine.ts
│   └── ...