ae39e45460
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)
394 lines
11 KiB
Markdown
394 lines
11 KiB
Markdown
---
|
|
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)
|