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

Vue3项目里总觉得代码乱重复多?怎么封装实用又复用性强的Hooks?

terry 1小时前 阅读数 12 #Vue
文章标签 Vue3 Hooks封装复用

最近帮几个朋友改Vue3的老项目重构,发现一个挺普遍的坑:大家把Composition API用成了「大号setup函数」,全堆在一起不说,同一个功能比如“防抖输入监听搜索框+loading状态”,可能三五个组件都写了一遍,改个防抖时间得去改五处,累得慌还容易错,其实这时候只要用好Vue3的Hooks封装,问题就能迎刃而解,不过Hooks也不是随便写个函数就行的,得踩过不少坑才能摸到门道,今天就结合我自己写和重构的经验,聊聊怎么搞这个事儿。

首先要搞懂:Vue3 Hooks到底是啥?为啥能解决问题?

很多刚转Vue3的同学,可能觉得“Hooks就是把setup里的逻辑抽成函数”——对,但不全对,它更准确的定义应该是“复用有状态/有副作用逻辑的纯JavaScript/TypeScript函数”。 核心是两点:一是复用性,抽出来的东西,换个组件甚至换个项目都能用;二是保留响应式和Vue的生命周期能力,不像普通函数那样只是个计算工具,之前Vue2解决复用的方式是Mixin,但Mixin的问题太明显了:命名冲突不知道哪来的data/methods、来源不透明,重构起来找不到依赖、嵌套Mixin逻辑混乱像套娃,Hooks就完美避开了这些:每个Hooks的状态、方法都是独立的,你显式导入显式使用,来源清清楚楚,想怎么组合就怎么组合。

实用Vue3 Hooks封装的5个核心原则,别瞎封装反添麻烦

刚开始封装Hooks的人,很容易走向两个极端:要么啥都抽,连个简单的数字加减都要写个useCount;要么封装得太死,只能在某一个特定组件里用,换个场景就得重写,要避免这两个问题,得记住这5个原则:

原则1:只抽“跨组件复用的、带状态/副作用的”逻辑

简单说,先判断这个逻辑有没有跨至少两个组件用的可能,再看它有没有涉及Vue的ref/reactive/computed/watch/watchEffect/onMounted/onUnmounted这些东西,如果只是纯计算(比如把时间戳转成YYYY-MM-DD HH:mm:ss),抽成普通的utils函数就行,别硬套Hooks的壳子,反而增加开销。 举个例子:判断用户滚动到底部加载更多,这个功能列表页、搜索页、动态流可能都要用,而且涉及scroll事件监听(副作用)、loading/refreshing/hasMore这些响应式状态,那就适合抽成useInfiniteScroll;但单纯的把用户昵称首字母大写,就扔utils里。

原则2:Hooks的命名一定要有use前缀,语义化要清晰

Vue官方推荐所有Hooks都以use开头,这不是硬性要求,但这是行业约定俗成的规范,能让你一眼就看出这个函数是Hooks,避免和普通函数混淆,命名要尽可能具体,别太宽泛,比如useRequest就比useApi好,useClickOutside就比useDomClick好,语义化越强,别人拿到你的Hooks就越容易用,自己隔段时间看也不会忘。

原则3:向外暴露的东西越少越好,保持封装的“黑盒性”

这个原则很重要!很多人封装Hooks的时候,把内部的所有ref、reactive、中间状态都暴露出去了,导致组件可以随意修改内部状态,逻辑耦合反而更严重,后续想优化Hooks内部实现都不敢动,怕影响外面的组件。 正确的做法是:只暴露组件必须用到的东西,比如useInfiniteScroll只暴露hasMore、loading、startLoadMore、reset这四个状态/方法,内部的滚动监听的移除函数、上次滚动的距离、累计加载的页数这些,都放在Hooks内部,不让外面碰。

原则4:参数要灵活,支持配置化,不要写死

Hooks要复用,参数就得灵活,不能太死,比如useRequest,你不能只支持GET请求,要支持GET/POST/PUT/DELETE所有常见的请求方法;不能只支持手动触发,要支持自动触发(页面加载就请求);不能只支持固定的loading状态,要支持自定义loading的初始值;不能不支持错误处理,要暴露error状态和onError回调。 怎么做到参数灵活呢?可以把参数分成“必传参数”和“可选配置对象”,可选配置对象给默认值,比如useRequest的签名可以写成: function useRequest(fetcher, options = { immediate: false, initialData: null, initialLoading: false, onSuccess: () => {}, onError: () => {} }) { // 内部逻辑 }

原则5:一定要清理副作用,防止内存泄漏

这是很多新手最容易踩的坑!比如useInfiniteScroll里加了scroll事件监听,useLocalStorage里监听了storage事件,useTimeout里开了定时器,如果组件销毁的时候不清理这些副作用,就会造成内存泄漏,页面用久了会越来越卡,甚至崩溃。 Vue3提供了onUnmounted生命周期钩子,专门用来清理组件销毁前的副作用,而且这个钩子在Hooks里用效果最好——因为副作用是在Hooks里注册的,清理逻辑也应该放在Hooks里,保持逻辑的一致性,比如useTimeout里的清理逻辑: let timer = null onUnmounted(() => { if (timer) clearTimeout(timer) })

从零封装2个高频实用的Vue3 Hooks,看完就能用

光说原则太干了,现在我们来从零封装两个我平时用得最多的Hooks:一个是处理本地存储的useLocalStorage,一个是处理防抖/节流输入的useDebouncedRef/useThrottledRef,这两个Hooks几乎是每个Vue3项目都会用到的,你可以直接抄过去改改就能用。

H3:第一个:支持响应式、JSON序列化的useLocalStorage

普通的localStorage是同步的,而且只能存字符串,每次用都要JSON.parse、JSON.stringify,还要处理解析错误,很麻烦,我们封装的useLocalStorage,要支持直接存对象/数组,自动JSON序列化和反序列化,而且存储的值是响应式的,修改值会自动同步到localStorage,localStorage的值被其他标签页修改了,也会自动同步到组件里。

// src/hooks/useLocalStorage.ts
import { ref, watch, onUnmounted } from 'vue'
// 可选配置的类型定义,用TypeScript的话更安全,不用的话可以删掉
interface UseLocalStorageOptions<T> {
  defaultValue?: T
  serializer?: (value: T) => string
  deserializer?: (value: string) => T
}
export function useLocalStorage<T = any>(
  key: string,
  options: UseLocalStorageOptions<T> = {}
) {
  // 解构配置,给默认值
  const {
    defaultValue = null,
    serializer = JSON.stringify,
    deserializer = JSON.parse
  } = options
  // 初始化响应式数据
  const storedValue = ref<T | null>(null)
  // 从localStorage读取初始值,处理解析错误
  const init = () => {
    try {
      const rawValue = localStorage.getItem(key)
      storedValue.value = rawValue ? deserializer(rawValue) : defaultValue
    } catch (error) {
      console.warn(`Failed to parse localStorage value for key "${key}":`, error)
      storedValue.value = defaultValue
    }
  }
  // 监听存储的值变化,自动同步到localStorage
  const stopWatch = watch(
    storedValue,
    (newValue) => {
      if (newValue === null) {
        localStorage.removeItem(key)
      } else {
        try {
          localStorage.setItem(key, serializer(newValue))
        } catch (error) {
          console.warn(`Failed to set localStorage value for key "${key}":`, error)
        }
      }
    },
    { deep: true } // 监听对象/数组的深层变化
  )
  // 监听其他标签页修改localStorage的事件,自动同步到组件
  const handleStorageChange = (event: StorageEvent) => {
    if (event.key === key && event.newValue !== null) {
      try {
        storedValue.value = deserializer(event.newValue)
      } catch (error) {
        console.warn(`Failed to parse updated localStorage value for key "${key}":`, error)
      }
    } else if (event.key === key && event.newValue === null) {
      storedValue.value = defaultValue
    }
  }
  window.addEventListener('storage', handleStorageChange)
  // 组件销毁时清理副作用
  onUnmounted(() => {
    stopWatch()
    window.removeEventListener('storage', handleStorageChange)
  })
  // 初始化数据
  init()
  // 只暴露响应式值,其他都封装在内部
  return storedValue
}

H3:第二个:通用的useDebouncedRef和useThrottledRef

防抖和节流是前端最常用的性能优化手段,比如输入框搜索用防抖,滚动加载用节流,窗口大小变化用节流,我们封装的useDebouncedRef和useThrottledRef,是基于ref的,传入一个ref值和延迟时间,返回一个新的响应式ref值,只有当原始值在延迟时间内不再变化(防抖)或者每隔延迟时间(节流),新值才会更新。

// src/hooks/useDebounceThrottle.ts
import { ref, watch, onUnmounted } from 'vue'
// 防抖ref
export function useDebouncedRef<T = any>(
  value: T,
  delay: number = 300
) {
  const debouncedValue = ref<T>(value)
  let timer: ReturnType<typeof setTimeout> | null = null
  const stopWatch = watch(
    ref(value), // 注意这里如果传入的是普通值,要先转成ref;如果传入的已经是ref,直接用就行
    (newValue) => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        debouncedValue.value = newValue
      }, delay)
    },
    { deep: true }
  )
  onUnmounted(() => {
    stopWatch()
    if (timer) clearTimeout(timer)
  })
  return debouncedValue
}
// 不过上面的useDebouncedRef有个小问题:如果传入的是普通值,就不会响应变化,更好的做法是支持传入ref或者普通值:
export function useBetterDebouncedRef<T = any>(
  source: T | Ref<T>,
  delay: number = 300
) {
  const debouncedValue = ref<T>(unref(source))
  let timer: ReturnType<typeof setTimeout> | null = null
  const stopWatch = watch(
    () => unref(source),
    (newValue) => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        debouncedValue.value = newValue
      }, delay)
    },
    { deep: true }
  )
  onUnmounted(() => {
    stopWatch()
    if (timer) clearTimeout(timer)
  })
  return debouncedValue
}
// 节流ref
export function useThrottledRef<T = any>(
  source: T | Ref<T>,
  delay: number = 300
) {
  const throttledValue = ref<T>(unref(source))
  let lastTime = 0
  let timer: ReturnType<typeof setTimeout> | null = null
  const stopWatch = watch(
    () => unref(source),
    (newValue) => {
      const now = Date.now()
      if (now - lastTime >= delay) {
        throttledValue.value = newValue
        lastTime = now
      } else {
        if (timer) clearTimeout(timer)
        timer = setTimeout(() => {
          throttledValue.value = newValue
          lastTime = Date.now()
        }, delay - (now - lastTime))
      }
    },
    { deep: true }
  )
  onUnmounted(() => {
    stopWatch()
    if (timer) clearTimeout(timer)
  })
  return throttledValue
}

怎么测试自己封装的Hooks好不好用?

封装完Hooks之后,别着急用到项目里,先做个简单的测试,看看有没有问题:

  1. 复用性测试:把Hooks用到至少两个不同的组件里,看看能不能正常工作;
  2. 黑盒性测试:只暴露组件必须的东西,修改Hooks内部实现,看看会不会影响外面的组件;
  3. 灵活性测试:修改可选配置,看看能不能达到预期的效果;
  4. 内存泄漏测试:打开浏览器的开发者工具,切换到Memory标签页,点击“Take heap snapshot”,然后销毁使用了Hooks的组件,再点击“Take heap snapshot”,对比两个快照,看看有没有多余的内存占用;
  5. 边界情况测试:比如useLocalStorage测试JSON解析错误的情况,useBetterDebouncedRef测试传入普通值和ref值的情况,测试延迟时间为0的情况,测试传入undefined/null的情况。

最后给新手的几个小建议

  1. 先模仿,后创新:刚开始封装Hooks的时候,可以先看看VueUse这个库(这个库是目前最流行、最全面的Vue3 Hooks库,有超过200个实用的Hooks),看看别人是怎么封装的,然后再根据自己的项目需求,封装自己的Hooks;
  2. 用TypeScript:TypeScript能给Hooks提供类型检查,减少运行时错误,而且语义化更强,别人拿到你的Hooks就知道怎么用;
  3. 写注释:给Hooks写清楚功能说明、参数说明、返回值说明、注意事项,这样自己隔段时间看也不会忘,别人拿到你的Hooks也能快速上手;
  4. 定期重构Hooks:随着项目的发展,你的需求可能会变化,Hooks的实现也可能会过时,所以要定期重构Hooks,优化性能,提高灵活性。

Vue3 Hooks封装是一个需要不断实践、不断踩坑、不断优化的过程,只要你记住这5个核心原则,多模仿、多测试、多总结,你就能封装出实用又复用性强的Hooks,让你的Vue3项目代码更清晰、更易维护、更易扩展。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门