大家好,我是奈德丽 👋
最近在Code review的时候,遇到了一个让人头疼的问题:团队新来的同事总是分不清什么时候该用 Hooks,什么时候该用 Utils,结果代码里到处都是响应式数据被随意修改的"灾难现场"。相信大家对hooks也早有所了解,但更想知道的是该如何运用Hooks,那么快加入进来,我们一起来CodeReview吧
🎯 问题的起源
小李在hooks中是定义了两个变量,全部人员和选中人员,再定义了一个选中的方法,在这看还没什么问题,且看他后面是如何操作的
javascript
// src/hooks/index.js
import { ref } from 'vue'
export const usePersonHook = () => {
const allPersons =ref ([])
const selectedPersons = ref([])
const addPerson = (person) => {
selectedPersons.value.push(person)
}
return {
allPersons, //全部人员
selectedPersons,//被选中的人员
addPerson
}
}
然后在组件中调用了这个hook,选中的时候却是直接操作hook中的响应式变量
javascript
// src/pages/index.vue - 某位同事写的人员选择逻辑
<template>
<div class="person-card">
<div class="card-info">
<span class="name">{{ person.name }}</span>
<span class="department">{{ person.department }}</span>
<span class="salary">{{ person.salary }}</span>
</div>
<div class="select-button" @click="handleSelect">
{{ $t('Select') }}
</div>
<button @click="delete"> {{ $t('Delete') }} </div>
</div>
</template>
<script setup>
import { usePersonHook } from '@/hooks'
import Decimal from 'decimal.js'
const { selectedPersons,allPersons } = usePersonHook()
// 🚨 直接在组件方法中修改了在hooks里面定义的变量
const handleSelect = () => {
selectedPersons.value = {
id: props.person.id,
name: props.person.name,
department: props.person.department,
salary: props.person.salary,
level: props.person.level
}
// 🚨 大错特错
const delete = (p)=>{
const idx = allPersons.value.findIndex(v=>v.id===p.id)
allPersons.value.splice(idx,1)
console.log('删除人员:', p)
}
</script>
看到这里的时候我的脸上已经是痛苦面具了,呜呜
在hooks里面定义的变量好好的,为什么要拿出来在其他地方直接去改啊,要更改的话可以在hooks里面写方法,然后在组件中去调用啊。
当时我心里就在想:毕竟是新同事,没关系,不会可以慢慢学
紧接着,我又发现了另一段代码,这次是另一个同事写的:
javascript
// src/stores/personStore.js - 另一位同事写的状态管理
import { defineStore } from 'pinia'
export const usePersonStore = defineStore('person', () => {
const selectedPersons = ref([])
const loading = ref(false)
// 🚨 在 Hook 里写计算逻辑!
const calculateTotalSalary = () => {
return selectedPersons.value.reduce((sum, person) => {
let salary = parseFloat(person.salary)
// 🚨 硬编码的业务规则散落在状态管理中
if (person.level === 'Senior') {
salary = salary * 1.3
} else if (person.level === 'Mid') {
salary = salary * 1.1
}
// 🚨 工龄奖金逻辑也混在这里
if (person.workYears > 3) {
salary *= 1.05
}
return sum + salary
}, 0)
}
const addPerson = (person) => {
// 🚨 缺少验证,直接推入数组
selectedPersons.value.push({
...person,
selectedAt: new Date()
})
}
return {
selectedPersons,
loading,
calculateTotalSalary,
addPerson
}
})
看到这两段代码,我意识到问题比想象的严重:
- 第一位同事:把计算逻辑写在组件里,还直接修改Hook数据
- 第二位同事:把业务计算逻辑全塞进Hook里,职责不清
于是我花了点时间整理了一下 Hooks 与 Utils 的最佳实践。
🔍 核心差异:状态 vs 计算
Hooks:我是状态管家
Hooks 适合处理的场景:
- 管理响应式状态
- 处理生命周期逻辑
- 跨组件共享状态
- 处理副作用(API、事件监听等)
来看一个实际的人员管理例子:
javascript
// composables/usePersonSelection.js
import { ref, computed, onMounted } from 'vue'
import { calculateTotalSalary, validatePersonSelection } from '@/utils/personUtils'
export function usePersonSelection() {
const selectedPersons = ref([])
const loading = ref(false)
const error = ref(null)
// 🎯 计算属性:基于状态的衍生数据
const totalSalary = computed(() => {
return calculateTotalSalary(selectedPersons.value) // 使用 Utils 计算
})
const isValidSelection = computed(() =>
validatePersonSelection(selectedPersons.value) // 使用 Utils 验证
)
// 🎯 状态操作:封装业务逻辑
const addPerson = async (person) => {
loading.value = true
error.value = null
try {
// 业务规则检查
if (selectedPersons.value.some(item => item.id === person.id)) {
throw new Error('该人员已选择')
}
// 调用 API 验证人员状态
const response = await api.validatePerson(person.id)
if (!response.available) {
throw new Error('该人员已被其他项目占用')
}
selectedPersons.value.push({
...person,
selectedAt: new Date(),
validated: true
})
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
const removePerson = (personId) => {
const index = selectedPersons.value.findIndex(person => person.id === personId)
if (index > -1) {
selectedPersons.value.splice(index, 1)
}
}
const clearSelection = () => {
selectedPersons.value = []
}
// 🎯 生命周期:自动恢复选择状态
onMounted(() => {
const savedSelection = localStorage.getItem('selectedPersons')
if (savedSelection) {
selectedPersons.value = JSON.parse(savedSelection)
}
})
return {
// 只读状态,防止外部随意修改
selectedPersons: readonly(selectedPersons),
loading: readonly(loading),
error: readonly(error),
totalSalary,
isValidSelection,
// 操作方法
addPerson,
removePerson,
clearSelection
}
}
Utils:我是纯函数工具人
Utils 适合处理的场景:
- 纯函数计算
- 数据格式化转换
- 独立的业务逻辑
- 可以单独测试的工具函数
Tips: 以下仅为示例,在实际的项目中,对于金额的处理一般需要用到 Decimal. js 来做精度处理
javascript
// utils/personUtils.js
// 🎯 格式化:纯函数,无副作用
export function formatPersonTitle(person) {
if (!person) return '未知人员'
const levelMap = {
'Junior': '初级',
'Mid': '中级',
'Senior': '高级',
'Lead': '资深'
}
const level = levelMap[person.level] || '员工'
return `${person.department} · ${level} · ${person.name}`
}
// 🎯 验证:输入输出明确
export function validatePersonSelection(persons) {
if (!Array.isArray(persons) || persons.length === 0) {
return { valid: false, message: '请至少选择一个人员' }
}
// 检查是否有重复选择
const uniqueIds = new Set(persons.map(person => person.id))
if (uniqueIds.size !== persons.length) {
return { valid: false, message: '不能重复选择同一人员' }
}
// 检查薪资总额是否合理
const totalSalary = calculateTotalSalary(persons)
if (totalSalary > 500000) { // 假设预算限额
return { valid: false, message: '薪资总额超出预算限制' }
}
return { valid: true, message: '人员选择有效' }
}
// 🎯 计算:可以独立测试
export function calculateTotalSalary(persons) {
if (!Array.isArray(persons)) return 0
return persons.reduce((total, person) => {
let salary = parseFloat(person.salary || 0)
// 根据级别计算薪资
switch (person.level) {
case 'Senior':
salary *= 1.3 // 高级 30% 加薪
break
case 'Mid':
salary *= 1.1 // 中级 10% 加薪
break
case 'Junior':
default:
// 初级无加薪
break
}
// 根据工作年限计算奖金
if (person.workYears >= 5) {
salary *= 1.1 // 5年以上 10% 奖金
} else if (person.workYears >= 3) {
salary *= 1.05 // 3-5年 5% 奖金
}
return total + salary
}, 0)
}
// 🎯 数据处理:复杂业务逻辑的拆分
export function filterAndSortPersons(persons, filters = {}) {
let result = [...persons]
// 按薪资范围过滤
if (filters.minSalary !== undefined) {
result = result.filter(person => parseFloat(person.salary) >= filters.minSalary)
}
if (filters.maxSalary !== undefined) {
result = result.filter(person => parseFloat(person.salary) <= filters.maxSalary)
}
// 按部门过滤
if (filters.departments && filters.departments.length > 0) {
result = result.filter(person => filters.departments.includes(person.department))
}
// 按级别过滤
if (filters.levels && filters.levels.length > 0) {
result = result.filter(person => filters.levels.includes(person.level))
}
// 按关键词过滤
if (filters.keyword) {
const keyword = filters.keyword.toLowerCase()
result = result.filter(person =>
person.name.toLowerCase().includes(keyword) ||
person.department.toLowerCase().includes(keyword)
)
}
// 排序
if (filters.sortBy) {
result.sort((a, b) => {
let aValue = a[filters.sortBy]
let bValue = b[filters.sortBy]
if (filters.sortBy === 'salary') {
aValue = parseFloat(aValue)
bValue = parseFloat(bValue)
}
if (filters.sortOrder === 'desc') {
return bValue > aValue ? 1 : -1
}
return aValue > bValue ? 1 : -1
})
}
return result
}
// 🎯 格式化货币显示
export function formatSalary(amount, currencyCode = 'CNY') {
const formatter = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 0,
maximumFractionDigits: 2
})
return formatter.format(amount)
}
// 🎯 计算工龄奖金
export function calculateSeniorityBonus(baseSalary, workYears) {
let bonus = 0
if (workYears >= 10) {
bonus = baseSalary * 0.2 // 10年以上 20% 奖金
} else if (workYears >= 5) {
bonus = baseSalary * 0.1 // 5-10年 10% 奖金
} else if (workYears >= 3) {
bonus = baseSalary * 0.05 // 3-5年 5% 奖金
}
return {
baseSalary,
bonus,
totalSalary: baseSalary + bonus,
bonusRate: (bonus / baseSalary * 100).toFixed(1) + '%'
}
}
💥 来看一组坏代码和好代码
坏代码:混乱的数据修改
javascript
// ❌ 错误示例:数据管理混乱
export function usePersonSelection() {
const selectedPersons = ref([])
const addPerson = (person) => selectedPersons.value.push(person)
return { selectedPersons, addPerson } // 直接暴露可写的 ref
}
// PersonCard.vue 组件中各种花式破坏
const { selectedPersons, addPerson } = usePersonSelection()
// 🚨 绕过 Hook 逻辑,直接修改
const clearAll = () => {
selectedPersons.value = [] // 破坏了数据一致性
}
const hackSalary = () => {
selectedPersons.value.forEach(person => {
person.salary = '999999' // 直接修改薪资,绕过验证
})
}
问题分析:
- 数据来源不明确,不知道在哪里被修改
- 业务逻辑被绕过,可能导致数据不一致
- 调试困难,无法追踪数据变化的源头
好代码:正确的数据封装
javascript
// ✅ 正确示例:严格的数据边界
export function usePersonSelection() {
const selectedPersons = ref([])
const loading = ref(false)
// 🎯 所有状态修改都在 Hook 内部完成
const addPerson = async (person) => {
loading.value = true
try {
// 数据验证
const validation = validatePersonSelection([...selectedPersons.value, person])
if (!validation.valid) {
throw new Error(validation.message)
}
// 薪资计算
const processedPerson = {
...person,
finalSalary: calculateTotalSalary([person]),
addedAt: new Date()
}
selectedPersons.value.push(processedPerson)
// 持久化存储
localStorage.setItem('selectedPersons', JSON.stringify(selectedPersons.value))
} catch (error) {
console.error('添加人员失败:', error.message)
throw error
} finally {
loading.value = false
}
}
const removePerson = (personId) => {
const index = selectedPersons.value.findIndex(person => person.id === personId)
if (index > -1) {
selectedPersons.value.splice(index, 1)
localStorage.setItem('selectedPersons', JSON.stringify(selectedPersons.value))
}
}
const clearPersons = () => {
selectedPersons.value = []
localStorage.removeItem('selectedPersons')
}
const totalSalary = computed(() =>
calculateTotalSalary(selectedPersons.value)
)
return {
// 🎯 只读状态,外部无法直接修改
selectedPersons: readonly(selectedPersons),
loading: readonly(loading),
totalSalary,
// 🎯 明确的操作接口
addPerson,
removePerson,
clearPersons
}
}
🚀 实战架构设计
1. 清晰的数据流
javascript
// ✅ 最佳实践:职责分离
// utils/personUtils.js - 纯函数工具层
export function processPersonData(persons, filters) {
return filterAndSortPersons(persons, filters)
.map(person => ({
...person,
displayTitle: formatPersonTitle(person),
formattedSalary: formatSalary(person.salary)
}))
}
// composables/usePersons.js - 状态管理层
export function usePersons() {
const persons = ref([])
const filters = ref({})
const loading = ref(false)
// 🎯 计算属性:结合 Hook 状态 + Utils 计算
const processedPersons = computed(() => {
return processPersonData(persons.value, filters.value)
})
// 🎯 状态修改的唯一入口
const setFilters = (newFilters) => {
filters.value = { ...filters.value, ...newFilters }
}
const fetchPersons = async (searchParams) => {
loading.value = true
try {
const response = await api.searchPersons(searchParams)
persons.value = response.data
} finally {
loading.value = false
}
}
return {
persons: readonly(persons),
processedPersons,
loading: readonly(loading),
setFilters,
fetchPersons
}
}
// PersonCard.vue - 视图层
<script setup>
const { processedPersons, loading, setFilters, fetchPersons } = usePersons()
// 🎯 只处理用户交互和视图渲染
const handleFilterChange = (newFilters) => {
setFilters(newFilters)
}
const handlePersonSelect = (person) => {
// 触发选择逻辑,不直接修改数据
emit('person-selected', person)
}
</script>
2. 通用异步数据处理
javascript
// 🎯 封装通用的异步操作逻辑
export function useAsyncData(fetchFn) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
const execute = async (...args) => {
loading.value = true
error.value = null
try {
const result = await fetchFn(...args)
data.value = result
return result
} catch (err) {
error.value = err
console.error('useAsyncData error:', err)
throw err // 让组件决定如何处理错误
} finally {
loading.value = false
}
}
return {
data: readonly(data),
error: readonly(error),
loading: readonly(loading),
execute
}
}
// 使用示例
const { data: personList, loading, execute: fetchPersons } = useAsyncData(api.getPersons)
📊 使用场景对比表
功能场景 | Hooks 适用 | Utils 适用 | 原因 |
---|---|---|---|
人员选择状态 | ✅ | ❌ | 需要响应式数据和生命周期 |
薪资计算 | ❌ | ✅ | 纯函数,无副作用 |
数据格式化 | ❌ | ✅ | 纯函数,无副作用 |
表单验证 | ❌ | ✅ | 独立逻辑,易于测试 |
API 调用 | ✅ | ❌ | 涉及副作用和状态更新 |
事件监听 | ✅ | ❌ | 需要生命周期管理 |
🎯 核心原则总结
架构设计的四个要点:
- 单一数据源:响应式数据只在 Hook 中修改
- 职责分离:Hook 管状态,Utils 管计算
- 接口清晰:Hook 提供明确的操作方法
- 易于测试:Utils 纯函数,Hook 可模拟依赖
看到这里,想知道大家Review的怎么样了,欢迎大家在评论区交流交流哦
恩恩......懦夫的味道。