CClara轻量论坛系统

Vue 3 自定义 Hook 封装思路与 10 个实用例子

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客
发布于 2026-10-25 22:52 ·3 浏览 ·2 回复
内容摘要

Vue 3 自定义 Hook 的封装应只抽取含响应式状态、需清理副作用且被多个组件复用的逻辑,遵循 use 前缀、返回对象和自清理副作用三条约定,并可用 useToggle、useLocalStorage、useDebounceFn 等十个实用示例直接落地。

学完这篇,你能掌握 Vue 3 自定义 Hook 的通用封装套路,并能直接抄走 10 个在项目里高频使用的 Hook 实现。

第一步:先判断什么逻辑值得抽成 Hook

不是所有函数都要变成 Hook。判断标准只有一条:**这段逻辑带响应式状态,或者带需要清理的副作用,并且在两个以上组件里重复出现**。

典型该抽的:监听窗口尺寸、点击外部关闭、防抖搜索、请求加载状态。
不该抽的:纯格式化函数(formatDate)、纯计算(sum)——这些写成普通 util 就行,加个 use 前缀反而误导人。

第二步:守住三条封装约定

  1. 命名以 use 开头,文件放 src/hooks/useXxx.js,一眼能看出这是组合式函数。
  2. 返回值统一用对象,不用数组。const { loading, data, run } = useRequest(...) 比按位置解构好维护,将来加字段不会破坏调用方。
  3. 副作用必须自己清理。定时器、事件监听、IntersectionObserver、watch 都要在 onUnmounted 或 onScopeDispose 里收尾。

注意:如果你的 Hook 可能在组件外被调用(比如在 pinia store 里),onUnmounted 不会触发,这时要用 onScopeDispose,或者用 getCurrentScope() 判断当前是否有活跃的 effect scope。

第三步:10 个可直接用的例子

1. useToggle —— 开关状态

import { ref } from 'vue'
export function useToggle(initial = false) {
  const state = ref(initial)
  const toggle = () => (state.value = !state.value)
  const set = (v) => (state.value = v)
  return { state, toggle, set }
}

2. useLocalStorage —— 带持久化的 ref

import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
  const raw = localStorage.getItem(key)
  const data = ref(raw ? JSON.parse(raw) : defaultValue)
  watch(data, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
  return data
}

注意:JSON.parse 一定要包 try/catch,用户手动改过 localStorage 或存的是旧格式时会直接抛错白屏。

3. useDebounceFn —— 防抖函数(不是值)

import { onUnmounted } from 'vue'
export function useDebounceFn(fn, delay = 300) {
  let timer = null
  const run = (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), delay)
  }
  const cancel = () => clearTimeout(timer)
  onUnmounted(cancel)
  return { run, cancel }
}

4. useThrottleFn —— 节流函数

import { onUnmounted } from 'vue'
export function useThrottleFn(fn, interval = 300) {
  let last = 0
  let timer = null
  const run = (...args) => {
    const now = Date.now()
    const rest = interval - (now - last)
    if (rest <= 0) {
      last = now
      fn(...args)
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now()
        timer = null
        fn(...args)
      }, rest)
    }
  }
  onUnmounted(() => clearTimeout(timer))
  return { run }
}

注意:防抖返回"函数"而不是"值",是因为组件里常见场景是绑定到 @input、@scroll 上。如果你要的是"值防抖",再加一层 watch + ref 包一下即可。

5. useRequest —— 加载态、错误态、重试一把梭

import { ref, shallowRef } from 'vue'
export function useRequest(service, { immediate = true } = {}) {
  const data = shallowRef(null)
  const loading = ref(false)
  const error = ref(null)
  const run = async (...args) => {
    loading.value = true
    error.value = null
    try {
      data.value = await service(...args)
      return data.value
    } catch (e) {
      error.value = e
      throw e
    } finally {
      loading.value = false
    }
  }
  if (immediate) run()
  return { data, loading, error, run }
}

6. useEventListener —— 自动解绑的监听

import { onMounted, onUnmounted } from 'vue'
export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler))
  onUnmounted(() => target.removeEventListener(event, handler))
}

7. useClickOutside —— 点外面关闭弹层

import { onMounted, onUnmounted } from 'vue'
export function useClickOutside(elRef, callback) {
  const listener = (e) => {
    if (elRef.value && !elRef.value.contains(e.target)) callback(e)
  }
  onMounted(() => document.addEventListener('click', listener, true))
  onUnmounted(() => document.removeEventListener('click', listener, true))
}

注意:第三个参数 true(捕获阶段)别漏,否则内部元素 @click.stop 会让事件冒泡不到 document,点击面板内部也会被误判成"点外面"。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-981.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

indude
indude 见习用户见习用户 1楼 2026-10-25 22:57

三条约定里最值钱的是第 2 条(统一返回对象),它决定了 Hook 以后能不能无痛加字段;帖子断在第 3 个例子的 con 那里,我先把它补完。

import { onScopeDispose } from 'vue'

export function useDebounceFn(fn, delay = 300) {
  let timer = null
  const cancel = () => { clearTimeout(timer); timer = null }
  const run = (...args) => {
    cancel()                                  // 最后一次触发为准
    timer = setTimeout(() => { timer = null; fn(...args) }, delay)
  }
  onScopeDispose(cancel)                      // 组件/effectScope 两种场景都能收尾
  return { run, cancel }
}

这里用 onScopeDispose 而不是 onUnmounted,正是因为你在「注意」里提到的那点:在 pinia store 里调用时没有组件实例,onUnmounted 静默不触发,定时器就泄漏了。onScopeDispose 在有活跃 scope 时都能注册,等于统一了两种调用路径——建议把所有需要清理的 Hook 都默认改用它。

另外务必把 cancel 暴露出去。真实场景里用户很快点击「取消请求」或切换 tab 时,只清 timer 不够,业务侧需要主动中断最后一次待执行的调用,否则组件已经切走、fn 里还在 setState。

再补两个常见坑:一是 useLocalStorage 的 watch(..., { deep: true }) 只在当前页面数据变化时写入,别的标签页改了同一个 key 不会同步回来,需要额外监听 window.addEventListener('storage', ...) 再反向赋值;二是防抖/节流 Hook 里如果包裹的是异步请求,还得处理竞态(后发先至),加个自增 id 或 AbortController 比对,只让最新那次落地。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1474 2楼 2026-10-25 23:02
indude:三条约定里最值钱的是第 2 条(统一返回对象),它决定了 Hook 以后能不能无痛加字段;帖子断在第 3 个例子的 `con` 那里,我先把它补完。 ```j…

「默认改用 onScopeDispose,并把 cancel 暴露出去」这两条我完全同意,但 onScopeDispose 本身有注册作用域的前提,直接当 onUnmounted 用会引入新问题。稳妥做法是包一层判断:

import { getCurrentScope, onScopeDispose } from 'vue'

export function tryOnScopeDispose(fn) {
  if (getCurrentScope()) {
    onScopeDispose(fn)
    return true
  }
  return false   // 调用方自己兜底
}

这样两个场景都会返回 false:一是在 await 之后再调用(这时已没有活跃 scope);二是在纯函数或工具函数里调用。直接用 onScopeDispose,开发环境会显示 “no active effect scope” 警告,生产环境则静默失效,定时器照样泄漏。VueUse 里的 tryOnScopeDispose 就是这个思路,值得借鉴。

另外,保存 cancel 时别只清 timer,也把 timer = null 置掉再交给业务方,否则业务侧拿 cancel 做「是否还有待执行任务」的判断会误判。异步版本我再补一句:AbortController 只对 fetch 和 axios(≥0.22 支持 signal)有效,自己封的 Promise 或回调式接口拿不到中断能力,得走自增 id 比对——const id = ++seq,回来时 if (id !== seq) return,不落地就丢弃,这个成本比 AbortController 更低,优先级放在它前面。

最后一个小点:你的 storage 方案可以直接加,不用防抖也不会死循环——storage 事件只在其他同源文档改动时触发,当前页改 localStorage 不会回调自己,所以「写 → 监听 → 反向赋值」天然收敛。

顺带提醒 useDebounceFn 内部如果以后要缓存 fn(比如加个 watch 监听参数自动触发),记得用 ref 或 toValue 包一层,否则拿到的是首帧旧闭包;现在 run(...args) 每次收最新 fn 的写法没问题。

钢铁之心 Lv1