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)
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
# 组合式函数设计模式
|
||||
|
||||
基于 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 }
|
||||
}
|
||||
```
|
||||
|
||||
**特征**:维护全局状态,通常不需要组件级清理。
|
||||
|
||||
### 模式 2:Store 桥接
|
||||
|
||||
用 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
|
||||
│ └── ...
|
||||
```
|
||||
@@ -0,0 +1,431 @@
|
||||
---
|
||||
title: Handling Cross-Feature Dependencies
|
||||
impact: MEDIUM
|
||||
impactDescription: 跨功能依赖管理不当会导致紧密耦合、不可预测的行为,以及功能交互时难以调试
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, dependencies, coupling, architecture, event-bus]
|
||||
---
|
||||
|
||||
# 处理跨功能依赖
|
||||
|
||||
**影响级别:MEDIUM** - 当功能需要交互时,合理的依赖管理可确保行为可预测、代码可维护。
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 通过函数参数显式传递依赖
|
||||
- [ ] 避免通过外层作用域闭包产生隐式依赖
|
||||
- [ ] 使用回调函数进行跨功能通信
|
||||
- [ ] 对于多对多通信,使用事件总线并自动清理
|
||||
- [ ] 考虑依赖方向(单向,避免循环)
|
||||
- [ ] 优先使用 Store 桥接组合式函数访问共享状态
|
||||
|
||||
## 问题所在
|
||||
|
||||
功能之间经常需要交互,但隐式依赖会使代码难以理解和测试。
|
||||
|
||||
**BAD - 通过外层作用域产生隐式依赖:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 功能 1:搜索
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
|
||||
async function handleSearch() {
|
||||
searchResults.value = await fetchResults(searchQuery.value)
|
||||
}
|
||||
|
||||
// 功能 2:分页 — 隐式依赖 handleSearch
|
||||
const currentPage = ref(1)
|
||||
|
||||
watch(currentPage, () => {
|
||||
handleSearch() // 这属于哪个功能?
|
||||
})
|
||||
|
||||
// 功能 3:筛选 — 也依赖搜索
|
||||
const activeFilter = ref('all')
|
||||
|
||||
watch(activeFilter, () => {
|
||||
handleSearch() // 又一个隐式依赖
|
||||
})
|
||||
|
||||
// 问题:更改筛选条件会重置页码,但顺序很重要!
|
||||
// 哪个 watch 先触发?不清楚!
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD - 通过参数显式传递依赖:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 功能声明
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
handleSearch
|
||||
} = useSearch()
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
changePage
|
||||
} = usePagination({
|
||||
onPageChange: handleSearch
|
||||
})
|
||||
|
||||
const {
|
||||
activeFilter,
|
||||
setFilter
|
||||
} = useFilter({
|
||||
onFilterChange: () => {
|
||||
changePage(1)
|
||||
handleSearch()
|
||||
}
|
||||
})
|
||||
|
||||
// ============ 功能实现 ============
|
||||
|
||||
function useSearch() {
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
|
||||
const handleSearch = async () => {
|
||||
searchResults.value = await fetchResults(searchQuery.value)
|
||||
}
|
||||
|
||||
return { searchQuery, searchResults, handleSearch }
|
||||
}
|
||||
|
||||
function usePagination(options: { onPageChange: () => void }) {
|
||||
const currentPage = ref(1)
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page
|
||||
options.onPageChange()
|
||||
}
|
||||
|
||||
return { currentPage, changePage }
|
||||
}
|
||||
|
||||
function useFilter(options: { onFilterChange: () => void }) {
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const setFilter = (filter: string) => {
|
||||
activeFilter.value = filter
|
||||
options.onFilterChange()
|
||||
}
|
||||
|
||||
return { activeFilter, setFilter }
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 依赖模式
|
||||
|
||||
### 模式 1:回调模式(推荐用于简单通信)
|
||||
|
||||
通过回调进行直接的跨功能通信:
|
||||
|
||||
```typescript
|
||||
function usePagination(options: {
|
||||
onPageChange?: (page: number) => void
|
||||
}) {
|
||||
const currentPage = ref(1)
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
currentPage.value = page
|
||||
options.onPageChange?.(page)
|
||||
}
|
||||
|
||||
return { currentPage, goToPage }
|
||||
}
|
||||
|
||||
// 用法
|
||||
const { handleSearch } = useSearch()
|
||||
const { currentPage, goToPage } = usePagination({
|
||||
onPageChange: () => handleSearch()
|
||||
})
|
||||
```
|
||||
|
||||
### 模式 2:Ref 注入模式
|
||||
|
||||
传递响应式 ref 实现共享状态:
|
||||
|
||||
```typescript
|
||||
function useSearch(query: Ref<string>) {
|
||||
const results = ref([])
|
||||
|
||||
watch(query, async (q) => {
|
||||
results.value = await fetchResults(q)
|
||||
})
|
||||
|
||||
return { results }
|
||||
}
|
||||
|
||||
function useSearchInput() {
|
||||
const query = ref('')
|
||||
const debouncedQuery = refDebounced(query, 300)
|
||||
|
||||
return { query, debouncedQuery }
|
||||
}
|
||||
|
||||
// 用法 — 显式依赖
|
||||
const { query, debouncedQuery } = useSearchInput()
|
||||
const { results } = useSearch(debouncedQuery)
|
||||
```
|
||||
|
||||
### 模式 3:组合式函数编排模式
|
||||
|
||||
创建更高层级的组合式函数来组合多个功能:
|
||||
|
||||
```typescript
|
||||
function useSearchWithPagination() {
|
||||
const { searchQuery, searchResults, handleSearch } = useSearch()
|
||||
const { currentPage, pageSize, changePage } = usePagination({
|
||||
onPageChange: handleSearch
|
||||
})
|
||||
|
||||
const searchWithParams = () => {
|
||||
return handleSearch({
|
||||
query: searchQuery.value,
|
||||
page: currentPage.value,
|
||||
size: pageSize.value
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery, searchResults,
|
||||
currentPage, pageSize,
|
||||
searchWithParams, changePage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 4:事件总线模式(适用于复杂的多对多场景)
|
||||
|
||||
使用 `mitt` 并自动清理,实现解耦通信:
|
||||
|
||||
```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 }
|
||||
}
|
||||
```
|
||||
|
||||
**用法 — 发送方:**
|
||||
|
||||
```typescript
|
||||
// Layout.vue - 发送事件
|
||||
const { emit } = useEmitt()
|
||||
emit('open-contextmenu', { event: e })
|
||||
```
|
||||
|
||||
**用法 — 接收方(自动清理):**
|
||||
|
||||
```typescript
|
||||
// MiContextMenu.vue - 监听事件
|
||||
const { on } = useEmitt()
|
||||
|
||||
on('open-contextmenu', (data) => {
|
||||
// 在事件位置处理上下文菜单
|
||||
})
|
||||
// 无需手动 off,组件卸载时自动清理
|
||||
// 多次调用 on() 注册多个监听器,全部会在卸载时清理
|
||||
```
|
||||
|
||||
> 另见:[组合式函数设计模式 - 模式 3:生命周期感知](composable-design-patterns.md#模式-3生命周期感知) 了解 `useEmitt` 作为 Lifecycle-Aware 模式的完整设计原理。
|
||||
|
||||
**何时使用事件总线 vs 回调:**
|
||||
|
||||
| 场景 | 模式 | 原因 |
|
||||
|----------|---------|--------|
|
||||
| 直接父子关系 | 回调/Props | 简单、显式、类型安全 |
|
||||
| 同一组件内的兄弟功能 | 回调 | 依赖流清晰 |
|
||||
| 不同层级树的跨组件通信 | 事件总线 | 需要解耦 |
|
||||
| 一个事件多个监听器 | 事件总线 | 一对多关系 |
|
||||
| 功能需要响应 store 变化 | Store 桥接 | 单一数据源 |
|
||||
|
||||
### 模式 5:Store 桥接用于共享状态
|
||||
|
||||
当多个功能需要相同的 store 数据时,使用 Store 桥接组合式函数,而不是直接访问 store:
|
||||
|
||||
```typescript
|
||||
// ✅ Good — Store 桥接提供统一接口
|
||||
// hooks/web/useSideCategory.ts
|
||||
export const useSideCategory = () => {
|
||||
const appStore = useAppStoreWithOut()
|
||||
|
||||
const selectCategory = computed(() => appStore.selectCategory)
|
||||
const sidebarCategories = computed(() => appStore.sidebarCategories)
|
||||
|
||||
const removeCategory = (val: string) => {
|
||||
// 复杂的业务逻辑集中在这里
|
||||
const idx = sidebarCategories.value.findIndex((s) => s.key === val)
|
||||
if (idx !== -1) {
|
||||
const newCategories = [...sidebarCategories.value]
|
||||
newCategories.splice(idx, 1)
|
||||
appStore.deletePageIconInfo(val) // 同时清理相关数据
|
||||
// 处理选中状态...
|
||||
appStore.setSidebarCategories(newCategories)
|
||||
}
|
||||
}
|
||||
|
||||
return { selectCategory, sidebarCategories, removeCategory }
|
||||
}
|
||||
|
||||
// ❌ Bad — 组件中分散的直接 store 访问
|
||||
// ComponentA.vue
|
||||
const appStore = useAppStore()
|
||||
const idx = appStore.sidebarCategories.findIndex(...)
|
||||
appStore.deletePageIconInfo(val)
|
||||
appStore.setSidebarCategories(...)
|
||||
|
||||
// ComponentB.vue - 重复的逻辑!
|
||||
const appStore = useAppStore()
|
||||
const idx = appStore.sidebarCategories.findIndex(...)
|
||||
appStore.deletePageIconInfo(val)
|
||||
appStore.setSidebarCategories(...)
|
||||
```
|
||||
|
||||
## 依赖方向规则
|
||||
|
||||
### ✅ Good — 单向依赖
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 父组件 │
|
||||
│ Component │
|
||||
└──────┬──────┘
|
||||
│ 传递依赖
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ 功能 A │────▶│ 功能 B │
|
||||
│ (搜索) │ │ (分页) │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### ✅ Good — 跨树通信使用事件总线
|
||||
|
||||
```
|
||||
┌──────────┐ emit ┌──────────────┐
|
||||
│ Layout │───────────────▶│ ContextMenu │
|
||||
└──────────┘ └──────────────┘
|
||||
|
||||
┌──────────┐ emit ┌──────────────┐
|
||||
│ Search │───────────────▶│ Suggestion │
|
||||
└──────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### ❌ 避免 — 循环依赖
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ 功能 A │◀───▶│ 功能 B │
|
||||
│ │ │ │
|
||||
└─────────────┘ └─────────────┘
|
||||
循环依赖!
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
### 搜索 + 筛选 + 分页
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 清晰的依赖链
|
||||
const { searchQuery } = useSearchInput()
|
||||
const { activeFilter } = useFilter()
|
||||
const { currentPage, pageSize } = usePagination()
|
||||
|
||||
// 数据获取整合所有参数
|
||||
const { data, loading, refetch } = useDataFetch({
|
||||
query: searchQuery,
|
||||
filter: activeFilter,
|
||||
page: currentPage,
|
||||
size: pageSize
|
||||
})
|
||||
|
||||
// 筛选变化时重置页码
|
||||
watch(activeFilter, () => {
|
||||
currentPage.value = 1
|
||||
refetch()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 通过事件总线跨组件通信
|
||||
|
||||
```vue
|
||||
<!-- Layout.vue - 触发 -->
|
||||
<script setup lang="ts">
|
||||
const { emit } = useEmitt()
|
||||
|
||||
const openContextmenu = (e: PointerEvent) => {
|
||||
emit('open-contextmenu', { event: e })
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- MiContextMenu.vue - 监听 -->
|
||||
<script setup lang="ts">
|
||||
const { on } = useEmitt()
|
||||
|
||||
on('open-contextmenu', ({ event }) => {
|
||||
// 在事件位置显示上下文菜单
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 表单 + 验证 + 提交
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { fields, updateField } = useFormFields({
|
||||
name: '',
|
||||
email: ''
|
||||
})
|
||||
|
||||
const { errors, validate } = useFormValidation(fields, {
|
||||
name: { required: true },
|
||||
email: { required: true, email: true }
|
||||
})
|
||||
|
||||
const { submit, isSubmitting } = useFormSubmit({
|
||||
onSubmit: async () => {
|
||||
if (!validate()) return
|
||||
await submitForm(fields)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
|
||||
- [mitt - Tiny Event Emitter](https://github.com/developit/mitt)
|
||||
@@ -0,0 +1,393 @@
|
||||
---
|
||||
title: Feature Extraction to Composables
|
||||
impact: MEDIUM
|
||||
impactDescription: 未能提取可复用逻辑将导致代码重复、组件间行为不一致以及更高的维护成本
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, composables, reusability, dry]
|
||||
---
|
||||
|
||||
# 将功能提取为组合式函数
|
||||
|
||||
**影响等级:MEDIUM** - 将通用功能提取到外部组合式函数中可以促进代码复用、保持一致性和简化测试。
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 识别多个组件中使用的逻辑
|
||||
- [ ] 提取到 `composables/` 或 `hooks/` 目录
|
||||
- [ ] 保持组合式函数专注于单一职责
|
||||
- [ ] 使用参数进行配置和依赖注入
|
||||
- [ ] 返回响应式引用和方法
|
||||
- [ ] 考虑使用 Store 桥接模式抽象 store 访问
|
||||
|
||||
## 问题
|
||||
|
||||
当相似逻辑在多个组件中重复时,任何 bug 修复或功能增强都必须在多处应用,增加了维护负担和不一致的风险。
|
||||
|
||||
**BAD - 组件间重复的逻辑:**
|
||||
|
||||
```vue
|
||||
<!-- ComponentA.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const width = ref(window.innerWidth)
|
||||
const height = ref(window.innerHeight)
|
||||
|
||||
function handleResize() {
|
||||
width.value = window.innerWidth
|
||||
height.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
</script>
|
||||
|
||||
<!-- ComponentB.vue -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const width = ref(window.innerWidth)
|
||||
const height = ref(window.innerHeight)
|
||||
|
||||
function handleResize() {
|
||||
width.value = window.innerWidth
|
||||
height.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
|
||||
// 使用 width/height 的其他逻辑...
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD - 提取到组合式函数:**
|
||||
|
||||
```typescript
|
||||
// hooks/web/useWindowSize.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useWindowSize() {
|
||||
const width = ref(window.innerWidth)
|
||||
const height = ref(window.innerHeight)
|
||||
|
||||
function handleResize() {
|
||||
width.value = window.innerWidth
|
||||
height.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('resize', handleResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- ComponentA.vue / ComponentB.vue -->
|
||||
<script setup lang="ts">
|
||||
import { useWindowSize } from '@/hooks/web/useWindowSize'
|
||||
|
||||
const { width, height } = useWindowSize()
|
||||
</script>
|
||||
```
|
||||
|
||||
## 何时提取
|
||||
|
||||
| 信号 | 示例 | 操作 |
|
||||
|--------|---------|--------|
|
||||
| 在 2+ 组件中使用 | 窗口大小、认证状态 | 提取到组合式函数 |
|
||||
| 复杂逻辑 | 表单验证、分页 | 提取以提高清晰度 |
|
||||
| 需要测试 | API 调用、状态机 | 提取以实现隔离 |
|
||||
| 第三方集成 | 数据分析、WebSocket | 提取以实现抽象 |
|
||||
| Store 访问模式 | 页面图标、侧边栏分类 | 提取为 Store 桥接组合式函数 |
|
||||
| 横切关注点 | 事件总线、网络状态 | 提取以保持一致性 |
|
||||
|
||||
## 提取模式
|
||||
|
||||
### 模式 1:简单工具组合式函数
|
||||
|
||||
无状态或最小状态,单一用途:
|
||||
|
||||
```typescript
|
||||
// hooks/web/useDesign.ts
|
||||
import variables from '@/styles/global.module.less'
|
||||
|
||||
export const useDesign = () => {
|
||||
const lessVariables = variables
|
||||
|
||||
const getPrefixCls = (scope: string) => {
|
||||
return `${lessVariables.namespace}-${scope}`
|
||||
}
|
||||
|
||||
return {
|
||||
variables: lessVariables,
|
||||
simplePrefixCls: lessVariables.miNamespace,
|
||||
getPrefixCls
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 2:Store 桥接组合式函数
|
||||
|
||||
将 store 访问封装在清晰的 API 之后。这是实际项目中最具影响力的提取模式:
|
||||
|
||||
```typescript
|
||||
// hooks/web/useEngine.ts
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
import { SEARCH_ENGINE_INFO, SEARCH_ENGINE_ORDER } from '@/config/setting'
|
||||
|
||||
export const useEngine = () => {
|
||||
const appStore = useAppStoreWithOut()
|
||||
|
||||
// 当前选中搜索引擎
|
||||
const selectEngine = computed(() => appStore.selectEngine)
|
||||
|
||||
// 当前设定搜索引擎列表(过滤后)
|
||||
const searchEngine = computed(() =>
|
||||
SEARCH_ENGINE_ORDER.filter((engine) => appStore.searchEngine.includes(engine))
|
||||
)
|
||||
|
||||
// 当前选中搜索引擎详细信息
|
||||
const engineInfo = computed(() => SEARCH_ENGINE_INFO[selectEngine.value])
|
||||
|
||||
// 更新搜索引擎
|
||||
const updateSelectEngine = (val: SearchEngine) => {
|
||||
appStore.setSelectEngine(val)
|
||||
}
|
||||
|
||||
// 下一个搜索引擎
|
||||
const nextEngine = () => {
|
||||
const idx = appStore.searchEngine.indexOf(selectEngine.value)
|
||||
if (idx === appStore.searchEngine.length - 1) {
|
||||
appStore.setSelectEngine(appStore.searchEngine[0])
|
||||
} else {
|
||||
appStore.setSelectEngine(appStore.searchEngine[idx + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return { selectEngine, engineInfo, searchEngine, nextEngine, updateSelectEngine }
|
||||
}
|
||||
```
|
||||
|
||||
**为什么 Store 桥接很重要:**
|
||||
- 组件不需要 `import { useAppStore }` 和了解 store 结构
|
||||
- 业务规则(例如 `SEARCH_ENGINE_ORDER.filter`)集中在一处
|
||||
- 可以轻松替换 store 实现,无需修改组件
|
||||
- 通过 `useXxxStoreWithOut` 可在组件外使用
|
||||
|
||||
### 模式 3:基于事件的组合式函数
|
||||
|
||||
管理副作用并自动清理:
|
||||
|
||||
```typescript
|
||||
// hooks/web/useNetwork.ts
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
export const useNetwork = () => {
|
||||
const online = ref(true)
|
||||
|
||||
const updateNetwork = () => {
|
||||
online.value = navigator.onLine
|
||||
}
|
||||
|
||||
window.addEventListener('online', updateNetwork)
|
||||
window.addEventListener('offline', updateNetwork)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('online', updateNetwork)
|
||||
window.removeEventListener('offline', updateNetwork)
|
||||
})
|
||||
|
||||
return { online }
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 4:参数化组合式函数
|
||||
|
||||
接受配置以实现灵活性:
|
||||
|
||||
```typescript
|
||||
// hooks/web/useCoordinateArea.ts
|
||||
interface Coordinate { x1: number; y1: number; x2: number; y2: number }
|
||||
type DirectionX = 'ltr' | 'rtl'
|
||||
type DirectionY = 'ttb' | 'btt'
|
||||
|
||||
export const useCoordinateArea = (
|
||||
coordinate: Coordinate,
|
||||
direction: DirectionX = 'ltr',
|
||||
directionY: DirectionY = 'ttb'
|
||||
) => {
|
||||
const { width, height } = useWindowSize()
|
||||
const { x, y } = useMouse()
|
||||
|
||||
const { x1, y1, x2, y2 } = coordinate
|
||||
|
||||
const inCoordinateX = computed(() =>
|
||||
direction === 'ltr'
|
||||
? x.value > x1 && x.value < x2
|
||||
: x.value > width.value - x2 && x.value < width.value - x1
|
||||
)
|
||||
|
||||
const inCoordinateY = computed(() =>
|
||||
directionY === 'ttb'
|
||||
? y.value > y1 && y.value < y2
|
||||
: y.value > height.value - y2 && y.value < height.value - y1
|
||||
)
|
||||
|
||||
const inCoordinate = computed(() => inCoordinateX.value && inCoordinateY.value)
|
||||
|
||||
return { inCoordinate }
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 5:第三方集成组合式函数
|
||||
|
||||
用 Vue 友好的 API 封装第三方库:
|
||||
|
||||
```typescript
|
||||
// hooks/web/useCache.ts
|
||||
import WebStorageCacheCrypto from 'web-storage-cache-crypto'
|
||||
import sm4 from '@/utils/cipher/sm4'
|
||||
|
||||
type CacheType = 'localStorage' | 'sessionStorage'
|
||||
|
||||
export const CACHE_KEY = {
|
||||
LANG: 'miao-lang',
|
||||
DICT_CACHE: 'dictCache',
|
||||
MIAOWING_APP: 'miaowing-app',
|
||||
MIAOWING_BUSINESS: 'miaowing-business'
|
||||
}
|
||||
|
||||
export const useCache = (type: CacheType = 'localStorage', crypt: boolean = true) => {
|
||||
const wsCache = new WebStorageCacheCrypto({
|
||||
storage: type,
|
||||
crypt: Boolean(crypt),
|
||||
encrypt: sm4.encrypt,
|
||||
decrypt: sm4.decrypt
|
||||
})
|
||||
|
||||
return { wsCache }
|
||||
}
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── hooks/ # 组合式函数目录
|
||||
│ ├── event/ # 事件相关的组合式函数
|
||||
│ │ └── useScrollTo.ts # 平滑滚动
|
||||
│ └── web/ # Web API 与业务逻辑组合式函数
|
||||
│ ├── useCache.ts # 加密存储
|
||||
│ ├── useCoordinateArea.ts # 鼠标位置检测
|
||||
│ ├── useDesign.ts # CSS 命名空间
|
||||
│ ├── useEmitt.ts # 事件总线
|
||||
│ ├── useEngine.ts # 搜索引擎(Store 桥接)
|
||||
│ ├── useI18n.ts # i18n 命名空间封装
|
||||
│ ├── useLocale.ts # 语言切换
|
||||
│ ├── useLocalForage.ts # IndexedDB 存储
|
||||
│ ├── useNetwork.ts # 网络状态
|
||||
│ ├── usePageIcon.ts # 页面图标(Store 桥接)
|
||||
│ ├── useSideCategory.ts # 侧边栏分类(Store 桥接)
|
||||
│ ├── useSuggestion.ts # 搜索建议 JSONP
|
||||
│ └── useTimeAgo.ts # 相对时间
|
||||
```
|
||||
|
||||
**命名规范:**
|
||||
- 文件名与函数名一致:`useEngine.ts` → `export const useEngine = () => {}`
|
||||
- 按领域分组:`event/` 用于 DOM 事件,`web/` 用于 Web API 和业务逻辑
|
||||
- 每个文件一个组合式函数
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 单一职责
|
||||
|
||||
```typescript
|
||||
// ✅ 好 — 只专注一件事
|
||||
export function useLocalStorage<T>(key: string, defaultValue: T) {
|
||||
const stored = localStorage.getItem(key)
|
||||
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
|
||||
|
||||
watch(data, (newValue) => {
|
||||
localStorage.setItem(key, JSON.stringify(newValue))
|
||||
}, { deep: true })
|
||||
|
||||
return { data }
|
||||
}
|
||||
|
||||
// ❌ 差 — 混合了多个关注点
|
||||
export function useUserStorageAndAuth() {
|
||||
// 太多职责混在一起
|
||||
}
|
||||
```
|
||||
|
||||
> **注意:** 上述 `useLocalStorage` 直接使用了浏览器 API。如果你的项目需要支持 SSR,应从 `@vueuse/core` 引入 `useStorage`,它会自动处理非浏览器环境。
|
||||
|
||||
### 2. 接受 Ref 以保持响应性
|
||||
|
||||
```typescript
|
||||
// ✅ 好 — 同时接受原始值和 ref
|
||||
export function useSearch(query: MaybeRef<string>) {
|
||||
const results = ref([])
|
||||
|
||||
watch(
|
||||
() => toValue(query),
|
||||
async (q) => {
|
||||
results.value = await searchAPI(q)
|
||||
}
|
||||
)
|
||||
|
||||
return { results }
|
||||
}
|
||||
|
||||
// 用法
|
||||
const query = ref('')
|
||||
const { results } = useSearch(query) // 响应式!
|
||||
const { results } = useSearch('static') // 也可以工作
|
||||
```
|
||||
|
||||
### 3. 返回响应式引用
|
||||
|
||||
```typescript
|
||||
// ✅ 好 — 返回 ref 用于模板绑定
|
||||
export function useTimer() {
|
||||
const seconds = ref(0)
|
||||
const isRunning = ref(false)
|
||||
return { seconds, isRunning, start, stop }
|
||||
}
|
||||
|
||||
// ❌ 差 — 返回普通值,丢失响应性
|
||||
export function useTimer() {
|
||||
let seconds = 0
|
||||
return { seconds } // 不是响应式的!
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 自动清理
|
||||
|
||||
始终在 `onBeforeUnmount` 中清理副作用:
|
||||
|
||||
```typescript
|
||||
// ✅ 好 — 卸载时清理
|
||||
export const useNetwork = () => {
|
||||
const online = ref(true)
|
||||
const update = () => { online.value = navigator.onLine }
|
||||
|
||||
window.addEventListener('online', update)
|
||||
window.addEventListener('offline', update)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('online', update)
|
||||
window.removeEventListener('offline', update)
|
||||
})
|
||||
|
||||
return { online }
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [VueUse - Collection of Vue Composition Utilities](https://vueuse.org/)
|
||||
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
@@ -0,0 +1,621 @@
|
||||
# 响应性与性能
|
||||
|
||||
Vue 3 Composition API 响应式与性能优化最佳实践。
|
||||
|
||||
---
|
||||
|
||||
## 1. ref vs shallowRef vs reactive
|
||||
|
||||
### 选择决策树
|
||||
|
||||
```
|
||||
需要响应式?
|
||||
├── 是 → 数据是基本类型?
|
||||
│ ├── 是 → ref
|
||||
│ └── 否 → 数据层级深?
|
||||
│ ├── 浅层即可 → shallowRef
|
||||
│ └── 需要深层 → ref(或 reactive)
|
||||
└── 否 → 不需要响应式 → 普通变量 / shallowRef
|
||||
```
|
||||
|
||||
### 对比表
|
||||
|
||||
| API | 响应深度 | 触发更新方式 | 适用场景 |
|
||||
|-----|---------|-------------|---------|
|
||||
| `ref` | 深层 | 自动 | 通用场景,对象属性变更需触发更新 |
|
||||
| `shallowRef` | 浅层(.value) | 手动 `triggerRef` | 大型对象、动态组件、性能敏感场景 |
|
||||
| `reactive` | 深层 | 自动 | 不需要重新赋值的对象 |
|
||||
|
||||
### ⚠️ reactive 的局限性
|
||||
|
||||
虽然 `reactive` 在某些场景下很方便,但 Vue 3 官方更推荐用 `ref` 作为主要响应式 API:
|
||||
|
||||
```typescript
|
||||
// ❌ 1. 不能重新赋值 -- 整个替换会丢失响应式
|
||||
let state = reactive({ count: 0 })
|
||||
state = reactive({ count: 1 }) // 响应式连接断开!
|
||||
|
||||
// ✅ 用 ref 没问题
|
||||
const state = ref({ count: 0 })
|
||||
state.value = { count: 1 } // 正常触发更新
|
||||
|
||||
// ❌ 2. 解构丢失响应式
|
||||
const { count } = reactive({ count: 0 }) // count 变成了普通数字
|
||||
|
||||
// ✅ 用 toRefs 保持响应式
|
||||
const { count } = toRefs(reactive({ count: 0 }))
|
||||
|
||||
// ❌ 3. 不支持基本类型
|
||||
const count = reactive(0) // 类型错误!
|
||||
|
||||
// ✅ 基本类型用 ref
|
||||
const count = ref(0)
|
||||
```
|
||||
|
||||
**经验法则:** 新代码优先使用 `ref`,仅在明确需要"对象属性级响应式且确定不会重新赋值"时使用 `reactive`。
|
||||
|
||||
### 实际案例:动态组件切换
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: 使用 shallowRef 避免组件对象的深层响应式开销
|
||||
function usePage() {
|
||||
const activeCom = shallowRef()
|
||||
const isPure = computed(() => appStore.pure)
|
||||
|
||||
watchEffect(() => {
|
||||
// 组件对象不需要深层响应式,shallowRef 足矣
|
||||
activeCom.value = isPure.value ? PureMode : HomeMode
|
||||
})
|
||||
|
||||
return { activeCom }
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: 使用 ref 对组件对象做深层响应式,无意义且浪费性能
|
||||
function usePage() {
|
||||
const activeCom = ref() // 会递归遍历组件对象的所有属性
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### shallowRef 手动触发更新
|
||||
|
||||
```typescript
|
||||
const list = shallowRef<string[]>(['a', 'b', 'c'])
|
||||
|
||||
// ❌ 不会触发更新:修改数组内部不会被追踪
|
||||
list.value.push('d')
|
||||
|
||||
// ✅ 触发更新:替换整个 .value
|
||||
list.value = [...list.value, 'd']
|
||||
|
||||
// ✅ 触发更新:使用 triggerRef
|
||||
list.value.push('d')
|
||||
triggerRef(list)
|
||||
```
|
||||
|
||||
### markRaw — 标记对象永不转为响应式
|
||||
|
||||
当确定某个对象不需要响应式时(如第三方库实例、大型静态数据),用 `markRaw` 标记它。这可以防止 Vue 的响应式系统意外地将其深层代理,避免性能浪费:
|
||||
|
||||
```typescript
|
||||
import { markRaw, reactive, shallowRef } from 'vue'
|
||||
|
||||
// ❌ BAD: 第三方库实例被意外代理
|
||||
const mapInstance = new Map() // 被 reactive 包装,产生大量 proxy 开销
|
||||
|
||||
// ✅ GOOD: 标记为永不代理
|
||||
const mapInstance = markRaw(new Map())
|
||||
const state = reactive({
|
||||
map: mapInstance // map 本身不会被代理
|
||||
})
|
||||
|
||||
// ✅ GOOD: 标记大型静态数据
|
||||
const largeStaticConfig = markRaw({
|
||||
// 数千行配置数据...
|
||||
})
|
||||
const appState = shallowRef({
|
||||
config: largeStaticConfig // config 不会被 deep-track
|
||||
})
|
||||
```
|
||||
|
||||
**何时使用 `markRaw`:**
|
||||
- 第三方库实例(如 Leaflet 地图、Monaco Editor、ECharts 实例)
|
||||
- 大型静态数据对象(如国家/地区列表、字典数据)
|
||||
- 已经冻结的数据(`Object.freeze`)
|
||||
- 在 `pinia` persist 中不需要持久化的运行时对象
|
||||
|
||||
**⚠️ 注意:** `markRaw` 是永久性的,标记后无法撤销。被标记的对象在 `reactive`/`ref` 中会被视为非响应式。
|
||||
|
||||
---
|
||||
|
||||
## 2. computed 缓存优化
|
||||
|
||||
### computed 的缓存特性
|
||||
|
||||
- 只在依赖变化时重新计算
|
||||
- 多次访问只计算一次
|
||||
- 适合派生状态和昂贵计算
|
||||
|
||||
### 何时用 computed vs 方法
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: 派生状态用 computed,有缓存
|
||||
const filteredList = computed(() =>
|
||||
list.value.filter(item => item.active)
|
||||
)
|
||||
|
||||
// ❌ BAD: 用方法返回派生值,每次调用都重新计算
|
||||
function getFilteredList() {
|
||||
return list.value.filter(item => item.active)
|
||||
}
|
||||
```
|
||||
|
||||
### computed 写入(双向绑定)
|
||||
|
||||
```typescript
|
||||
const keyword = computed({
|
||||
get: () => searchStore.keyword,
|
||||
set: (val: string) => { searchStore.keyword = val }
|
||||
})
|
||||
```
|
||||
|
||||
### 避免在 computed 中产生副作用
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: computed 中有副作用
|
||||
const userInfo = computed(() => {
|
||||
fetchUserInfo() // 每次依赖变化都会请求
|
||||
return userStore.info
|
||||
})
|
||||
|
||||
// ✅ GOOD: 用 watch 处理副作用
|
||||
const userInfo = computed(() => userStore.info)
|
||||
watch(userId, (newId) => {
|
||||
fetchUserInfo(newId)
|
||||
}, { immediate: true })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. watch 优化
|
||||
|
||||
### watch vs watchEffect
|
||||
|
||||
| API | 依赖追踪 | 访问旧值 | 精确控制 | 适用场景 |
|
||||
|-----|---------|---------|---------|---------|
|
||||
| `watch` | 显式指定 | ✅ | ✅ | 需要旧值对比、精确监听 |
|
||||
| `watchEffect` | 自动追踪 | ❌ | ❌ | 副作用与响应式源直接关联 |
|
||||
|
||||
### watch 的精确控制
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: 精确监听特定属性
|
||||
watch(
|
||||
() => appStore.theme,
|
||||
(newTheme) => { applyTheme(newTheme) }
|
||||
)
|
||||
|
||||
// ❌ BAD: 监听整个 store,任何变化都触发
|
||||
watch(
|
||||
() => appStore,
|
||||
() => { applyTheme(appStore.theme) },
|
||||
{ deep: true } // 深层监听开销大
|
||||
)
|
||||
```
|
||||
|
||||
### 常用选项
|
||||
|
||||
```typescript
|
||||
watch(source, callback, {
|
||||
immediate: true, // 创建时立即执行一次
|
||||
deep: false, // 避免深层监听(默认 false)
|
||||
once: true, // 只触发一次后自动停止(Vue 3.4+)
|
||||
flush: 'post', // DOM 更新后执行(需要访问更新后的 DOM 时使用)
|
||||
})
|
||||
```
|
||||
|
||||
### watch 中清理副作用
|
||||
|
||||
```typescript
|
||||
watch(id, (newId, oldId, onCleanup) => {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`/api/user/${newId}`, { signal: controller.signal })
|
||||
.then(res => res.json())
|
||||
.then(data => { user.value = data })
|
||||
|
||||
// id 变化时取消上一次请求
|
||||
onCleanup(() => controller.abort())
|
||||
})
|
||||
```
|
||||
|
||||
### onWatcherCleanup (Vue 3.5+)
|
||||
|
||||
**Vue 3.5 引入了 `onWatcherCleanup()`** — 可以在 `watchEffect` 内部调用的清理函数(之前 `watchEffect` 不支持 `onCleanup` 回调参数):
|
||||
|
||||
```typescript
|
||||
import { watchEffect, onWatcherCleanup } from 'vue'
|
||||
|
||||
// ✅ Vue 3.5+: watchEffect 内部也能注册清理函数
|
||||
watchEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
fetch(`/api/user/${userId.value}`, { signal: controller.signal })
|
||||
.then(res => res.json())
|
||||
.then(data => { user.value = data })
|
||||
|
||||
// userId 变化或组件卸载时自动取消请求
|
||||
onWatcherCleanup(() => controller.abort())
|
||||
})
|
||||
```
|
||||
|
||||
**对比 `watch` 的 `onCleanup` 参数:**
|
||||
|
||||
| 特性 | `watch(fn, (_, __, onCleanup) => {})` | `watchEffect(() => { onWatcherCleanup(...) })` |
|
||||
|------|--------------------------------------|----------------------------------------------|
|
||||
| 可用性 | Vue 3.0+ | Vue 3.5+ |
|
||||
| 清理触发时机 | 下次执行前 + 卸载时 | 下次执行前 + 卸载时 |
|
||||
| 使用方式 | 回调参数 | 独立函数调用 |
|
||||
| 适用场景 | 精确监听 + 清理 | 自动追踪 + 清理 |
|
||||
|
||||
**为什么需要 `onWatcherCleanup`:** 之前 `watchEffect` 无法注册清理函数,导致在 effect 中发起的异步请求无法在新请求发起前自动取消,容易产生竞态条件。
|
||||
|
||||
---
|
||||
|
||||
## 4. 事件监听清理
|
||||
|
||||
### 组件级自动清理
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: 在 composable 中使用生命周期钩子自动清理
|
||||
import { onUnmounted } from 'vue'
|
||||
import { mittBus } from '@/utils/mitt'
|
||||
|
||||
export function useEmitt() {
|
||||
const listeners: Array<{ event: string; handler: Function }> = []
|
||||
|
||||
function on(event: string, handler: Function) {
|
||||
mittBus.on(event, handler as any)
|
||||
listeners.push({ event, handler })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
listeners.forEach(({ event, handler }) => {
|
||||
mittBus.off(event, handler as any)
|
||||
})
|
||||
listeners.length = 0
|
||||
})
|
||||
|
||||
return { on, emit: mittBus.emit }
|
||||
}
|
||||
```
|
||||
|
||||
> 另见:[组合式函数设计模式 - 模式 3](composable-design-patterns.md#模式-3生命周期感知) 和 [跨功能依赖 - 模式 4](cross-feature-dependencies.md#模式-4事件总线模式适用于复杂的多对多场景) 了解 `useEmitt` 的更多使用场景。
|
||||
|
||||
### DOM 事件清理
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 VueUse 的 useEventListener 自动清理
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
export function useNetwork() {
|
||||
const isOnline = ref(navigator.onLine)
|
||||
// 自动在卸载时移除监听
|
||||
useEventListener(window, 'online', () => (isOnline.value = true))
|
||||
useEventListener(window, 'offline', () => (isOnline.value = false))
|
||||
return { isOnline }
|
||||
}
|
||||
```
|
||||
|
||||
### 手动清理模式
|
||||
|
||||
```typescript
|
||||
// 对于不支持生命周期钩子的场景,提供 stop 函数
|
||||
export function useInterval(fn: () => void, delay: number) {
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
timer = setInterval(fn, delay)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
return { start, stop }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件懒加载
|
||||
|
||||
### defineAsyncComponent
|
||||
|
||||
```typescript
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
// ✅ GOOD: 懒加载重型组件
|
||||
const HeavyChart = defineAsyncComponent(() =>
|
||||
import('@/components/Chart/src/HeavyChart.vue')
|
||||
)
|
||||
```
|
||||
|
||||
### 动态 import + shallowRef
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: 条件加载组件
|
||||
const activeCom = shallowRef<Component>()
|
||||
|
||||
watchEffect(async () => {
|
||||
if (condition.value) {
|
||||
const mod = await import('./HeavyComponent.vue')
|
||||
activeCom.value = mod.default
|
||||
} else {
|
||||
activeCom.value = LightComponent
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Suspense 配合
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<AsyncComponent />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<LoadingSpinner />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. v-once 与 v-memo
|
||||
|
||||
### v-once:只渲染一次
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只在首次渲染时求值,后续更新跳过 -->
|
||||
<div v-once>{{ staticContent }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### v-memo:条件记忆
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 仅当 item.id 变化时重新渲染 -->
|
||||
<div v-memo="[item.id]">
|
||||
<ExpensiveComponent :item="item" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 列表渲染优化
|
||||
|
||||
### key 的正确使用
|
||||
|
||||
```vue
|
||||
<!-- ✅ GOOD: 使用唯一 ID -->
|
||||
<div v-for="item in list" :key="item.id">
|
||||
|
||||
<!-- ❌ BAD: 使用 index -->
|
||||
<div v-for="(item, index) in list" :key="index">
|
||||
```
|
||||
|
||||
### 虚拟列表
|
||||
|
||||
当列表项超过 100 个时,使用虚拟滚动:
|
||||
|
||||
```typescript
|
||||
// 推荐 vueuse/useVirtualList 或第三方库
|
||||
import { useVirtualList } from '@vueuse/core'
|
||||
|
||||
const { list, containerProps, wrapperProps } = useVirtualList(
|
||||
largeList,
|
||||
{ itemHeight: 48, overscan: 10 }
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 响应式解包注意事项
|
||||
|
||||
### 模板自动解包
|
||||
|
||||
在模板中,`ref` 自动解包,不需要 `.value`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ 自动解包 -->
|
||||
<div>{{ count }}</div>
|
||||
|
||||
<!-- ❌ 不需要 .value -->
|
||||
<div>{{ count.value }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### reactive 内的 ref 自动解包
|
||||
|
||||
```typescript
|
||||
const state = reactive({
|
||||
count: ref(0),
|
||||
name: 'test'
|
||||
})
|
||||
|
||||
// ✅ reactive 对象中 ref 自动解包
|
||||
console.log(state.count) // 0,不需要 state.count.value
|
||||
```
|
||||
|
||||
### 非响应式对象中的 ref 不解包
|
||||
|
||||
```typescript
|
||||
const map = new Map<string, Ref<number>>()
|
||||
map.set('a', ref(1))
|
||||
|
||||
// ❌ 非 reactive 对象,不会自动解包
|
||||
console.log(map.get('a')) // Ref 对象,需要 .value
|
||||
console.log(map.get('a')!.value) // 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. effectScope — 管理多个 Composable 的生命周期
|
||||
|
||||
当多个 composable 需要同时创建和销毁时,`effectScope` 可以批量管理它们的响应式 effect:
|
||||
|
||||
### 基础用法
|
||||
|
||||
```typescript
|
||||
import { effectScope, ref, watchEffect, onScopeDispose } from 'vue'
|
||||
|
||||
// 创建独立作用域
|
||||
const scope = effectScope()
|
||||
|
||||
scope.run(() => {
|
||||
// 在这个作用域内创建的所有 effect、watch、computed
|
||||
// 都会关联到此 scope
|
||||
const count = ref(0)
|
||||
|
||||
watchEffect(() => {
|
||||
console.log(`Count: ${count.value}`)
|
||||
})
|
||||
|
||||
// 注册作用域销毁时的清理函数
|
||||
onScopeDispose(() => {
|
||||
console.log('Scope disposed')
|
||||
})
|
||||
})
|
||||
|
||||
// 一次性停止作用域内的所有 effect
|
||||
scope.stop()
|
||||
// 输出: "Scope disposed"
|
||||
// 所有 watchEffect 停止
|
||||
```
|
||||
|
||||
### 实际场景:Composable 工厂
|
||||
|
||||
```typescript
|
||||
// hooks/web/useControlledEffects.ts
|
||||
import { effectScope, ref, watch } from 'vue'
|
||||
|
||||
export function useControlledEffects() {
|
||||
let scope: ReturnType<typeof effectScope> | null = effectScope()
|
||||
const isActive = ref(true)
|
||||
|
||||
function run(setup: () => void) {
|
||||
scope?.run(() => {
|
||||
setup()
|
||||
})
|
||||
}
|
||||
|
||||
function restart() {
|
||||
scope?.stop()
|
||||
scope = effectScope()
|
||||
isActive.value = false
|
||||
nextTick(() => (isActive.value = true))
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
return { run, restart, isActive }
|
||||
}
|
||||
```
|
||||
|
||||
**何时使用 `effectScope`:**
|
||||
- 需要在组件外手动管理多个 effect 的生命周期(如插件、指令)
|
||||
- 实现"批量创建/销毁"模式(如路由切换时清理上一页所有 effect)
|
||||
- 编写 composable 测试时隔离 effect
|
||||
|
||||
**何时不需要:**
|
||||
- 直接在组件内使用 composable — 组件卸载时自动清理
|
||||
- 单个 `watch` / `watchEffect` — 返回的 `stop` 函数足矣
|
||||
|
||||
> 另见:[组合式函数测试](composable-design-patterns.md#9-测试-composable) 了解如何在测试中使用 `effectScope` 隔离 effect。
|
||||
|
||||
---
|
||||
|
||||
## 10. Store 性能优化
|
||||
|
||||
### storeToRefs 避免额外响应式
|
||||
|
||||
```typescript
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const store = useAppStore()
|
||||
|
||||
// ✅ GOOD: storeToRefs 只提取响应式属性,不触发额外响应式包装
|
||||
const { theme, pure } = storeToRefs(store)
|
||||
|
||||
// ❌ BAD: 解构丢失响应式
|
||||
const { theme, pure } = store // 失去响应式
|
||||
|
||||
// ❌ BAD: toRefs 对 store 实例做额外包装
|
||||
const { theme, pure } = toRefs(store) // 不必要,用 storeToRefs
|
||||
```
|
||||
|
||||
### 按需访问 Store 属性
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: computed 精确追踪
|
||||
const theme = computed(() => appStore.theme)
|
||||
|
||||
// ❌ BAD: 解构整个 store 导致所有属性变化都触发重渲染
|
||||
const store = useAppStore()
|
||||
const { theme, pure, layout, ... } = storeToRefs(store) // 过度解构
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 性能检查清单
|
||||
|
||||
### 组件级
|
||||
|
||||
- [ ] 大型数据使用 `shallowRef` 而非 `ref`
|
||||
- [ ] 动态组件使用 `shallowRef`
|
||||
- [ ] 派生状态用 `computed`,不用方法
|
||||
- [ ] 避免在 `computed` 中产生副作用
|
||||
- [ ] 列表使用唯一 `key`
|
||||
- [ ] 重型组件懒加载
|
||||
- [ ] 非响应式对象使用 `markRaw` 标记
|
||||
|
||||
### 副作用清理
|
||||
|
||||
- [ ] 事件监听器在 `onUnmounted` 中移除
|
||||
- [ ] 定时器在 `onUnmounted` 中清除
|
||||
- [ ] JSONP 脚本在完成/超时后移除
|
||||
- [ ] `watch` 返回的 `stop` 函数在适当时机调用
|
||||
- [ ] `watchEffect` 中使用 `onWatcherCleanup()` 清理异步请求(3.5+)
|
||||
|
||||
### Store 使用
|
||||
|
||||
- [ ] 组件内用 `useXxxStore()`,组件外用 `useXxxStoreWithOut()`
|
||||
- [ ] 解构 store 用 `storeToRefs`
|
||||
- [ ] 避免深层 `watch` store
|
||||
- [ ] 不暴露整个 store 实例
|
||||
|
||||
### 响应式选择
|
||||
|
||||
- [ ] 基本类型用 `ref`
|
||||
- [ ] 不需要深层响应的大型对象用 `shallowRef`
|
||||
- [ ] 需要旧值对比用 `watch`,否则用 `watchEffect`
|
||||
- [ ] 模板中不加 `.value`
|
||||
- [ ] 多个 composable 需统一生命周期管理时考虑 `effectScope`
|
||||
@@ -0,0 +1,571 @@
|
||||
---
|
||||
title: script setup Best Practices
|
||||
impact: HIGH
|
||||
impactDescription: 滥用 script setup 特性会导致类型安全问题、运行时错误以及更难维护的代码
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, script-setup, typescript, best-practices]
|
||||
---
|
||||
|
||||
# script setup 最佳实践
|
||||
|
||||
**影响程度:高** — `<script setup>` 是编写 Vue 3 组件的推荐方式。理解其模式可确保类型安全和可维护性。
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 使用 `<script setup lang="ts">` 以支持 TypeScript
|
||||
- [ ] 使用 `defineOptions` 设置组件名称(Vue 3.3+)—— 务必始终使用
|
||||
- [ ] 使用类型声明式的 `defineProps` 和 `defineEmits`
|
||||
- [ ] 使用 `defineModel` 实现双向绑定(Vue 3.4+)
|
||||
- [ ] 使用 `useTemplateRef()` 获取类型安全的模板引用(Vue 3.5+)
|
||||
- [ ] 使用 `useId()` 生成无障碍友好的唯一 ID(Vue 3.5+)
|
||||
- [ ] 优先使用 `toValue()` 而非 `unref()` 来解包 MaybeRef(Vue 3.3+)
|
||||
- [ ] 使用 `InjectionKey` 为 `provide`/`inject` 添加类型
|
||||
- [ ] 避免将 `<script setup>` 与 Options API 混用
|
||||
- [ ] 在组件内和组件外使用正确的 Store 访问模式
|
||||
|
||||
## 问题所在
|
||||
|
||||
使用 `<script setup>` 时的常见错误可能导致类型问题、响应性丢失或令人困惑的代码模式。
|
||||
|
||||
## defineOptions — 务必始终使用
|
||||
|
||||
`defineOptions` 对于 DevTools 识别、`keep-alive` 的 include/exclude 以及递归组件至关重要:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ Good - 始终设置组件名称
|
||||
defineOptions({
|
||||
name: 'MiSearch'
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
**在真实项目中的重要性:**
|
||||
- Vue DevTools 显示组件名称而非 `<Anonymous>`
|
||||
- `<keep-alive :include="['MiSearch']">` 可以正常工作
|
||||
- 递归组件可以引用自身
|
||||
- 调试堆栈信息具有可读性
|
||||
- 更易于在代码库中按组件名称搜索
|
||||
|
||||
## TypeScript 集成
|
||||
|
||||
### 带类型的 Props
|
||||
|
||||
**BAD — 运行时声明:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// props 使用时没有类型推导
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
count: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
// props.title 的类型是 string | undefined,而不是 string
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD — 基于类型的声明:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
count: 0
|
||||
})
|
||||
|
||||
// props.title 的类型是 string(必填、已定义)
|
||||
// props.count 的类型是 number(可选、有默认值)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 带类型的 Emits
|
||||
|
||||
**BAD — 无类型安全:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['update', 'delete'])
|
||||
// 没有参数类型检查
|
||||
emit('update', { any: 'thing' })
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD — 类型安全的 emits:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface Emits {
|
||||
update: [value: { id: string; name: string }]
|
||||
delete: [id: string]
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 类型检查!
|
||||
emit('update', { id: '1', name: 'Test' }) // ✅
|
||||
emit('update', { wrong: 'type' }) // ❌ Error
|
||||
emit('delete', '123') // ✅
|
||||
</script>
|
||||
```
|
||||
|
||||
## defineModel 实现双向绑定
|
||||
|
||||
**Vue 3.4+ 特性:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 简单 model
|
||||
const model = defineModel<string>()
|
||||
model.value = 'new value' // 更新父组件
|
||||
|
||||
// 带选项
|
||||
const count = defineModel<number>({
|
||||
default: 0,
|
||||
required: true
|
||||
})
|
||||
|
||||
// 命名 model
|
||||
const title = defineModel<string>('title')
|
||||
const description = defineModel<string>('description')
|
||||
|
||||
// 带校验器
|
||||
const email = defineModel<string>({
|
||||
default: '',
|
||||
validator: (value) => value.includes('@')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="model" />
|
||||
<input v-model:title="title" v-model:description="description" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Vue 3.4 之前(手动实现):**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const inputValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Store 访问模式
|
||||
|
||||
### 在 Vue 组件内 — 使用 `useXxxStore()`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ Good - 在组件内,使用标准的 store 访问方式
|
||||
const appStore = useAppStore()
|
||||
const businessStore = useBusinessStore()
|
||||
|
||||
// 在组件中直接访问
|
||||
const isDark = computed(() => appStore.getIsDark)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 在 Vue 组件外 — 使用 `useXxxStoreWithOut()`
|
||||
|
||||
```typescript
|
||||
// ✅ Good - 在 utils、hooks、plugins 中使用 WithOut 版本
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
|
||||
export const useEngine = () => {
|
||||
const appStore = useAppStoreWithOut() // 传入全局 pinia 实例
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Good - 在 utils/migration.ts 中
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
|
||||
export async function migrateOnlineIcons() {
|
||||
const appStore = useAppStoreWithOut()
|
||||
// 可以在组件上下文之外访问 store
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ Good - 在 plugins/vue-i18n/index.ts 中
|
||||
import { useLocaleStoreWithOut } from '@/store/modules/locale'
|
||||
|
||||
export async function setupI18n(app: App) {
|
||||
const localeStore = useLocaleStoreWithOut()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**区分两者的原因:**
|
||||
- `useXxxStore()` 依赖 Vue 的 `inject`/`provide`,仅在组件上下文中可用
|
||||
- `useXxxStoreWithOut(store)` 显式传入 pinia 实例,可在任意位置使用
|
||||
- 使用错误会导致运行时错误:`getActivePinia was called with no active Pinia`
|
||||
|
||||
详见 [store-without-pattern](store-without-pattern.md)。
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 带默认值的响应式 Props
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
items: string[]
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
// 使用 toRefs 获取响应式 props
|
||||
const { pageSize } = toRefs(props)
|
||||
|
||||
// 或使用 computed 获取派生值
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(props.items.length / pageSize.value)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### 暴露组件方法
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const focus = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
inputRef.value!.value = ''
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
focus,
|
||||
clear
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 模板引用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 元素引用
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 组件引用
|
||||
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
|
||||
|
||||
// v-for 中的引用数组
|
||||
const itemRefs = ref<HTMLDivElement[]>([])
|
||||
|
||||
onMounted(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="inputRef" />
|
||||
<MyComponent ref="componentRef" />
|
||||
<div v-for="item in items" :key="item.id" ref="itemRefs">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### useTemplateRef(Vue 3.5+)
|
||||
|
||||
**Vue 3.5 引入了 `useTemplateRef()`** — 一种类型安全的替代方案,用于替代普通的 `ref()` 来获取模板引用。它解决了尴尬的 `null` 初始化问题,并提供更好的类型推导:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ✅ Vue 3.5+:无需 null 初始值,类型从模板中推导
|
||||
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
|
||||
const componentRef = useTemplateRef<InstanceType<typeof MyComponent>>('componentRef')
|
||||
|
||||
onMounted(() => {
|
||||
inputRef.value?.focus() // HTMLInputElement | undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="inputRef" />
|
||||
<MyComponent ref="componentRef" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**与 `ref<T | null>` 的关键区别:**
|
||||
|
||||
| 特性 | `ref<T \| null>` | `useTemplateRef<T>()` |
|
||||
|---------|-------------------|-----------------------|
|
||||
| 初始值 | 必须指定 `null` | 自动推导,无需手动设置 null |
|
||||
| 挂载后的类型 | `T \| null` | `T \| undefined` |
|
||||
| 需要 ref 名称匹配 | 手动(靠约定) | 通过字符串参数强制匹配 |
|
||||
| 支持 v-for | ✅ `ref<T[]>([])` | ✅ `useTemplateRef<T[]>('list')` |
|
||||
|
||||
### useId(Vue 3.5+)
|
||||
|
||||
**Vue 3.5 引入了 `useId()`**,用于生成唯一的、SSR 安全的 ID。对于无障碍访问(`aria-labelledby`、`for`/`id` 关联)和避免 ID 冲突至关重要:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const inputId = useId()
|
||||
const labelId = useId()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label :for="inputId" :id="labelId">Email</label>
|
||||
<input :id="inputId" :aria-labelledby="labelId" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**为什么不使用 `Math.random()` 或计数器?**
|
||||
- `useId()` 是 SSR 安全的 — 服务端和客户端生成匹配的 ID
|
||||
- 不会在组件实例之间产生冲突
|
||||
- 在客户端导航(SPA 路由切换)之间会清除
|
||||
|
||||
### Provide/Inject 与 TypeScript
|
||||
|
||||
`provide`/`inject` 是避免 props 逐层传递的强大工具,但类型安全需要明确的模式:
|
||||
|
||||
**步骤 1:定义 `InjectionKey`**
|
||||
|
||||
```typescript
|
||||
// types/injection-keys.ts
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// 类型化的 injection key
|
||||
export const THEME_KEY: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
|
||||
export const CONFIG_KEY: InjectionKey<AppConfig> = Symbol('config')
|
||||
```
|
||||
|
||||
**步骤 2:带类型安全地 provide**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
const appConfig = { apiUrl: 'https://api.example.com', timeout: 5000 }
|
||||
|
||||
provide(THEME_KEY, theme) // ✅ 类型检查通过
|
||||
provide(CONFIG_KEY, appConfig) // ✅ 类型检查通过
|
||||
|
||||
// ❌ Error:类型错误
|
||||
provide(THEME_KEY, 'blue')
|
||||
</script>
|
||||
```
|
||||
|
||||
**步骤 3:带类型安全地 inject**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { THEME_KEY, CONFIG_KEY } from '@/types/injection-keys'
|
||||
|
||||
// ✅ 使用 InjectionKey — 完全类型化,无需默认值
|
||||
const theme = inject(THEME_KEY) // Ref<'light' | 'dark'>
|
||||
|
||||
// ✅ 带默认值 — 类型推导,不会是 undefined
|
||||
const config = inject(CONFIG_KEY, { apiUrl: '/fallback', timeout: 3000 })
|
||||
|
||||
// ⚠️ 不使用 key 或默认值 — 返回 T | undefined
|
||||
const maybeConfig = inject<AppConfig>('config') // AppConfig | undefined
|
||||
</script>
|
||||
```
|
||||
|
||||
| 模式 | 返回类型 | 使用场景 |
|
||||
|---------|------------|-------------|
|
||||
| `inject(key)` 配合 `InjectionKey` | `T`(不可为 null) | 祖先组件中存在 provider |
|
||||
| `inject(key, default)` | `T`(不可为 null) | provider 可能不存在 |
|
||||
| `inject<string>('key')` | `T \| undefined` | 旧的字符串 key 模式 |
|
||||
|
||||
**⚠️ 在 TypeScript 中不要使用纯字符串进行 provide/inject** — 你会失去所有类型安全和 IDE 自动补全。
|
||||
|
||||
### toValue() vs unref()
|
||||
|
||||
**Vue 3.3 引入了 `toValue()`**,作为解包 "MaybeRef" 值的首选方式:
|
||||
|
||||
```typescript
|
||||
import { toValue, unref } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
// 两者都可以标准化 ref 和普通值
|
||||
const a = ref(42)
|
||||
const b = 100
|
||||
|
||||
toValue(a) // 42 — 解包 ref,透传普通值
|
||||
toValue(b) // 100
|
||||
|
||||
unref(a) // 42 — 行为相同
|
||||
unref(b) // 100
|
||||
|
||||
// 但 toValue() 有一个关键区别:
|
||||
// toValue() 还可以解包 getter(返回值的函数)
|
||||
const getter = () => 42
|
||||
toValue(getter) // 42 ✅
|
||||
unref(getter) // () => 42 ❌(不会调用 getter)
|
||||
```
|
||||
|
||||
**经验法则:**
|
||||
- 如果你的组合式函数接受 `MaybeRef<T>` — 使用 `toValue()` 来标准化
|
||||
- 如果你只处理 `Ref` 对象 — `unref()` 也可以
|
||||
- 新代码应优先使用 `toValue()` 以保持向前兼容
|
||||
|
||||
## 应避免的反模式
|
||||
|
||||
### ❌ 不要与 Options API 混用
|
||||
|
||||
```vue
|
||||
<!-- BAD - 混合风格 -->
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 无法在这里访问 Options API 的 data!
|
||||
const double = computed(() => this.count * 2) // Error!
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ 不要解构 Props
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ count: number }>()
|
||||
|
||||
// BAD - 丢失响应性
|
||||
const { count } = props
|
||||
|
||||
// GOOD - 使用 toRefs
|
||||
const { count } = toRefs(props)
|
||||
|
||||
// 或者直接使用 props
|
||||
const double = computed(() => props.count * 2)
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ 不要在 script setup 中使用 `this`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// ❌ Error:'this' 是 undefined
|
||||
console.log(this.$router)
|
||||
|
||||
// ✅ 改用组合式函数
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
console.log(router)
|
||||
</script>
|
||||
```
|
||||
|
||||
### ❌ 不要忘记 ref 的 `.value`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const count = ref(0)
|
||||
|
||||
// ❌ 不会触发响应性
|
||||
count = 1
|
||||
|
||||
// ✅ 正确方式
|
||||
count.value = 1
|
||||
|
||||
// 模板中不需要 .value
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 模板中自动解包 -->
|
||||
<div>{{ count }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### ❌ 不要在组件外使用 `useXxxStore()`
|
||||
|
||||
```typescript
|
||||
// ❌ Bad - 在 utils/hooks/plugins 中会抛出错误
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
export function someUtil() {
|
||||
const store = useAppStore() // Error: pinia is not defined
|
||||
}
|
||||
|
||||
// ✅ Good - 使用 WithOut 版本
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
export function someUtil() {
|
||||
const store = useAppStoreWithOut() // 正常工作!
|
||||
}
|
||||
```
|
||||
|
||||
## 性能提示
|
||||
|
||||
### 对大型对象和动态组件使用 shallowRef
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 动态组件切换 — 使用 shallowRef 避免深层响应性
|
||||
const activeCom = shallowRef()
|
||||
watchEffect(() => {
|
||||
activeCom.value = isPure.value ? PureMode : HomeMode
|
||||
})
|
||||
|
||||
// 大型数据对象 — 替换整个值时使用 shallowRef
|
||||
const largeData = shallowRef<BigObject>({})
|
||||
largeData.value = { /* 新对象 */ } // 仅在引用变化时触发更新
|
||||
</script>
|
||||
```
|
||||
|
||||
### 对派生状态使用 computed
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const items = ref<string[]>([])
|
||||
|
||||
// ✅ 高效 - 缓存直到 items 变化
|
||||
const sortedItems = computed(() =>
|
||||
[...items.value].sort()
|
||||
)
|
||||
|
||||
// ❌ 低效 - 每次渲染都重新创建
|
||||
const getSortedItems = () => [...items.value].sort()
|
||||
</script>
|
||||
```
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Vue.js script setup](https://vuejs.org/api/sfc-script-setup.html)
|
||||
- [Vue.js TypeScript with Composition API](https://vuejs.org/guide/typescript/composition-api.html)
|
||||
- [Vue.js defineModel](https://vuejs.org/api/sfc-script-setup.html#definemodel)
|
||||
- [Vue.js defineOptions](https://vuejs.org/api/sfc-script-setup.html#defineoptions)
|
||||
- [Vue.js useTemplateRef](https://vuejs.org/api/composition-api-helpers.html#usetemplateref)
|
||||
- [Vue.js useId](https://vuejs.org/api/composition-api-helpers.html#useid)
|
||||
- [Vue.js provide/inject](https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity)
|
||||
- [Vue.js toValue](https://vuejs.org/api/reactivity-utilities.html#tovalue)
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
title: SFC Code Organization Order
|
||||
impact: HIGH
|
||||
impactDescription: 代码组织混乱会导致维护困难、难以理解组件结构,以及团队成员之间代码风格不一致
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, script-setup, code-organization, maintainability]
|
||||
---
|
||||
|
||||
# SFC 代码组织顺序
|
||||
|
||||
**影响等级:高** - 良好组织的 SFC(单文件组件)对可维护性和团队协作至关重要。遵循一致的顺序使代码可预测且易于导航。
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 遵循标准的 SFC 代码组织顺序
|
||||
- [ ] 使用 useXxx 函数按功能分组相关代码
|
||||
- [ ] 将 Vue 公共项(options、props、emits 等)放在顶部
|
||||
- [ ] 将功能实现放在底部,IDE 中默认折叠
|
||||
- [ ] 使用清晰的区块注释进行分隔
|
||||
|
||||
## 问题所在
|
||||
|
||||
`<script setup>` 带来了自由和灵活性,但如果没有约定,每个 SFC 文件可能看起来完全不同,使得维护和重构变得困难。
|
||||
|
||||
**BAD - 组织混乱的代码:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, provide, inject } from 'vue'
|
||||
|
||||
// 分散的状态
|
||||
const count = ref(0)
|
||||
const user = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 函数随意穿插在中间
|
||||
function fetchUser() {
|
||||
loading.value = true
|
||||
// ...
|
||||
}
|
||||
|
||||
// 计算属性混在其中
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
|
||||
// 又一个状态
|
||||
const theme = ref('dark')
|
||||
|
||||
// props 定义在很后面
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
// watch 散落在某处
|
||||
watch(() => props.id, fetchUser)
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchUser()
|
||||
})
|
||||
|
||||
// 更多函数...
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
// emits 放在底部
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// provide 分散在各处
|
||||
provide('theme', theme)
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD - 组织良好的代码:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, provide, inject } from 'vue'
|
||||
|
||||
// 组件名
|
||||
defineOptions({
|
||||
name: 'UserComponent'
|
||||
})
|
||||
|
||||
// props
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
// model
|
||||
const model = defineModel<string>()
|
||||
|
||||
// inject
|
||||
const globalConfig = inject('config')
|
||||
|
||||
// emits
|
||||
const emit = defineEmits<{
|
||||
update: [value: string]
|
||||
}>()
|
||||
|
||||
// store
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 外部 hooks
|
||||
const { user, loading, fetchUser } = useUser()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
// 功能声明
|
||||
const { count, doubleCount, increment } = useCounter()
|
||||
|
||||
// provide
|
||||
provide('theme', theme)
|
||||
|
||||
// expose
|
||||
defineExpose({
|
||||
increment,
|
||||
fetchUser
|
||||
})
|
||||
|
||||
// ============ 功能实现 ============
|
||||
|
||||
// 用户管理功能
|
||||
function useUser() {
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchUser = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
user.value = await fetchUserData(props.id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.id, fetchUser, { immediate: true })
|
||||
onMounted(fetchUser)
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
fetchUser
|
||||
}
|
||||
}
|
||||
|
||||
// 主题功能
|
||||
function useTheme() {
|
||||
const theme = ref<'light' | 'dark'>('dark')
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
|
||||
// 计数器功能
|
||||
function useCounter() {
|
||||
const count = ref(0)
|
||||
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
|
||||
const increment = () => {
|
||||
count.value++
|
||||
emit('update', count.value.toString())
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
doubleCount,
|
||||
increment
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 标准组织顺序
|
||||
|
||||
| 顺序 | 区块 | 是否必须 | 描述 |
|
||||
|-------|---------|----------|-------------|
|
||||
| 1 | `defineOptions` | ✅ 推荐 | 组件名称(DevTools、keep-alive、递归组件) |
|
||||
| 2 | `defineProps` | 可选 | 带类型声明的组件 props |
|
||||
| 3 | `defineModel` | 可选 | 双向绑定 model(Vue 3.4+) |
|
||||
| 4 | `inject` | 可选 | 注入的依赖 |
|
||||
| 5 | `defineEmits` | 可选 | 带类型声明的组件事件 |
|
||||
| 6 | Store 声明 | 可选 | Pinia store 实例(`useXxxStore()`) |
|
||||
| 7 | 外部 hooks | 可选 | 导入的组合式函数 |
|
||||
| 8 | 功能声明 | 可选 | `const { ... } = useFeature()` |
|
||||
| 9 | `provide` | 可选 | 提供的依赖 |
|
||||
| 10 | `defineExpose` | 可选 | 暴露的公共 API |
|
||||
| 11 | 功能实现 | 按需 | `function useFeature() {}` |
|
||||
|
||||
## 区块注释风格
|
||||
|
||||
使用清晰、简洁的区块注释。两种常见风格:
|
||||
|
||||
### 风格一:简洁中文注释(推荐中文团队使用)
|
||||
|
||||
```typescript
|
||||
// 组件名
|
||||
defineOptions({ name: 'Layout' })
|
||||
|
||||
// props
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
// emits
|
||||
const emit = defineEmits<{ update: [value: string] }>()
|
||||
|
||||
// store
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 外部 hooks
|
||||
const { t } = useI18n()
|
||||
|
||||
// 功能声明
|
||||
const { imageBgUrl, videoBgUrl } = useBackground()
|
||||
|
||||
// ============ 功能实现 ============
|
||||
|
||||
function useBackground() { /* ... */ }
|
||||
```
|
||||
|
||||
### 风格二:分隔线风格(适用于大型组件)
|
||||
|
||||
```typescript
|
||||
// ============ Vue 公共项 ============
|
||||
|
||||
defineOptions({ name: 'UserComponent' })
|
||||
|
||||
// ============ Store ============
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ============ 外部 Hooks ============
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// ============ 功能声明 ============
|
||||
|
||||
const { search, results } = useSearch()
|
||||
|
||||
// ============ 功能实现 ============
|
||||
|
||||
function useSearch() { /* ... */ }
|
||||
```
|
||||
|
||||
## 收益
|
||||
|
||||
1. **结构可预测**:团队成员知道在哪里找到特定代码
|
||||
2. **快速概览**:顶部区域让组件接口一目了然
|
||||
3. **IDE 导航**:点击声明中的函数名即可跳转到实现
|
||||
4. **默认折叠**:功能实现保持折叠状态,减少视觉干扰
|
||||
5. **依赖清晰**:一眼看清每个功能返回和消费了什么
|
||||
6. **Store 聚合**:所有 store 实例集中声明,便于识别
|
||||
|
||||
## 适用场景
|
||||
|
||||
- **始终遵循**:对所有 `<script setup>` 组件使用此组织方式
|
||||
- **小型组件**:即使区块较少,仍应遵循此顺序
|
||||
- **大型组件**:功能较多时,对保持可读性至关重要
|
||||
- **团队项目**:通过代码审查和 lint 规则强制保持一致性
|
||||
|
||||
## 真实案例
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
// 组件名
|
||||
defineOptions({
|
||||
name: 'MiSearch'
|
||||
})
|
||||
|
||||
// store
|
||||
const appStore = useAppStore()
|
||||
const businessStore = useBusinessStore()
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 简单前缀
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
// useEngine
|
||||
const { engineInfo, nextEngine } = useEngine()
|
||||
|
||||
// useSearchInput
|
||||
const {
|
||||
searchContent,
|
||||
handleChange,
|
||||
handleSearch,
|
||||
isComposing,
|
||||
searchHistoryRef,
|
||||
searchSuggestionRef,
|
||||
operaHistoryOrSuggestion,
|
||||
searchInputRef
|
||||
} = useSearchInput()
|
||||
|
||||
// 输入框
|
||||
function useSearchInput() {
|
||||
const searchContent = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
const handleSearch = (val) => {
|
||||
if (isComposing.value) return
|
||||
const useContent = encodeURIComponent(val)
|
||||
if (!useContent) return
|
||||
businessStore.updateHistoryList(val)
|
||||
window.open(`${engineInfo.value.url}${useContent}`, appStore.openType)
|
||||
}
|
||||
|
||||
// ... 更多逻辑
|
||||
|
||||
return {
|
||||
searchContent,
|
||||
isComposing,
|
||||
handleSearch,
|
||||
handleChange,
|
||||
searchHistoryRef,
|
||||
searchSuggestionRef,
|
||||
operaHistoryOrSuggestion,
|
||||
searchInputRef
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
- [Vue.js script setup](https://vuejs.org/api/sfc-script-setup.html)
|
||||
- [官方示例:FileExplorer.vue](https://github.com/vuejs-translations/docs-zh-cn/blob/main/assets/FileExplorer.vue)
|
||||
@@ -0,0 +1,164 @@
|
||||
# Store Without 模式
|
||||
|
||||
## 问题
|
||||
|
||||
Pinia 的 `useStore()` 默认依赖 Vue 组件上下文(inject/provide)。在组件外(hooks、utils、plugins、路由守卫、axios 拦截器)直接调用会抛出错误:
|
||||
|
||||
```
|
||||
Error: "getActivePinia()" was called but there was no active Pinia.
|
||||
```
|
||||
|
||||
## 解决方案:Store Without 模式
|
||||
|
||||
每个 store 模块额外导出一个 `useXxxStoreWithOut` 函数,接收全局 pinia 实例作为参数,使 store 可在任意上下文中安全访问。
|
||||
|
||||
### 模式定义
|
||||
|
||||
```typescript
|
||||
// store/modules/app.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { store } from '@/store'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
// ... store 定义
|
||||
})
|
||||
|
||||
// 在组件外使用时,传入全局 pinia 实例
|
||||
export const useAppStoreWithOut = () => {
|
||||
return useAppStore(store)
|
||||
}
|
||||
```
|
||||
|
||||
### 全局 Pinia 实例
|
||||
|
||||
```typescript
|
||||
// store/index.ts
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
|
||||
// 导出 store 供 Without 函数使用
|
||||
export const store = pinia
|
||||
```
|
||||
|
||||
## 使用规则
|
||||
|
||||
### 何时使用哪个
|
||||
|
||||
| 函数 | 使用场景 | 原因 |
|
||||
|------|---------|------|
|
||||
| `useAppStore()` | Vue 组件 `<script setup>` 内 | 自动从组件上下文获取 pinia |
|
||||
| `useAppStoreWithOut()` | hooks、utils、plugins、路由守卫等 | 组件上下文不可用,需显式传入 pinia |
|
||||
|
||||
### 在 Vue 组件中(始终使用标准方式)
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD:组件内使用标准方式
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ BAD:组件内使用 WithOut 是多余的
|
||||
<script setup lang="ts">
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
|
||||
const appStore = useAppStoreWithOut() // 可以工作但不必要
|
||||
</script>
|
||||
```
|
||||
|
||||
### 在 Hooks / Utils / Plugins 中(必须使用 WithOut)
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD:hooks 中使用 WithOut
|
||||
// hooks/web/useSideCategory.ts
|
||||
import { useBusinessStoreWithOut } from '@/store/modules/business'
|
||||
|
||||
export function useSideCategory() {
|
||||
const businessStore = useBusinessStoreWithOut()
|
||||
const categories = computed(() => businessStore.getSideCategory)
|
||||
return { categories }
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD:utils 中使用 WithOut
|
||||
// utils/migration.ts
|
||||
import { useBusinessStoreWithOut } from '@/store/modules/business'
|
||||
|
||||
export async function migrateOnlineIcons() {
|
||||
const businessStore = useBusinessStoreWithOut()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ BAD:组件外直接使用标准方式会报错
|
||||
// utils/migration.ts
|
||||
import { useBusinessStore } from '@/store/modules/business'
|
||||
|
||||
export async function migrateOnlineIcons() {
|
||||
const businessStore = useBusinessStore() // Error: no active Pinia
|
||||
}
|
||||
```
|
||||
|
||||
## 命名规范
|
||||
|
||||
所有模块遵循统一命名规范:
|
||||
|
||||
| Store 模块 | 标准函数 | WithOut 函数 |
|
||||
|-----------|---------|-------------|
|
||||
| `app.ts` | `useAppStore` | `useAppStoreWithOut` |
|
||||
| `business.ts` | `useBusinessStore` | `useBusinessStoreWithOut` |
|
||||
| `dict.ts` | `useDictStore` | `useDictStoreWithOut` |
|
||||
| `locale.ts` | `useLocaleStore` | `useLocaleStoreWithOut` |
|
||||
|
||||
**规则**:`use{ModuleName}StoreWithOut` — 模块名首字母大写 + Store + WithOut(注意大小写)。
|
||||
|
||||
## 实现清单
|
||||
|
||||
每个 store 模块必须:
|
||||
|
||||
- [ ] 导出标准 `useXxxStore` 函数(`defineStore` 的返回值)
|
||||
- [ ] 导出 `useXxxStoreWithOut` 函数,内部调用 `useXxxStore(store)`
|
||||
- [ ] 从 `@/store` 导入全局 `store` 实例
|
||||
- [ ] WithOut 函数放在文件底部,紧跟标准函数之后
|
||||
|
||||
## 为什么不直接使用 `useXxxStore(pinia)`?
|
||||
|
||||
理论上可以直接调用 `useAppStore(pinia)`,但 `WithOut` 函数提供了:
|
||||
|
||||
1. **语义明确** — 函数名直接表达"在组件外使用"的意图
|
||||
2. **统一入口** — 不需要每个调用方都 import `store`,减少依赖
|
||||
3. **集中管理** — 如果 pinia 实例获取方式变更,只需改 WithOut 函数
|
||||
4. **可搜索** — 搜索 `WithOut` 即可找到所有组件外使用 store 的地方
|
||||
|
||||
## 替代方案:`storeToRefs` 注意事项
|
||||
|
||||
注意 `useXxxStoreWithOut()` 返回的是 store 实例,如需解构响应式属性,仍需使用 `storeToRefs`:
|
||||
|
||||
```typescript
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStoreWithOut } from '@/store/modules/app'
|
||||
|
||||
export function useAppInfo() {
|
||||
const appStore = useAppStoreWithOut()
|
||||
const { pure, theme } = storeToRefs(appStore) // 保持响应式
|
||||
return { pure, theme }
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
| 优势 | 说明 |
|
||||
|------|------|
|
||||
| 解决组件外访问 | 核心价值,让 store 可在任意上下文使用 |
|
||||
| 命名约定清晰 | `WithOut` 后缀一目了然 |
|
||||
| 减少样板代码 | 调用方无需 import store |
|
||||
| 易于维护 | pinia 实例变更只需改一处 |
|
||||
| 可追溯 | 搜索 `WithOut` 可定位所有组件外使用 |
|
||||
@@ -0,0 +1,358 @@
|
||||
---
|
||||
title: UseXxx Function Pattern for Feature Encapsulation
|
||||
impact: HIGH
|
||||
impactDescription: 如果不使用 useXxx 模式,功能逻辑会变得分散,难以理解每个功能暴露了什么,也难以追踪功能之间的依赖关系
|
||||
type: best-practice
|
||||
tags: [vue3, composition-api, script-setup, use-pattern, code-organization]
|
||||
---
|
||||
|
||||
# UseXxx 函数模式:功能封装
|
||||
|
||||
**影响等级:高** - useXxx 模式将相关逻辑封装到自包含的函数中,使功能易于理解、测试和复用。
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [ ] 将功能逻辑封装在 `useFeatureName()` 函数中
|
||||
- [ ] 仅返回外部需要的值和方法
|
||||
- [ ] 将功能实现放在 script 底部
|
||||
- [ ] 在顶部使用解构声明功能使用
|
||||
- [ ] 功能函数命名清晰,能反映其用途
|
||||
|
||||
## 问题所在
|
||||
|
||||
没有封装时,相关的变量、计算属性、监听器和方法散落在代码各处,难以理解哪些代码属于哪个功能。
|
||||
|
||||
**BAD - 功能逻辑分散:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
// 搜索功能分散
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const isSearching = ref(false)
|
||||
|
||||
// 分页功能混在一起
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalItems = ref(0)
|
||||
|
||||
// 随意放置的 computed
|
||||
const hasResults = computed(() => searchResults.value.length > 0)
|
||||
|
||||
// 另一个功能开始
|
||||
const selectedItem = ref(null)
|
||||
|
||||
// 搜索函数
|
||||
async function handleSearch() {
|
||||
isSearching.value = true
|
||||
searchResults.value = await fetchResults(searchQuery.value)
|
||||
isSearching.value = false
|
||||
}
|
||||
|
||||
// 分页函数
|
||||
function changePage(page: number) {
|
||||
currentPage.value = page
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// watch 分散
|
||||
watch(searchQuery, handleSearch)
|
||||
|
||||
// 选择函数
|
||||
function selectItem(item: any) {
|
||||
selectedItem.value = item
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**GOOD - 使用 useXxx 模式封装:**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
// 功能声明
|
||||
|
||||
// 搜索功能 - 一目了然的接口
|
||||
const {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isSearching,
|
||||
hasResults,
|
||||
handleSearch
|
||||
} = useSearch()
|
||||
|
||||
// 分页功能 - 自包含
|
||||
const {
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalItems,
|
||||
changePage
|
||||
} = usePagination({ onPageChange: handleSearch })
|
||||
|
||||
// 选择功能 - 独立
|
||||
const {
|
||||
selectedItem,
|
||||
selectItem
|
||||
} = useSelection()
|
||||
|
||||
// ============ 功能实现 ============
|
||||
|
||||
function useSearch() {
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
|
||||
const hasResults = computed(() => searchResults.value.length > 0)
|
||||
|
||||
const handleSearch = async () => {
|
||||
isSearching.value = true
|
||||
try {
|
||||
searchResults.value = await fetchResults(searchQuery.value)
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchQuery, handleSearch)
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isSearching,
|
||||
hasResults,
|
||||
handleSearch
|
||||
}
|
||||
}
|
||||
|
||||
function usePagination(options: { onPageChange: () => void }) {
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalItems = ref(0)
|
||||
|
||||
const changePage = (page: number) => {
|
||||
currentPage.value = page
|
||||
options.onPageChange()
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageSize,
|
||||
totalItems,
|
||||
changePage
|
||||
}
|
||||
}
|
||||
|
||||
function useSelection() {
|
||||
const selectedItem = ref<SearchResult | null>(null)
|
||||
|
||||
const selectItem = (item: SearchResult) => {
|
||||
selectedItem.value = item
|
||||
}
|
||||
|
||||
return {
|
||||
selectedItem,
|
||||
selectItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 1. 清晰的返回接口
|
||||
|
||||
return 语句记录了该功能暴露了什么:
|
||||
|
||||
```typescript
|
||||
function useSearch() {
|
||||
// 内部状态 - 不返回
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
|
||||
// 公共状态
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
|
||||
// 公共方法
|
||||
const handleSearch = async () => { /* ... */ }
|
||||
|
||||
return {
|
||||
// 只暴露需要的内容
|
||||
searchQuery,
|
||||
searchResults,
|
||||
handleSearch
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 自包含的逻辑
|
||||
|
||||
每个 useXxx 函数包含所有相关的内容:
|
||||
- 状态(ref、reactive)
|
||||
- 计算属性
|
||||
- 监听器
|
||||
- 生命周期钩子
|
||||
- 方法
|
||||
|
||||
```typescript
|
||||
function useSearch() {
|
||||
// 状态
|
||||
const query = ref('')
|
||||
const results = ref([])
|
||||
|
||||
// 计算属性
|
||||
const isEmpty = computed(() => results.value.length === 0)
|
||||
|
||||
// 监听器
|
||||
watch(query, debounce(search, 300))
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
if (query.value) search()
|
||||
})
|
||||
|
||||
// 方法
|
||||
async function search() { /* ... */ }
|
||||
|
||||
return { query, results, isEmpty, search }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 通过参数进行依赖注入
|
||||
|
||||
将依赖作为参数传递,实现跨功能通信:
|
||||
|
||||
```typescript
|
||||
function usePagination(options: {
|
||||
onPageChange?: () => void
|
||||
initialPage?: number
|
||||
} = {}) {
|
||||
const currentPage = ref(options.initialPage ?? 1)
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
currentPage.value = page
|
||||
options.onPageChange?.()
|
||||
}
|
||||
|
||||
return { currentPage, goToPage }
|
||||
}
|
||||
|
||||
// 使用方式
|
||||
const { handleSearch } = useSearch()
|
||||
const { currentPage, goToPage } = usePagination({
|
||||
onPageChange: handleSearch
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Store 桥接模式
|
||||
|
||||
当组合式函数封装 store 访问时,需提供干净的接口来隐藏 store 实现细节:
|
||||
|
||||
```typescript
|
||||
// 页面图标管理 hooks - 封装 store 访问
|
||||
export const usePageIcon = () => {
|
||||
const appStore = useAppStoreWithOut()
|
||||
|
||||
// 当前 page
|
||||
const curPage = computed(() => appStore.selectCategory.key)
|
||||
|
||||
// 当前 page icons
|
||||
const curPageIcons = computed(() => appStore.pageIconMap[curPage.value] || [])
|
||||
|
||||
// 新增 page icon
|
||||
const addPageIcon = (icon: PageItemWithOptionalKey, page?: string) => {
|
||||
if (!appStore.pageIconMap[page || curPage.value]) {
|
||||
appStore.addPageIconInfo(page || curPage.value)
|
||||
}
|
||||
const icons = appStore.pageIconMap[page || curPage.value]
|
||||
if (!icon.key) {
|
||||
icon.key = `${page || curPage.value}-icon-${icon.type}-${icons.length}`
|
||||
}
|
||||
appStore.updatePageIconInfo(page || curPage.value, icons.concat(icon as PageItem))
|
||||
}
|
||||
|
||||
// 更新 page icon
|
||||
const updatePageIcon = (icon: PageItem, page: string) => {
|
||||
const icons = appStore.pageIconMap[page]
|
||||
const targetIdx = icons.findIndex((i) => i.key === icon.key)
|
||||
if (targetIdx !== -1) {
|
||||
icons[targetIdx] = icon
|
||||
appStore.updatePageIconInfo(page, icons)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 page icon
|
||||
const removePageIcon = (icon: PageItem, page?: string) => {
|
||||
const icons = appStore.pageIconMap[page || curPage.value]
|
||||
if (icons) {
|
||||
appStore.updatePageIconInfo(
|
||||
page || curPage.value,
|
||||
icons.filter((item) => item.key !== icon.key)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
curPage,
|
||||
curPageIcons,
|
||||
addPageIcon,
|
||||
updatePageIcon,
|
||||
removePageIcon
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Store 桥接模式的收益:**
|
||||
- 组件无需了解 store 内部结构
|
||||
- 业务逻辑集中在一处
|
||||
- 便于在无需更新所有组件的情况下更改 store 结构
|
||||
- 与 `useXxxStoreWithOut` 无缝配合,适用于非组件场景
|
||||
|
||||
## 命名约定
|
||||
|
||||
| 模式 | 示例 | 使用场景 |
|
||||
|---------|---------|----------|
|
||||
| `useXxx` | `useSearch()` | 组件内部的功能封装 |
|
||||
| `useXxx` | `useUserStore()` | 外部组合式函数导入 |
|
||||
| `useXxxStoreWithOut` | `useAppStoreWithOut()` | 组件外部的 store 访问 |
|
||||
| `useXxx`(桥接) | `usePageIcon()` | Store 桥接组合式函数 |
|
||||
|
||||
## 何时提取到外部文件
|
||||
|
||||
满足以下条件时移至外部文件:
|
||||
- 跨多个组件使用
|
||||
- 复杂度足够高,需要单独测试
|
||||
- 不依赖父组件状态
|
||||
- 作为 store 数据的桥接层
|
||||
|
||||
```typescript
|
||||
// hooks/web/usePageIcon.ts
|
||||
export const usePageIcon = () => {
|
||||
const appStore = useAppStoreWithOut()
|
||||
// ...
|
||||
return { curPageIcons, addPageIcon, updatePageIcon, removePageIcon }
|
||||
}
|
||||
|
||||
// 在组件中
|
||||
import { usePageIcon } from '@/hooks/web/usePageIcon'
|
||||
const { curPageIcons, addPageIcon } = usePageIcon()
|
||||
```
|
||||
|
||||
## 功能实现检查清单
|
||||
|
||||
编写 `useXxx` 函数时:
|
||||
|
||||
- [ ] 所有相关状态都在函数内部(不散落在外部)
|
||||
- [ ] 从状态派生的计算属性都在函数内部
|
||||
- [ ] 响应状态变化的监听器都在函数内部
|
||||
- [ ] 生命周期钩子(`onMounted`、`onBeforeUnmount`)都在函数内部
|
||||
- [ ] 清理逻辑(`removeEventListener`、`off`)在 `onBeforeUnmount` 内
|
||||
- [ ] 只返回组件需要的内容
|
||||
- [ ] 内部实现细节被隐藏
|
||||
|
||||
## 参考
|
||||
|
||||
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
|
||||
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
Reference in New Issue
Block a user