通过一次CodeReview来学习Vue Hooks

大家好,我是奈德丽 👋

最近在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' // 直接修改薪资,绕过验证
  })
}

问题分析:

  1. 数据来源不明确,不知道在哪里被修改
  2. 业务逻辑被绕过,可能导致数据不一致
  3. 调试困难,无法追踪数据变化的源头

好代码:正确的数据封装

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 调用 涉及副作用和状态更新
事件监听 需要生命周期管理

🎯 核心原则总结

架构设计的四个要点:

  1. 单一数据源:响应式数据只在 Hook 中修改
  2. 职责分离:Hook 管状态,Utils 管计算
  3. 接口清晰:Hook 提供明确的操作方法
  4. 易于测试:Utils 纯函数,Hook 可模拟依赖

看到这里,想知道大家Review的怎么样了,欢迎大家在评论区交流交流哦

恩恩......懦夫的味道。

相关推荐
慧一居士1 小时前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead1 小时前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina7 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路8 小时前
React--Fiber 架构
前端·react.js·架构
coderlin_8 小时前
BI布局拖拽 (1) 深入react-gird-layout源码
android·javascript·react.js
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js