通过一次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的怎么样了,欢迎大家在评论区交流交流哦

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

相关推荐
喝拿铁写前端7 分钟前
前端 Emoji 注释规范实践:VSCode 插件 Emoji 注释增强器分享
前端·开源·代码规范
石小石Orz1 小时前
如何将本地文件转成流数据传递给后端?
前端·vue.js
RPGMZ2 小时前
RPGMZ游戏引擎之如何设计每小时开启一次的副本
javascript·游戏·游戏引擎·rpgmz
RPGMZ2 小时前
RPGMZ游戏引擎 如何手动控制文字显示速度
开发语言·javascript·游戏引擎·rpgmz
Codebee2 小时前
OneCode核心概念解析——View(视图)
前端·人工智能
GIS之路2 小时前
GIS 数据质检:验证 Geometry 有效性
前端
GIS之路2 小时前
GeoJSON 数据简介
前端
今阳2 小时前
鸿蒙开发笔记-16-应用间跳转
android·前端·harmonyos
前端小饭桌2 小时前
CSS属性值太多记不住?一招教你搞定
前端·css
快起来别睡了2 小时前
深入浏览器底层原理:从输入URL到页面显示全过程解析
前端·架构