Files
pulse-libs/skills/vue-composition-api-best-practices/reference/composable-design-patterns.md
T
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

677 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 组合式函数设计模式
基于 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:有状态服务
封装独立的响应式状态和操作逻辑。
```typescript
// 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 内部实现细节。
```typescript
// 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:生命周期感知
自动在组件卸载时清理副作用。
```typescript
// 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:事件总线模式](cross-feature-dependencies.md#模式-4事件总线模式适用于复杂的多对多场景) 了解此模式在跨组件通信中的实际应用。
### 模式 4:异步资源
封装异步资源加载,提供加载状态。
```typescript
// 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:参数化工具
接收参数,返回计算结果或操作函数,不维护持久状态。
```typescript
// 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 }
}
```
```typescript
// hooks/event/useScrollTo.ts
export function useScrollTo() {
function scrollTo(target: HTMLElement, options?: ScrollToOptions) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start',
...options,
})
}
return { scrollTo }
}
```
**特征**
- 纯函数式,无副作用
- 参数决定返回值
- 不需要清理
**何时使用**:工具类逻辑,如样式计算、DOM 操作、格式化函数。
---
## 3. 参数设计原则
### 单一职责参数
```typescript
// ✅ GOOD: 每个参数职责明确
export function useLocalForage(storeName: string) { ... }
// ✅ GOOD: 可选参数用 Options 模式
export function useSuggestion(engine: string, options?: SuggestionOptions) { ... }
```
### Options 模式
当参数超过 2 个时,使用 Options 对象:
```typescript
interface SuggestionOptions {
timeout?: number
maxResults?: number
callbackName?: string
}
export function useSuggestion(engine: string, options?: SuggestionOptions) {
const { timeout = 5000, maxResults = 10, callbackName } = options ?? {}
// ...
}
```
---
## 4. 返回值设计原则
### 最小暴露原则
只返回外部真正需要的:
```typescript
// ✅ 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)` | 只读状态,内部可修改 | 防止外部篡改 |
```typescript
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. 类型设计原则
### 泛型约束
```typescript
// ✅ 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 }
}
```
### 输入类型严格
```typescript
// ✅ GOOD: 参数类型精确
export function useEngine(engineType: SearchEngineType) { ... }
// ❌ BAD: 参数类型过于宽泛
export function useEngine(engineType: string) { ... }
```
### 返回类型推断
让 TypeScript 自动推断返回类型,除非需要导出:
```typescript
// 一般不需要显式声明返回类型
export function usePageIcon() {
// TypeScript 自动推断返回 { pageIcon: ComputedRef<...>, addPageIcon: (...) => void, ... }
return { pageIcon, addPageIcon, removePageIcon }
}
// 如果其他模块需要使用返回值类型,用 Extract 类型工具
export type PageIconReturn = ReturnType<typeof usePageIcon>
```
---
## 6. 错误处理原则
### 静默失败 vs 抛出异常
| 场景 | 策略 | 原因 |
|------|------|------|
| 数据获取 | 静默失败 + 降级 | 不应阻塞 UI |
| 关键操作 | 抛出异常 | 必须让调用方感知 |
| 生命周期清理 | 静默失败 | 卸载时不应抛错 |
```typescript
// 数据获取:静默失败 + 降级
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
```typescript
// ✅ 放 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
│ └── ...
```