1. Vue3 Hooks 规范与最佳实践
作为一名长期奋战在一线的 Vue 开发者,我深刻体会到合理组织 hooks 代码对项目可维护性的重要性。今天就来聊聊 Vue3 项目中 hooks 文件夹的组织规范,这些都是我在多个商业项目中总结出的实战经验。
2. Hooks 文件类型规划
2.1 适合放入 hooks 的文件类型
在 Vue3 项目中,hooks 文件夹应该成为你组织业务逻辑的核心阵地。根据我的经验,以下四类功能最适合封装为 hooks:
-
通用工具型 hooks
- 本地存储操作(localStorage/sessionStorage)
- 防抖/节流函数
- 日期时间格式化
- 浏览器特性检测
-
业务逻辑型 hooks
- 用户认证状态管理
- 购物车操作
- 表单验证逻辑
- 数据列表分页加载
-
UI 交互型 hooks
- 滚动位置监听
- 窗口尺寸变化响应
- 鼠标拖拽行为
- 键盘快捷键处理
-
API 请求型 hooks
- 通用数据请求封装
- WebSocket 连接管理
- 文件上传下载
- 长轮询实现
2.2 不适合放入 hooks 的内容
在实践中,我见过不少团队把各种内容都往 hooks 里塞,这会导致项目结构混乱。以下内容应该放在更合适的位置:
- Vue 组件:应该放在 components 目录
- 纯工具函数:如数学计算、字符串处理等应放在 utils
- 常量定义:config 或 constants 目录更合适
- 样式文件:styles 或 assets 目录是更好的选择
提示:一个简单的判断标准是 - 如果代码不依赖 Vue 的响应式系统,就不应该放在 hooks 中。
3. Hooks 命名规范详解
3.1 基础命名规则
良好的命名规范能让项目可读性大幅提升。这是我们团队遵循的 hooks 命名标准:
- 必须使用 use 前缀:这是 Vue 社区的约定俗成
- 采用驼峰命名法:如 useDarkMode 而非 use-dark-mode
- 语义化命名:名称应准确描述功能
- 单一职责原则:一个文件只解决一个问题
3.2 命名示例对比
| 不良命名 | 推荐命名 | 改进原因 |
|---|---|---|
| useAuth | useUserAuth | 更明确表达是用户认证 |
| useData | useProductList | 具体说明是商品列表 |
| useFn | useDebounce | 避免缩写,明确功能 |
3.3 复杂场景命名策略
对于大型项目,可以考虑增加分类前缀:
bash复制hooks/
├── useAuth/
│ ├── useLogin.js
│ └── usePermission.js
├── useUI/
│ ├── useModal.js
│ └── useToast.js
这种结构适合 hooks 数量超过 20 个的中大型项目。
4. Hooks 目录结构设计
4.1 基础目录结构
这是我在多个项目中验证过的目录结构方案:
bash复制src/
├── hooks/ # hooks 根目录
│ ├── core/ # 核心基础 hooks
│ │ ├── useStorage.js
│ │ └── useRequest.js
│ ├── features/ # 功能型 hooks
│ │ ├── useUser.js
│ │ └── useCart.js
│ └── ui/ # UI 交互型 hooks
│ ├── useScroll.js
│ └── useResize.js
├── components/
├── views/
└── App.vue
4.2 按业务模块划分
对于业务复杂的项目,可以按业务域组织:
bash复制hooks/
├── product/
│ ├── useProductList.js
│ └── useProductDetail.js
├── order/
│ ├── useOrderSubmit.js
│ └── usePayment.js
└── member/
├── useProfile.js
└── useAddress.js
5. Hooks 编写与使用示例
5.1 本地存储封装进阶版
这是我优化过的 useStorage 实现,增加了更多实用功能:
javascript复制// hooks/core/useStorage.js
import { ref, watch, computed } from 'vue'
export function useLocalStorage(key, defaultValue, options = {}) {
const {
expires, // 过期时间(毫秒)
encrypt = false, // 是否加密
deepWatch = true // 是否深度监听
} = options
// 读取存储值
const readValue = () => {
try {
let rawValue = localStorage.getItem(key)
if (!rawValue) return defaultValue
// 解密处理
if (encrypt) {
rawValue = decrypt(rawValue) // 假设有解密函数
}
const data = JSON.parse(rawValue)
// 检查过期时间
if (expires && data?.expiresAt && Date.now() > data.expiresAt) {
removeValue()
return defaultValue
}
return data.value ?? defaultValue
} catch (error) {
console.error(`读取 localStorage[${key}] 失败:`, error)
return defaultValue
}
}
// 当前值
const value = ref(readValue())
// 计算属性:是否已过期
const isExpired = computed(() => {
return expires && value.value?.expiresAt
? Date.now() > value.value.expiresAt
: false
})
// 保存值
const saveValue = (newValue) => {
try {
let dataToSave = { value: newValue }
// 设置过期时间
if (expires) {
dataToSave.expiresAt = Date.now() + expires
}
let stringified = JSON.stringify(dataToSave)
// 加密处理
if (encrypt) {
stringified = encrypt(stringified) // 假设有加密函数
}
localStorage.setItem(key, stringified)
} catch (error) {
console.error(`保存 localStorage[${key}] 失败:`, error)
}
}
// 移除值
const removeValue = () => {
localStorage.removeItem(key)
value.value = defaultValue
}
// 自动同步变化
watch(
value,
(newVal) => saveValue(newVal),
{ deep: deepWatch }
)
return {
value,
isExpired,
remove: removeValue
}
}
使用示例:
javascript复制// 在组件中使用
const { value: userPref, isExpired } = useLocalStorage(
'user_preferences',
{ theme: 'light' },
{
expires: 30 * 24 * 60 * 60 * 1000, // 30天过期
encrypt: true // 加密存储
}
)
5.2 业务型 Hook 最佳实践
以用户认证为例,展示如何编写健壮的 useAuth hook:
javascript复制// hooks/features/useAuth.js
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useLocalStorage } from '@/hooks/core/useStorage'
import { login, logout, getUserInfo } from '@/api/auth'
export function useAuth() {
const router = useRouter()
// 使用我们封装的 useLocalStorage
const { value: token, remove: removeToken } = useLocalStorage(
'auth_token',
null,
{ encrypt: true }
)
const user = ref(null)
const loading = ref(false)
const error = ref(null)
// 计算属性:是否已登录
const isAuthenticated = computed(() => !!token.value)
// 登录方法
const loginUser = async (credentials) => {
loading.value = true
error.value = null
try {
const res = await login(credentials)
if (res.success) {
token.value = res.data.token
await fetchUser()
router.push('/dashboard')
} else {
error.value = res.message || '登录失败'
}
} catch (err) {
error.value = err.message || '网络错误'
} finally {
loading.value = false
}
}
// 获取用户信息
const fetchUser = async () => {
if (!token.value) return
try {
const res = await getUserInfo()
user.value = res.data
} catch (err) {
console.error('获取用户信息失败:', err)
// token 可能失效,执行登出
logoutUser()
}
}
// 登出方法
const logoutUser = async () => {
try {
await logout()
} finally {
removeToken()
user.value = null
router.push('/login')
}
}
return {
user,
token,
isAuthenticated,
loading,
error,
login: loginUser,
logout: logoutUser,
fetchUser
}
}
组件中使用:
javascript复制<script setup>
import { useAuth } from '@/hooks/features/useAuth'
const {
user,
isAuthenticated,
loading,
error,
login,
logout
} = useAuth()
// 登录表单提交
const handleSubmit = async () => {
await login({
username: 'admin',
password: '123456'
})
if (isAuthenticated.value) {
console.log('登录成功!')
}
}
</script>
5.3 高阶请求 Hook 实现
这是我为项目封装的增强版 useRequest:
javascript复制// hooks/core/useRequest.js
import { ref, onBeforeUnmount } from 'vue'
export function useRequest(apiFn, options = {}) {
const {
immediate = false,
initialData = null,
debounce = 0,
retry = 0
} = options
const data = ref(initialData)
const error = ref(null)
const loading = ref(false)
const timestamp = ref(0)
const retryCount = ref(0)
let debounceTimer = null
let abortController = null
// 取消当前请求
const cancelRequest = () => {
if (abortController) {
abortController.abort()
abortController = null
}
loading.value = false
}
// 执行请求
const execute = async (...args) => {
// 取消之前的请求
cancelRequest()
// 设置新的 AbortController
abortController = new AbortController()
// 防抖处理
if (debounce > 0) {
clearTimeout(debounceTimer)
return new Promise((resolve) => {
debounceTimer = setTimeout(async () => {
resolve(await doRequest(...args))
}, debounce)
})
}
return doRequest(...args)
}
// 实际请求逻辑
const doRequest = async (...args) => {
const currentTimestamp = Date.now()
timestamp.value = currentTimestamp
loading.value = true
error.value = null
try {
const response = await apiFn(...args, {
signal: abortController?.signal
})
// 确保是最新的请求结果
if (timestamp.value === currentTimestamp) {
data.value = response.data
retryCount.value = 0
return response
}
} catch (err) {
// 如果是取消请求,不处理错误
if (err.name === 'AbortError') return
error.value = err
// 重试逻辑
if (retry > 0 && retryCount.value < retry) {
retryCount.value++
return new Promise((resolve) => {
setTimeout(() => {
resolve(doRequest(...args))
}, 1000 * retryCount.value) // 指数退避
})
}
throw err
} finally {
if (timestamp.value === currentTimestamp) {
loading.value = false
}
}
}
// 立即执行
if (immediate) {
execute()
}
// 组件卸载时取消请求
onBeforeUnmount(() => {
cancelRequest()
if (debounceTimer) {
clearTimeout(debounceTimer)
}
})
return {
data,
error,
loading,
execute,
cancel: cancelRequest,
retryCount,
timestamp
}
}
使用示例:
javascript复制// 在组件中使用
const {
data: products,
loading,
error,
execute: fetchProducts
} = useRequest(
(page, size) => api.getProducts({ page, size }),
{
immediate: true,
initialData: [],
debounce: 300,
retry: 3
}
)
// 分页变化时重新请求
const handlePageChange = (page) => {
fetchProducts(page, 10)
}
6. 高级技巧与最佳实践
6.1 Hooks 组合使用
强大的 hooks 可以相互组合使用,例如:
javascript复制// hooks/features/useCart.js
import { useRequest } from '@/hooks/core/useRequest'
import { useLocalStorage } from '@/hooks/core/useStorage'
import { computed } from 'vue'
export function useCart() {
// 使用 useRequest 处理 API 请求
const {
data: cartItems,
loading,
execute: fetchCart
} = useRequest(api.getCart, { immediate: true })
// 使用 useLocalStorage 保存最近浏览的商品
const { value: recentViewed } = useLocalStorage(
'recent_viewed',
[],
{ expires: 7 * 24 * 60 * 60 * 1000 } // 7天过期
)
// 计算总价
const totalPrice = computed(() => {
return cartItems.value?.reduce((sum, item) => {
return sum + (item.price * item.quantity)
}, 0) || 0
})
// 添加商品到购物车
const addToCart = async (productId, quantity = 1) => {
await api.addToCart({ productId, quantity })
await fetchCart() // 刷新购物车数据
}
return {
cartItems,
loading,
totalPrice,
recentViewed,
addToCart,
refresh: fetchCart
}
}
6.2 性能优化技巧
- 惰性加载 hooks:对于不常用的 hooks,可以动态导入
javascript复制const useHeavyHook = () => import('@/hooks/features/useHeavyHook')
// 在需要时使用
const handleClick = async () => {
const { default: useHeavyHook } = await useHeavyHook()
const { run } = useHeavyHook()
run()
}
- 记忆化计算:对于耗时的计算,使用 computed 或 memoize
javascript复制import { computed } from 'vue'
import { memoize } from 'lodash-es'
export function useProductStats(products) {
// 使用 computed 自动缓存
const totalValue = computed(() => {
return products.value.reduce((sum, p) => sum + p.price, 0)
})
// 对于纯函数可以使用 memoize
const getCategoryStats = memoize((category) => {
return products.value
.filter(p => p.category === category)
.reduce((stats, p) => {
stats.count++
stats.total += p.price
return stats
}, { count: 0, total: 0 })
})
return { totalValue, getCategoryStats }
}
6.3 TypeScript 增强支持
对于使用 TypeScript 的项目,为 hooks 添加类型定义可以大幅提升开发体验:
typescript复制// hooks/core/useStorage.ts
import { ref, watch, computed, Ref } from 'vue'
interface StorageOptions<T> {
expires?: number
encrypt?: boolean
deepWatch?: boolean
validator?: (value: T) => boolean
}
export function useLocalStorage<T>(
key: string,
defaultValue: T,
options?: StorageOptions<T>
): {
value: Ref<T>
isExpired: Ref<boolean>
remove: () => void
} {
// 实现逻辑...
}
// hooks/features/useAuth.ts
interface User {
id: string
name: string
email: string
roles: string[]
}
interface AuthResult {
user: Ref<User | null>
token: Ref<string | null>
isAuthenticated: Ref<boolean>
loading: Ref<boolean>
error: Ref<Error | null>
login: (credentials: { username: string; password: string }) => Promise<void>
logout: () => Promise<void>
fetchUser: () => Promise<void>
}
export function useAuth(): AuthResult {
// 实现逻辑...
}
7. 常见问题与解决方案
7.1 响应式丢失问题
问题描述:在 hooks 中解构响应式对象时,可能会丢失响应性
javascript复制// 错误示例
const { x, y } = useMousePosition() // 解构后 x, y 不再是响应式的
// 正确做法
const mouse = useMousePosition()
// 在模板中使用 mouse.x 和 mouse.y
解决方案:
- 返回 reactive 对象而不是解构
- 使用 toRefs 保持响应性
javascript复制export function useMousePosition() {
const pos = reactive({ x: 0, y: 0 })
const update = (e) => {
pos.x = e.pageX
pos.y = e.pageY
}
// 方法1:直接返回 reactive
// return pos
// 方法2:返回 toRefs
return {
...toRefs(pos),
update
}
}
7.2 内存泄漏问题
问题描述:在 hooks 中添加了全局事件监听器,但组件卸载时未清除
javascript复制// 有内存泄漏风险的实现
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
window.addEventListener('resize', () => {
width.value = window.innerWidth
height.value = window.innerHeight
})
return { width, height }
}
解决方案:使用 onBeforeUnmount 清理副作用
javascript复制export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const handler = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
window.addEventListener('resize', handler)
onBeforeUnmount(() => {
window.removeEventListener('resize', handler)
})
return { width, height }
}
7.3 服务端渲染 (SSR) 兼容性
问题描述:在 SSR 环境下,window 或 document 等浏览器 API 不可用
javascript复制// 有 SSR 问题的实现
export function useLocalStorage() {
// 直接使用 localStorage 会在 SSR 报错
const value = localStorage.getItem('key')
// ...
}
解决方案:添加环境判断
javascript复制import { inBrowser } from 'vitepress' // 或自定义判断
export function useLocalStorage() {
const value = ref(null)
if (inBrowser) {
// 只在浏览器环境执行
value.value = localStorage.getItem('key')
onMounted(() => {
const handler = () => { /*...*/ }
window.addEventListener('resize', handler)
onBeforeUnmount(() => {
window.removeEventListener('resize', handler)
})
})
}
return { value }
}
8. 测试策略与技巧
8.1 单元测试 hooks
使用 Vue Test Utils 或 Vitest 测试 hooks:
javascript复制// tests/hooks/useCounter.spec.js
import { renderHook } from '@testing-library/vue'
import { useCounter } from '@/hooks/useCounter'
describe('useCounter', () => {
it('should increment count', async () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count.value).toBe(0)
await result.current.increment()
expect(result.current.count.value).toBe(1)
})
it('should reset count', async () => {
const { result } = renderHook(() => useCounter(5))
expect(result.current.count.value).toBe(5)
await result.current.reset()
expect(result.current.count.value).toBe(0)
})
})
8.2 测试异步 hooks
测试包含异步操作的 hooks:
javascript复制// tests/hooks/useRequest.spec.js
import { renderHook } from '@testing-library/vue'
import { useRequest } from '@/hooks/useRequest'
describe('useRequest', () => {
it('should handle successful request', async () => {
const mockApi = jest.fn().mockResolvedValue({ data: 'success' })
const { result, waitForNextUpdate } = renderHook(() => useRequest(mockApi, { immediate: true }))
expect(result.current.loading.value).toBe(true)
await waitForNextUpdate()
expect(result.current.loading.value).toBe(false)
expect(result.current.data.value).toBe('success')
expect(result.current.error.value).toBeNull()
})
it('should handle request error', async () => {
const mockError = new Error('Request failed')
const mockApi = jest.fn().mockRejectedValue(mockError)
const { result, waitForNextUpdate } = renderHook(() => useRequest(mockApi, { immediate: true }))
await waitForNextUpdate()
expect(result.current.loading.value).toBe(false)
expect(result.current.error.value).toBe(mockError)
})
})
8.3 测试带有生命周期的 hooks
javascript复制// tests/hooks/useEventListener.spec.js
import { renderHook } from '@testing-library/vue'
import { useEventListener } from '@/hooks/useEventListener'
describe('useEventListener', () => {
it('should add and remove event listener', async () => {
const mockHandler = jest.fn()
const mockEvent = new Event('resize')
const { unmount } = renderHook(() => {
useEventListener(window, 'resize', mockHandler)
})
// 触发事件
window.dispatchEvent(mockEvent)
expect(mockHandler).toHaveBeenCalledTimes(1)
// 卸载组件
unmount()
// 再次触发事件,handler 不应被调用
window.dispatchEvent(mockEvent)
expect(mockHandler).toHaveBeenCalledTimes(1)
})
})
9. 项目实战经验分享
9.1 大型项目 hooks 组织方案
在中大型项目中,我推荐采用以下组织结构:
bash复制src/
├── hooks/
│ ├── core/ # 核心基础 hooks
│ │ ├── useStorage/
│ │ │ ├── index.js # 统一出口
│ │ │ ├── local.js # localStorage
│ │ │ └── session.js
│ │ ├── useRequest/
│ │ │ ├── index.js
│ │ │ ├── base.js # 基础请求
│ │ │ └── pagination.js
│ ├── modules/ # 按业务模块划分
│ │ ├── auth/
│ │ │ ├── useLogin.js
│ │ │ └── usePermission.js
│ │ ├── product/
│ │ │ ├── useProductList.js
│ │ │ └── useProductDetail.js
│ └── shared/ # 跨模块共享 hooks
│ ├── useSearch.js
│ └── useAnalytics.js
9.2 团队协作规范
为了保持团队代码一致性,我们制定了以下规范:
-
命名一致性:
- 所有 hooks 必须以 use 开头
- 业务 hooks 使用 use[模块名][功能名] 格式
- 通用 hooks 使用 use[功能名] 格式
-
文档要求:
- 每个 hooks 文件顶部必须有 JSDoc 注释
- 复杂逻辑需要添加内联注释
- 维护 hooks 使用示例文档
-
代码审查重点:
- 是否处理了所有边界情况
- 是否清理了所有副作用
- 是否有性能优化空间
- 类型定义是否完善(TS项目)
9.3 性能优化实战
在电商项目中,我们通过优化 hooks 实现了显著性能提升:
-
减少不必要的响应式:
javascript复制// 优化前 - 整个配置对象都是响应式的 const config = reactive({ apiUrl: '...', maxRetry: 3, timeout: 5000 }) // 优化后 - 只有需要变化的值是响应式的 const apiUrl = ref('...') const maxRetry = 3 // 常量不需要响应式 const timeout = 5000 -
批量更新策略:
javascript复制export function useBulkUpdate() { const updates = ref([]) const isUpdating = ref(false) const addUpdate = (item) => { updates.value.push(item) // 批量处理,避免频繁触发更新 if (!isUpdating.value) { isUpdating.value = true nextTick(() => { processUpdates(updates.value) updates.value = [] isUpdating.value = false }) } } return { addUpdate } } -
虚拟滚动优化:
javascript复制export function useVirtualScroll(items, itemHeight, containerRef) { const visibleCount = ref(0) const startIndex = ref(0) const endIndex = ref(0) const paddingTop = ref(0) const paddingBottom = ref(0) const updateVisibleRange = () => { if (!containerRef.value) return const { scrollTop, clientHeight } = containerRef.value visibleCount.value = Math.ceil(clientHeight / itemHeight) + 2 startIndex.value = Math.floor(scrollTop / itemHeight) endIndex.value = startIndex.value + visibleCount.value paddingTop.value = startIndex.value * itemHeight paddingBottom.value = Math.max( 0, (items.value.length - endIndex.value) * itemHeight ) } onMounted(() => { updateVisibleRange() window.addEventListener('resize', updateVisibleRange) }) onBeforeUnmount(() => { window.removeEventListener('resize', updateVisibleRange) }) const visibleItems = computed(() => { return items.value.slice( startIndex.value, Math.min(endIndex.value, items.value.length) ) }) return { visibleItems, paddingTop, paddingBottom, update: updateVisibleRange } }
10. 未来演进与思考
随着 Vue 3.3+ 版本推出,hooks 开发模式有了更多可能性:
-
组合式函数增强:
- 更好的 TypeScript 支持
- 更简洁的响应式语法
- 改进的 ref 解构体验
-
状态管理集成:
javascript复制// 与 Pinia 集成示例 export function useUserStore() { const store = useStore() // 将 Pinia 的 state 转换为 composable const user = computed(() => store.user) const isAdmin = computed(() => store.user.role === 'admin') const login = async (credentials) => { await store.login(credentials) } return { user, isAdmin, login } } -
跨框架复用:
javascript复制// 设计跨 Vue/React 的通用逻辑层 function useCounterLogic(initialValue = 0) { const count = ref(initialValue) const increment = () => { count.value++ } return { count, increment } } // Vue 适配层 export function useCounter(initialValue) { const logic = useCounterLogic(initialValue) return { count: logic.count, increment: logic.increment, double: computed(() => logic.count.value * 2) } }
在实际项目中,我发现合理组织的 hooks 可以带来以下收益:
- 业务逻辑复用率提升 40% 以上
- 组件代码量减少 30-50%
- 单元测试覆盖率显著提高
- 新成员上手速度加快
最后分享一个心得:好的 hooks 设计应该像乐高积木一样,每个部分功能单一但可以灵活组合。当你在多个项目中复用同一个 hooks 而几乎不需要修改时,你就真正掌握了组合式 API 的精髓。