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:
Pulse
2026-05-19 21:03:25 -03:00
parent 22d9f5b21d
commit ae39e45460
83 changed files with 13349 additions and 1 deletions
@@ -0,0 +1,8 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "vue-composition-api-best-practices",
"installedVersion": "1.0.0",
"installedAt": 1779235185180,
"fingerprint": "b117fb9fc7693ff411a53c62a54f5989e882495193430abfb8f27e5b3ed08021"
}
@@ -0,0 +1,119 @@
---
name: vue-composition-api-best-practices
description: "Vue 3 组合式 API 与 <script setup> 最佳实践。涵盖代码组织、useXxx 模式、组合式函数设计、Store 集成、响应性优化及功能提取。"
license: MIT
metadata:
author: github.com/acanzaima
version: 1.0.0
tags: [vue3, composition-api, script-setup, typescript, pinia, composables, reactivity, performance]
---
Vue 3 组合式 API 与 `<script setup>`、TypeScript 集成及代码组织模式的最佳实践。
## 版本兼容性
本技能面向 **Vue 3.3+**,部分功能需要更高的次版本号:
| 特性 | 最低版本 | 参考 |
|---------|:---:|---|
| `defineOptions` | 3.3+ | [script-setup](reference/script-setup-best-practices.md) |
| `toValue()` | 3.3+ | [script-setup](reference/script-setup-best-practices.md) |
| `defineModel` | 3.4+ | [script-setup](reference/script-setup-best-practices.md) |
| `watch` 搭配 `once` 选项 | 3.4+ | [reactivity](reference/reactivity-performance.md) |
| `useTemplateRef()` | 3.5+ | [script-setup](reference/script-setup-best-practices.md) |
| `useId()` | 3.5+ | [script-setup](reference/script-setup-best-practices.md) |
| `onWatcherCleanup()` | 3.5+ | [reactivity](reference/reactivity-performance.md) |
## 快速决策表
| 问题 | 参考阅读 |
|---------|-------------|
| 我的 `<script setup>` 很乱,难以浏览 | [SFC 代码组织](reference/sfc-code-organization.md) |
| 某个功能的逻辑分散在很多行中 | [useXxx 函数模式](reference/use-function-pattern.md) |
| 相同逻辑在多个组件中重复 | [功能提取](reference/feature-extraction.md) |
| 两个功能相互影响,但不知道正确的处理模式 | [跨功能依赖](reference/cross-feature-dependencies.md) |
| Props/emits 类型安全、`defineModel` 使用 | [script setup 最佳实践](reference/script-setup-best-practices.md) |
| 在 hooks/工具函数/插件中无法访问 Store | [组件外 Store 访问](reference/store-without-pattern.md) |
| 如何组织新的组合式函数文件 | [组合式函数设计模式](reference/composable-design-patterns.md) |
| 页面感觉慢,可能是响应性问题 | [响应性与性能](reference/reactivity-performance.md) |
| 如何测试组合式函数 | [组合式函数测试](reference/composable-design-patterns.md#9-testing-composables) |
| 类型安全的 `provide`/`inject` | [script setup - Provide/Inject](reference/script-setup-best-practices.md#provideinject-with-typescript) |
### 代码组织
- SFC 代码缺乏清晰的组织结构 → 参见 [sfc-code-organization](reference/sfc-code-organization.md)
- 功能逻辑分散在脚本各处 → 参见 [use-function-pattern](reference/use-function-pattern.md)
- 需要将可复用逻辑提取为组合式函数 → 参见 [feature-extraction](reference/feature-extraction.md)
- 跨功能依赖导致混乱 → 参见 [cross-feature-dependencies](reference/cross-feature-dependencies.md)
### TypeScript 与 Script Setup
- 需要 script setup 的 TypeScript 最佳实践 → 参见 [script-setup-best-practices](reference/script-setup-best-practices.md)
### Store 集成
- 在 Vue 组件外部访问 Pinia store → 参见 [store-without-pattern](reference/store-without-pattern.md)
### 组合式函数设计
- 设计健壮、可复用的组合式函数 → 参见 [composable-design-patterns](reference/composable-design-patterns.md)
### 响应性与性能
- 优化响应性以获得更好性能 → 参见 [reactivity-performance](reference/reactivity-performance.md)
### 测试
- 使用 Vitest 测试组合式函数 → 参见 [composable-design-patterns](reference/composable-design-patterns.md#9-testing-composables)
---
## 速查表
### SFC 代码组织顺序(11 步)
```
1. defineOptions → 组件名称
2. defineProps → Props 类型声明
3. defineModel → 双向绑定(3.4+)
4. inject → 注入依赖
5. defineEmits → 事件类型声明
6. Store 声明 → useXxxStore()
7. 外部 hooks → useI18n()、useDesign() 等
8. 功能声明 → const { ... } = useFeature()
9. provide → 提供依赖
10. defineExpose → 暴露公共 API
11. 功能实现 → function useFeature() {}
```
### 响应式 API 选择
```
基本类型 → ref
需要深层响应 → ref
大型对象/动态组件 → shallowRef
不需要重新赋值 → reactive(谨慎使用)
永不响应式 → markRaw + shallowRef
```
### Store 访问规则
```
组件内 (<script setup>) → useAppStore()
组件外 (hooks/utils/plugins) → useAppStoreWithOut()
解构保持响应式 → storeToRefs(store)
```
### 反模式 TOP 5
| # | 反模式 | 正确做法 |
|---|--------|---------|
| 1 | 解构 props → 丢失响应式 | `toRefs(props)` 或直接用 `props.xxx` |
| 2 | 组件外用 `useXxxStore()` | 用 `useXxxStoreWithOut()` |
| 3 | `ref` 用于动态组件/大对象 | 用 `shallowRef` |
| 4 | 混用 Options API + script setup | 只选一种风格 |
| 5 | 事件监听不清理 | `onUnmounted` 中移除 / 使用 VueUse 的 `useEventListener` |
### 依赖模式速查
| 场景 | 推荐模式 |
|------|---------|
| 父子组件通信 | Props + Emits |
| 兄弟功能交互 | 回调参数(`onXxxChange` |
| 跨层级多对多 | 事件总线(`useEmitt` |
| 共享状态 | Store 桥接组合式函数 |
| 功能编排 | 组合式函数编排模式 |
@@ -0,0 +1,6 @@
{
"ownerId": "kn73f0csgtdgbgjvxyzpvfp32h86bs77",
"slug": "vue-composition-api-best-practices",
"version": "1.0.0",
"publishedAt": 1778222320482
}
@@ -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 }
}
```
**特征**:维护全局状态,通常不需要组件级清理。
### 模式 2Store 桥接
用 composable 封装 store 访问,隐藏 store 内部实现细节。
```typescript
// hooks/web/usePageIcon.ts
import { computed } from 'vue'
import { useBusinessStoreWithOut } from '@/store/modules/business'
export function usePageIcon() {
const businessStore = useBusinessStoreWithOut()
const pageIcon = computed(() => businessStore.getPageIcon)
function addPageIcon(icon: IconItem) {
businessStore.addPageIcon(icon)
}
function removePageIcon(id: string) {
businessStore.removePageIcon(id)
}
return { pageIcon, addPageIcon, removePageIcon }
}
```
**特征**
- 使用 `useXxxStoreWithOut` 访问 store(因为 hook 可能在组件外使用)
- 对外暴露语义化接口,隐藏 store action 细节
- 不维护自身状态,仅转发 store 数据
**何时使用**:当多个组件需要以相同方式访问同一 store 数据时。
### 模式 3:生命周期感知
自动在组件卸载时清理副作用。
```typescript
// hooks/web/useEmitt.ts
import { onUnmounted } from 'vue'
import { mittBus } from '@/utils/mitt'
export function useEmitt() {
const listeners: Array<{ event: string; handler: (...args: any[]) => void }> = []
function on(event: string, handler: (...args: any[]) => void) {
mittBus.on(event, handler)
listeners.push({ event, handler })
}
function emit(event: string, ...args: any[]) {
mittBus.emit(event, ...args)
}
// 组件卸载时自动解绑所有通过此 hook 注册的事件
onUnmounted(() => {
listeners.forEach(({ event, handler }) => {
mittBus.off(event, handler)
})
listeners.length = 0
})
return { on, emit }
}
```
**特征**
- 使用 `onUnmounted` 自动清理
- 内部维护清理队列
- 防止内存泄漏
**何时使用**:涉及事件监听、定时器、DOM 事件等需要清理的副作用。
> 另见:[跨功能依赖 - 模式 4:事件总线模式](cross-feature-dependencies.md#模式-4事件总线模式适用于复杂的多对多场景) 了解此模式在跨组件通信中的实际应用。
### 模式 4:异步资源
封装异步资源加载,提供加载状态。
```typescript
// hooks/web/useLocalForage.ts
import { ref } from 'vue'
import localforage from 'localforage'
export function useLocalForage(storeName: string) {
const store = localforage.createInstance({ name: storeName })
const loading = ref(false)
async function getItem<T>(key: string): Promise<T | null> {
loading.value = true
try {
return await store.getItem<T>(key)
} finally {
loading.value = false
}
}
async function setItem<T>(key: string, value: T): Promise<void> {
loading.value = true
try {
await store.setItem(key, value)
} finally {
loading.value = false
}
}
return { loading, getItem, setItem }
}
```
**特征**
- 提供 `loading` 状态
- `try/finally` 保证状态重置
- 支持泛型返回值
**何时使用**:封装 IndexedDB、fetch、文件读取等异步操作。
### 模式 5:参数化工具
接收参数,返回计算结果或操作函数,不维护持久状态。
```typescript
// hooks/web/useDesign.ts
import { useAppStoreWithOut } from '@/store/modules/app'
export function useDesign(scope: string) {
const appStore = useAppStoreWithOut()
const prefixCls = computed(() => `${appStore.getPrefixCls}-${scope}`)
const variables = computed(() => ({
'--prefix-cls': prefixCls.value,
}))
return { prefixCls, variables }
}
```
```typescript
// hooks/event/useScrollTo.ts
export function useScrollTo() {
function scrollTo(target: HTMLElement, options?: ScrollToOptions) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start',
...options,
})
}
return { scrollTo }
}
```
**特征**
- 纯函数式,无副作用
- 参数决定返回值
- 不需要清理
**何时使用**:工具类逻辑,如样式计算、DOM 操作、格式化函数。
---
## 3. 参数设计原则
### 单一职责参数
```typescript
// ✅ GOOD: 每个参数职责明确
export function useLocalForage(storeName: string) { ... }
// ✅ GOOD: 可选参数用 Options 模式
export function useSuggestion(engine: string, options?: SuggestionOptions) { ... }
```
### Options 模式
当参数超过 2 个时,使用 Options 对象:
```typescript
interface SuggestionOptions {
timeout?: number
maxResults?: number
callbackName?: string
}
export function useSuggestion(engine: string, options?: SuggestionOptions) {
const { timeout = 5000, maxResults = 10, callbackName } = options ?? {}
// ...
}
```
---
## 4. 返回值设计原则
### 最小暴露原则
只返回外部真正需要的:
```typescript
// ✅ GOOD: 只暴露必要的接口
export function usePageIcon() {
const businessStore = useBusinessStoreWithOut()
const pageIcon = computed(() => businessStore.getPageIcon)
function addPageIcon(icon: IconItem) { businessStore.addPageIcon(icon) }
function removePageIcon(id: string) { businessStore.removePageIcon(id) }
return { pageIcon, addPageIcon, removePageIcon }
}
// ❌ BAD: 暴露了整个 store
export function usePageIcon() {
const businessStore = useBusinessStoreWithOut()
return { businessStore } // 调用方可随意修改 store
}
```
### Ref vs Computed
| 返回类型 | 使用场景 | 特征 |
|---------|---------|------|
| `computed` | 派生状态,依赖其他响应式源 | 只读,自动更新,有缓存 |
| `ref` | 独立状态 | 可读写 |
| `readonly(ref)` | 只读状态,内部可修改 | 防止外部篡改 |
```typescript
export function useSideCategory() {
// 派生自 store → computed
const categories = computed(() => businessStore.getSideCategory)
// 独立状态 → ref
const activeId = ref<string>('')
// 只读暴露 → readonly
const isEditing = ref(false)
const editingState = readonly(isEditing)
return { categories, activeId, editingState }
}
```
---
## 5. 类型设计原则
### 泛型约束
```typescript
// ✅ GOOD: 泛型支持不同数据类型
export function useLocalForage(storeName: string) {
async function getItem<T>(key: string): Promise<T | null> { ... }
async function setItem<T>(key: string, value: T): Promise<void> { ... }
return { getItem, setItem }
}
```
### 输入类型严格
```typescript
// ✅ GOOD: 参数类型精确
export function useEngine(engineType: SearchEngineType) { ... }
// ❌ BAD: 参数类型过于宽泛
export function useEngine(engineType: string) { ... }
```
### 返回类型推断
让 TypeScript 自动推断返回类型,除非需要导出:
```typescript
// 一般不需要显式声明返回类型
export function usePageIcon() {
// TypeScript 自动推断返回 { pageIcon: ComputedRef<...>, addPageIcon: (...) => void, ... }
return { pageIcon, addPageIcon, removePageIcon }
}
// 如果其他模块需要使用返回值类型,用 Extract 类型工具
export type PageIconReturn = ReturnType<typeof usePageIcon>
```
---
## 6. 错误处理原则
### 静默失败 vs 抛出异常
| 场景 | 策略 | 原因 |
|------|------|------|
| 数据获取 | 静默失败 + 降级 | 不应阻塞 UI |
| 关键操作 | 抛出异常 | 必须让调用方感知 |
| 生命周期清理 | 静默失败 | 卸载时不应抛错 |
```typescript
// 数据获取:静默失败 + 降级
export function useSuggestion(engine: string) {
const suggestions = ref<string[]>([])
async function fetchSuggestion(keyword: string) {
try {
suggestions.value = await doFetch(keyword)
} catch {
suggestions.value = [] // 降级为空列表
}
}
return { suggestions, fetchSuggestion }
}
// 关键操作:抛出异常
export function useBackup() {
async function exportData(): Promise<Blob> {
const data = await collectData()
if (!data) throw new Error('No data to export')
return packZip(data)
}
return { exportData }
}
```
---
## 7. Composable 与组件的边界
### 放在 Composable 中
- 可复用的状态逻辑
- 与特定 UI 无关的数据转换
- Store 访问桥接
- 浏览器 API 封装
### 放在组件中
- 模板渲染相关计算
- 仅当前组件使用的 UI 状态(如弹窗开关)
- DOM 直接操作(通过 ref
```typescript
// ✅ 放 composable:可复用的搜索引擎逻辑
// hooks/web/useEngine.ts
export function useEngine() {
const businessStore = useBusinessStoreWithOut()
const currentEngine = computed(() => businessStore.getSearchEngine)
function switchEngine() { ... }
return { currentEngine, switchEngine }
}
// ✅ 放组件:仅当前组件使用的弹窗状态
<script setup lang="ts">
const dialogVisible = ref(false)
const openDialog = () => (dialogVisible.value = true)
</script>
```
---
## 8. 完整示例:生产级 Composable
```typescript
// hooks/web/useSuggestion.ts
import { ref, onUnmounted } from 'vue'
import { useEngine } from './useEngine'
import { SUGGESTION_TIMEOUT } from '@/constants'
interface SuggestionOptions {
timeout?: number
maxResults?: number
}
export function useSuggestion(options?: SuggestionOptions) {
const { currentEngine } = useEngine()
const { timeout = SUGGESTION_TIMEOUT, maxResults = 10 } = options ?? {}
// 状态
const suggestions = ref<string[]>([])
const loading = ref(false)
// 清理:JSONP 脚本和超时定时器
let scriptEl: HTMLScriptElement | null = null
let timer: ReturnType<typeof setTimeout> | null = null
function cleanup() {
if (scriptEl) {
scriptEl.remove()
scriptEl = null
}
if (timer) {
clearTimeout(timer)
timer = null
}
}
async function fetch(keyword: string) {
if (!keyword.trim()) {
suggestions.value = []
return
}
cleanup()
loading.value = true
return new Promise<void>((resolve) => {
const callbackName = `suggestion_${Date.now()}`
// JSONP 回调
;(window as any)[callbackName] = (data: string[]) => {
suggestions.value = data.slice(0, maxResults)
loading.value = false
cleanup()
delete (window as any)[callbackName]
resolve()
}
// 超时处理
timer = setTimeout(() => {
suggestions.value = []
loading.value = false
cleanup()
delete (window as any)[callbackName]
resolve()
}, timeout)
// 注入脚本
const url = currentEngine.value.suggestionUrl(keyword, callbackName)
scriptEl = document.createElement('script')
scriptEl.src = url
document.head.appendChild(scriptEl)
})
}
// 自动清理
onUnmounted(cleanup)
return { suggestions, loading, fetch }
}
```
这个示例综合了多种模式:
- **Options 模式**:可配置超时和最大结果数
- **异步资源**loading 状态管理
- **生命周期感知**onUnmounted 自动清理
- **最小暴露**:只返回 suggestions、loading、fetch
- **错误处理**:超时降级为空列表
---
## 9. 测试 Composable
Composable 是纯函数(返回响应式状态 + 方法),非常适合单元测试。推荐使用 **Vitest + @vue/test-utils**
### 测试纯计算型 Composable
```typescript
// hooks/__tests__/useDesign.test.ts
import { describe, it, expect } from 'vitest'
import { useDesign } from '../web/useDesign'
describe('useDesign', () => {
it('should generate correct prefix class', () => {
const { getPrefixCls } = useDesign()
expect(getPrefixCls('layout')).toBe('mi-layout')
})
it('should expose namespace variables', () => {
const { variables, simplePrefixCls } = useDesign()
expect(variables.namespace).toBeDefined()
expect(simplePrefixCls).toBeDefined()
})
})
```
### 测试有状态 Composable
```typescript
// hooks/__tests__/useEngine.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEngine } from '../web/useEngine'
describe('useEngine', () => {
beforeEach(() => {
setActivePinia(createPinia()) // 每次测试前创建新的 pinia 实例
})
it('should return current search engine', () => {
const { selectEngine, engineInfo } = useEngine()
expect(selectEngine.value).toBe('baidu')
expect(engineInfo.value.label).toBe('百度')
})
it('should switch to next engine', () => {
const { selectEngine, nextEngine } = useEngine()
nextEngine()
expect(selectEngine.value).toBe('google')
})
it('should cycle back to first engine', () => {
const { selectEngine, updateSelectEngine, nextEngine } = useEngine()
// 切换到最后一个
updateSelectEngine('sogou')
nextEngine()
expect(selectEngine.value).toBe('baidu')
})
})
```
### 测试含生命周期的 Composable
```typescript
// hooks/__tests__/useNetwork.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useNetwork } from '../web/useNetwork'
describe('useNetwork', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('should reflect online status', async () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
const { isOnline } = useNetwork()
expect(isOnline.value).toBe(true)
})
it('should update when going offline', async () => {
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const { isOnline } = useNetwork()
expect(isOnline.value).toBe(false)
})
})
```
### 测试异步 Composable
```typescript
// hooks/__tests__/useLocalForage.test.ts
import { describe, it, expect } from 'vitest'
import { useLocalForage } from '../web/useLocalForage'
describe('useLocalForage', () => {
it('should get and set items', async () => {
const { setItem, getItem, loading } = useLocalForage('test')
await setItem('key1', { name: 'test' })
expect(loading.value).toBe(false)
const result = await getItem<{ name: string }>('key1')
expect(result?.name).toBe('test')
})
it('should handle missing items', async () => {
const { getItem } = useLocalForage('test')
const result = await getItem('nonexistent')
expect(result).toBeNull()
})
})
```
### 测试 Store Bridge Composable
Store Bridge 模式的 composable 测试关键是初始化 pinia
```typescript
// hooks/__tests__/usePageIcon.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePageIcon } from '../web/usePageIcon'
describe('usePageIcon', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should add page icon', () => {
const { addPageIcon, curPageIcons } = usePageIcon()
addPageIcon({
label: '测试',
url: 'https://example.com',
icon: 'test-icon',
iconType: 'online',
type: 'icon'
})
expect(curPageIcons.value.length).toBe(1)
expect(curPageIcons.value[0].label).toBe('测试')
})
})
```
### 测试原则
| 原则 | 说明 |
|------|------|
| 每个 test 独立 | 用 `beforeEach` + `setActivePinia(createPinia())` 重置状态 |
| 只测试公开接口 | 只测 `return` 的值和方法,不测内部实现 |
| Mock 副作用 | 网络请求、浏览器 API 用 `vi.spyOn` / `vi.mock` 隔离 |
| 覆盖边界情况 | 空输入、异常路径、极限值 |
| 测试异步行为 | 用 `async/await` + 断言 `loading` 状态变化 |
**目录结构建议:**
```
src/hooks/
├── web/
│ ├── __tests__/ # 测试文件目录
│ │ ├── useDesign.test.ts
│ │ ├── useEngine.test.ts
│ │ ├── useNetwork.test.ts
│ │ ├── usePageIcon.test.ts
│ │ └── useLocalForage.test.ts
│ ├── useDesign.ts
│ ├── useEngine.ts
│ └── ...
```
@@ -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()
})
```
### 模式 2Ref 注入模式
传递响应式 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()` 来解包 MaybeRefVue 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>
```
### useTemplateRefVue 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')` |
### useIdVue 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` | 可选 | 双向绑定 modelVue 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
// ✅ GOODhooks 中使用 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
// ✅ GOODutils 中使用 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)