Code前端首页关于Code前端联系我们

Vue3组合式API Hooks怎么用才规范高效不踩坑?

terry 5小时前 阅读数 76 #Vue

很多刚从Vue2转Vue3或者初学Vue3的开发者,上手组合式API(Composition API)和Hooks的时候,要么把setup写得比Options API的data+methods还长,要么Hooks拆得太碎到处飞,要么犯一些比如闭包陷阱、响应式丢失的低级错误,最后写出来的代码反而更难维护了,这篇文章就用问答的形式,把大家最关心的、日常开发里最容易踩雷的点说透,还有一些实用的规范和技巧,帮你把Vue3 Hooks用得顺手。

什么情况下才需要抽离自定义Hooks?

很多开发者一开始会过度抽离Hooks,觉得每个逻辑块都要单独拆出去才是用对了组合式API,但其实过度抽离反而会增加项目的认知负担——比如你要找一个简单的表单输入逻辑,还要翻3个不同的Hooks文件,那到底什么时候抽离才合适呢?

逻辑被2个及以上组件复用的时候,这是最核心的场景,比如多个页面都用到的用户登录状态获取、表格的分页筛选查询、主题切换这些,抽成自定义Hooks是100%有必要的。

单个组件内逻辑块太大,超过30行代码,且逻辑独立的时候,抽离可以让setup函数更清晰,比如一个商品详情页,可能有“获取商品基础信息”“处理评论加载和提交”“管理购物车加购状态和弹窗”三个独立的大逻辑,这时候把每个逻辑拆成对应的Hooks,比如useProductDetail、useProductComments、useCartModal,setup里只需要调用这三个Hooks,拿到需要的响应式数据和方法,整个结构一目了然。

需要封装副作用和第三方库调用的时候,比如封装监听窗口滚动的逻辑、调用Axios的统一封装、使用ECharts绘制图表的逻辑,这些逻辑里有很多onMounted、onUnmounted、watchEffect这样的生命周期钩子或者副作用,抽成自定义Hooks可以让这些副作用集中管理,避免组件卸载时忘记清理导致内存泄漏。

自定义Hooks的命名有什么讲究?

命名规范看起来是小事,但如果团队里每个人都按自己的习惯来,时间久了维护起来会特别痛苦,这里给大家一个通用的、行业内也比较认可的命名规范:

自定义Hooks必须以"use"开头,这是Vue3官方推荐的,也是ESLint Vue3规则里默认要求的,比如useLocalStorage、useDebounce、useTable,这样一眼就能看出来这是一个自定义Hooks,不是普通的工具函数。

Hooks的命名要能体现它的功能,不能太模糊,比如不能叫useData,而要叫useProductData或者useUserData;不能叫useLogic,要叫useCommentSubmitLogic或者useThemeToggleLogic。

工具类自定义Hooks可以省略结尾的"Logic"或者"Hook",功能类、业务类的尽量加上明确的后缀,比如防抖节流、本地存储、窗口监听这些属于工具类,可以直接叫useDebounce、useLocalStorage、useWindowScroll;而像刚才提到的商品详情页的业务逻辑,叫useProductDetail就可以,提交评论的叫useCommentSubmit也没问题,加上"Logic"反而有点啰嗦,但如果有歧义的话还是加上更保险。

怎么避免自定义Hooks里的闭包陷阱?

闭包陷阱是Vue3 Hooks里最容易踩的坑之一,很多开发者甚至都不知道自己踩了,直到出现数据更新不及时、定时器里的值还是旧的这样的问题才慌。

先简单说一下什么是Vue3里的闭包陷阱:组合式API的setup函数是一次性执行的,当你在setup里或者自定义Hooks里定义了一个响应式变量,然后用这个变量的原始值(而不是响应式引用)作为参数传给了定时器、事件监听器、或者Promise的回调函数,这些回调函数会在闭包里保存变量的初始值,后面即使变量更新了,回调函数里拿到的还是旧值。

那怎么避免呢?

如果要在回调函数里使用响应式数据,优先用ref包裹或者用toRefs/toRef把reactive的属性转成ref,因为ref是一个对象,回调函数里保存的是这个对象的引用,值更新的时候引用不变,拿到的就是最新的。

举个例子,这是一个典型的闭包陷阱写法:

import { reactive, onMounted } from 'vue'
export function useTimer() {
  const state = reactive({ count: 0 })
  onMounted(() => {
    setInterval(() => {
      // 这里用了state.count的原始值,闭包保存的是初始值0
      console.log(state.count) // 注意!其实这个写法是对的!哦不对,我刚才说反了?等下,state是reactive,state.count如果是在同一个作用域里的函数直接访问state对象的属性,是能拿到最新值的,因为Proxy拦截的是属性访问,那什么时候会有闭包陷阱?
      // 改一下,把count单独拿出来作为原始值传递给外部函数
      const externalLog = (num) => console.log(num)
      externalLog(state.count) // 这里如果是作为参数传进去,外部函数的参数num是原始值,闭包保存的就是初始值0
      // 或者更常见的场景,把响应式变量的原始值赋值给了一个普通变量,然后在回调里用这个普通变量
      const currentCount = state.count
      setTimeout(() => {
        console.log(currentCount) // 这里拿到的就是初始值0,因为currentCount是普通变量,闭包保存的是它的初始值
      }, 1000)
    }, 1000)
  })
  return state
}

哦对,刚才差点举错例子,得准确,那这个例子里,有两个错误的地方:一个是把state.count作为原始值传给了externalLog(假设externalLog是异步调用的,比如在setTimeout里),另一个是把state.count赋值给了普通变量currentCount,然后在setTimeout里用currentCount。

正确的写法应该是:

import { reactive, toRef, onMounted } from 'vue'
export function useTimer() {
  const state = reactive({ count: 0 })
  const countRef = toRef(state, 'count') // 把state.count转成ref
  onMounted(() => {
    // 直接访问state对象的属性,没问题
    setInterval(() => {
      console.log(state.count)
    }, 1000)
    // 用ref的原始值?不,用ref.value
    const currentCountRef = countRef
    setTimeout(() => {
      console.log(currentCountRef.value) // 拿到的是最新值
    }, 1000)
  })
  return state
}

如果必须要在回调函数里使用普通变量,可以用watchEffect或者watch监听响应式数据的变化,每次变化时重新注册回调函数或者更新普通变量,不过这种方法不如直接用ref方便,尽量少用。

使用Vue3提供的生命周期钩子和watch/watchEffect的时候,要确保它们是在setup函数或者自定义Hooks的顶层调用的,不能放在条件语句、循环语句或者异步函数里,否则Vue无法正确追踪依赖和副作用,也可能导致闭包陷阱或者生命周期钩子失效。

自定义Hooks的返回值怎么设计才合理?

返回值的设计也是自定义Hooks里很重要的一点,设计得不好,组件里调用的时候会很麻烦,也容易出错。

这里有几个常用的返回值设计规范:

工具类自定义Hooks,尽量返回单个ref或者reactive对象,或者包含ref和方法的数组/对象,根据使用场景选择,比如useLocalStorage,如果只需要存一个值,可以返回[value, setValue]这样的数组,和React的useState类似,很多开发者习惯这种写法;如果需要存多个值,或者需要更多的方法(比如removeValue、clearAll),可以返回一个对象,包含value、setValue、removeValue、clearAll这些属性和方法。

功能类、业务类自定义Hooks,尽量返回对象,用解构赋值的方式在组件里调用,因为这类Hooks通常会返回多个响应式数据和方法,用对象解构的话可以按需获取,不需要按顺序记住返回值的位置,比数组更灵活,比如useProductDetail,可能返回product、loading、error、fetchProduct这些,用对象解构的话:

const { product, loading, error, fetchProduct } = useProductDetail()

如果以后要在Hooks里添加新的返回值,比如updateProduct,只需要在返回的对象里加进去就行,不会影响之前调用这个Hooks的组件;但如果用数组的话,添加新的返回值就要改变数组的顺序,之前调用的组件如果是按顺序解构的,就会出错。

如果自定义Hooks返回的对象里有很多属性和方法,可以考虑用ref和computed的自动解包特性,简化组件里的调用,比如在Vue3.2+里,如果你用