Vue.js从零到精通系列(六):组合式函数与逻辑复用——打造自己的 Hooks 工具箱

摘要: 经过前几篇的学习,你已经掌握了 Vue 3 的组件、路由和状态管理三大支柱。但你是否注意到,不同组件中常常会出现相似的逻辑------获取鼠标位置、发起异步请求、处理表单校验,这些代码如果每次都要重写,既枯燥又容易出错。Vue 3 的组合式函数(Composables) 正是为解决逻辑复用而生的。它将可复用的状态逻辑抽离成独立函数,让你像搭积木一样在组件中组装功能。本文将先带你理解"为什么要抽出组合式函数",然后从最简单的 useMouseuseFetch 等实例入手,逐步掌握组合式函数的编写规范、响应式数据暴露、生命周期集成以及与 Pinia Store 的分工协作。最后我们会将 Todo 应用中与数据无关的交互逻辑(如按钮倒计时、输入防抖、本地持久化)抽离成组合式函数,让代码更清晰、更可测试。


一、逻辑复用的前世今生

1.1 Vue 2 时代的"混入"(Mixin)与局限

在 Vue 2 中,如果要复用一段组件逻辑,通常会使用混入(Mixin)。一个 Mixin 对象可以包含 data、methods、生命周期钩子等,组件引入后会自动与自身合并。但混入有三大痛点:

  • 命名冲突:如果组件和 Mixin 有同名属性,合并规则不直观,容易产生 bug。

  • 来源不明:使用多个 Mixin 后,很难知道某个方法或数据来自哪一个 Mixin,调试困难。

  • 类型推导差:TypeScript 对 Mixin 的支持有限,自动补全几乎不可用。

1.2 React Hooks 的启发

React 在 16.8 版本推出了 Hooks,允许在函数组件中"钩入"状态和生命周期。这种模式让逻辑复用成为可能------你可以编写自定义 Hook(如 useWindowSizeuseFetch),并在不同组件间共享。

Vue 3 的组合式 API 在设计之初就吸收了这一思想,推出了组合式函数(Composables) 。它们是一类以 use 开头命名的函数,内部可以使用 refreactivecomputedwatch、生命周期钩子等所有 Vue 响应式能力,并且可以返回响应式状态和方法给组件。

1.3 组合式函数 vs Pinia Store

你可能已经在上一篇学习了 Pinia,它也是逻辑和状态共享的一种方式。那么,何时用组合式函数,何时用 Pinia Store?

场景 组合式函数 Pinia Store
跨组件/页面共享全局状态 不太适合(除非配合 provide/inject) ✅ 推荐
与特定组件实例绑定的逻辑(局部状态) ✅ 完美 ❌ 太重
需要持久化或 Devtools 调试的状态 ❌ 需手动实现 ✅ 内置支持
纯逻辑复用(不涉及全局状态) ✅ 推荐 ❌ 可能过度
多个独立组件实例需拥有各自的状态副本 ✅ 每次调用创建新实例 ❌ 默认单例(除非使用工厂)

简单记忆:如果一段逻辑是"这个组件自己的事",就用组合式函数;如果一段逻辑需要"整个应用都知道",就用 Pinia


二、编写第一个组合式函数:useMouse

我们来写一个追踪鼠标位置的组合式函数,体验逻辑抽离的好处。

2.1 基本实现

新建 src/composables/useMouse.ts

TypeScript 复制代码
// src/composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
​
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
​
  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }
​
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
​
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
​
  return { x, y }
}

2.2 在组件中使用

任何组件都可以引入并使用它,且每个组件调用都会创建独立的响应式引用(示例使用App.vue):

TypeScript 复制代码
<script setup lang="ts">
import { useMouse } from './composables/useMouse'
​
const { x, y } = useMouse()
</script>
​
<template>
  <p>鼠标位置:{{ x }}, {{ y }}</p>
</template>

模板中 xy 会随着鼠标移动实时更新。组件销毁时,事件监听自动移除,没有内存泄漏。

2.3 加入配置选项

让组合式函数更灵活,可以接收参数。例如,我们想限制只在某个元素内追踪鼠标:

TypeScript 复制代码
// src/composables/useMouse.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue'

export function useMouse(target?: Ref<HTMLElement | null> | HTMLElement) {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    const el = target instanceof HTMLElement ? target : target?.value
    if (el) {
      el.addEventListener('mousemove', update)
    } else {
      window.addEventListener('mousemove', update)
    }
  })

  onUnmounted(() => {
    const el = target instanceof HTMLElement ? target : target?.value
    if (el) {
      el.removeEventListener('mousemove', update)
    } else {
      window.removeEventListener('mousemove', update)
    }
  })

  return { x, y }
}

现在你可以传入一个模板引用:

TypeScript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { useMouse } from './composables/useMouse'

const box = ref<HTMLElement | null>(null)
const { x, y } = useMouse(box)
</script>

<template>
  <div ref="box" class="mouse-area">
    在这个区域内移动鼠标:{{ x }}, {{ y }}
  </div>
</template>

<style scoped>
.mouse-area {
  width: 300px;
  height: 200px;
  border: 2px solid #42b883;
}
</style>

浏览器中显示鼠标位置坐标的截图,限制在一个框内


三、异步数据请求:useFetch 组合式函数

在 Vue 中,数据请求通常与组件生命周期绑定。我们写一个通用的 useFetch,把请求状态、错误处理、响应数据都封装起来。

3.1 实现 useFetch

TypeScript 复制代码
// src/composables/useFetch.ts
import { ref, type Ref, type UnwrapRef } from 'vue'
interface UseFetchReturn<T> {
  data: Ref<UnwrapRef<T> | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
  execute: (url?: string) => Promise<void>
}
export function useFetch<T = unknown>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const loading = ref(false)
​
  async function execute(fetchUrl?: string) {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(fetchUrl || url)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json() as T
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }
  // 立即执行
  execute()
  return { data, error, loading, execute }
}

这个函数接收一个 URL 作为默认地址,自动发起请求。同时返回 execute 方法,供手动重新请求。

3.2 在组件中使用

假设我们有一个展示用户信息的组件,请求一个 JSON API:

TypeScript 复制代码
<script setup lang="ts">
import { useFetch } from './composables/useFetch'
interface User {
  name: string
  email: string
}
const { data: user, error, loading } = useFetch<User>('https://jsonplaceholder.typicode.com/users/1')
</script>
​
<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">出错了:{{ error.message }}</div>
  <div v-else-if="user">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="execute()">刷新</button>
  </div>
</template>

通过泛型 useFetch<User>,TypeScript 会推断 data 的类型为 User | null,你在模板中使用 user.name 时能获得自动补全和类型检查。

3.3 使用 watchEffect 改进"自动追踪"

useFetch 可以设计成响应式 URL------当 URL 变化时自动重新请求。我们利用 watchEffectwatch 来实现:

TypeScript 复制代码
import { ref, watchEffect, type Ref } from 'vue'
export function useFetch<T = unknown>(url: Ref<string> | string) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  async function doFetch() {
    const currentUrl = typeof url === 'string' ? url : url.value
    loading.value = true
    error.value = null
    try {
      const response = await fetch(currentUrl)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
    } finally {
      loading.value = false
    }
  }
  if (typeof url === 'string') {
    doFetch()
  } else {
    watchEffect(doFetch)   // url.value 变化时自动重新请求
  }
  return { data, error, loading }
}

现在你可以传入一个 ref<string>,URL 变化时自动拉取新数据------非常适合依赖动态路由参数的数据请求(比如从 route.params.id 构造的 URL)。


四、表单校验逻辑复用:useFormValidation

表单校验是每个前端应用的必需品。我们可以把校验规则和错误状态抽离成组合式函数。

4.1 设计思路

一个典型的表单校验组合式函数需要:

  • 接受一个包含字段值的响应式对象(或 refs)。

  • 接受校验规则(函数或对象)。

  • 返回错误信息对象、整体是否通过、手动触发校验等方法。

4.2 实现 useFormValidation

TypeScript 复制代码
// src/composables/useFormValidation.ts
import { reactive, computed, type ComputedRef } from 'vue'
type ValidationRules<T> = {
  [K in keyof T]?: (value: T[K], form: T) => string | true
}
interface UseFormValidationReturn<T> {
  errors: Record<keyof T, string | null>
  isValid: ComputedRef<boolean>
  validate: () => boolean
  validateField: (field: keyof T) => boolean
  resetErrors: () => void
}
export function useFormValidation<T extends Record<string, any>>(
  form: T,
  rules: ValidationRules<T>
): UseFormValidationReturn<T> {
  const errors = reactive<Record<keyof T, string | null>>(
    Object.keys(form).reduce((acc, key) => {
      acc[key as keyof T] = null
      return acc
    }, {} as Record<keyof T, string | null>)
  )
  function validateField(field: keyof T): boolean {
    const rule = rules[field]
    if (rule) {
      const result = rule(form[field], form)
      if (result === true) {
        errors[field] = null
        return true
      } else {
        errors[field] = result
        return false
      }
    }
    errors[field] = null
    return true
  }
  function validate(): boolean {
    let valid = true
    for (const field in rules) {
      if (!validateField(field)) {
        valid = false
      }
    }
    return valid
  }
  const isValid = computed(() => {
    return Object.values(errors).every(err => err === null)
  })
  function resetErrors() {
    Object.keys(errors).forEach(key => {
      errors[key as keyof T] = null
    })
  }
  return { errors, isValid, validate, validateField, resetErrors }
}

4.3 在组件中使用

以一个登录表单为例:

TypeScript 复制代码
<script setup lang="ts">
import { reactive } from 'vue'
import { useFormValidation } from './composables/useFormValidation'
const form = reactive({
  username: '',
  email: ''
})
const rules = {
  username: (val: string) => val.trim() ? true : '用户名不能为空',
  email: (val: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) ? true : '请输入有效的邮箱'
}
const { errors, isValid, validate } = useFormValidation(form, rules)
function submit() {
  if (validate()) {
    alert('提交成功!')
  }
}
</script>
​
<template>
  <form @submit.prevent="submit">
    <div>
      <input v-model="form.username" placeholder="用户名" />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
    <div>
      <input v-model="form.email" placeholder="邮箱" />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
    <button type="submit" :disabled="!isValid">提交</button>
  </form>
</template>

所有校验逻辑都从组件中移除了,组件只负责渲染和触发校验,非常干净。而且 useFormValidation 可以被任何表单复用,无论字段如何变化。


五、为 Todo 应用抽离组合式函数

我们的 Todo 应用目前逻辑主要存放在 Pinia Store 中,但有一些与全局状态无关的 UI 交互细节,非常适合抽离成组合式函数。

5.1 输入防抖:useDebounce

Todo 的输入我们直接用 @keyup.enter 触发,但有时你可能想做一个搜索框,需要防抖。写一个通用的 useDebounce

TypeScript 复制代码
// src/composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(value: Ref<T>, delay: number = 300) {
  const debouncedValue = ref(value.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout>
  watch(value, (newVal) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debouncedValue.value = newVal
    }, delay)
  })
  return { debouncedValue }
}

5.2 操作节流:useThrottle

对于按钮点击,可以用节流防止重复提交:

TypeScript 复制代码
// src/composables/useThrottle.ts
export function useThrottle(fn: (...args: any[]) => void, delay: number = 1000) {
  let lastTime = 0
  return function (this: any, ...args: any[]) {
    const now = Date.now()
    if (now - lastTime >= delay) {
      lastTime = now
      fn.apply(this, args)
    }
  }
}

5.3 在 TodoInput 中使用防抖(示例)

如果你希望用户输入时能实时显示搜索建议,但不想频繁更新,可以这样:

TypeScript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { useDebounce } from './composables/useDebounce'
const text = ref('')
const { debouncedValue } = useDebounce(text, 300)
// 用 watch 监听 debouncedValue 发出请求
watch(debouncedValue, (val) => {
  // 发送搜索请求
})
</script>

这样实际处理的 debouncedValue 会在用户停止输入 300 毫秒后更新,减少不必要的网络请求。

5.4 把本地存储操作封装成 useLocalStorage

在上一篇我们用了持久化插件,但有时候你可能想自己管理本地存储,提供更灵活的控制。可以写一个 useLocalStorage

TypeScript 复制代码
// src/composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
  watch(data, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })
  function remove() {
    localStorage.removeItem(key)
    data.value = defaultValue
  }
  return { data, remove }
}

现在你可以在任何地方创建独立的本地持久化状态,而不需要整个 Store 的粒度。


六、组合式函数的最佳实践

6.1 命名规范

  • 函数名以 use 开头,这是 Vue 社区的约定,也便于 ESLint 识别。

  • 返回的对象属性建议使用 refreactive,以便在组件中解构时保持响应性。

6.2 副作用清理

在组合式函数中使用 watchwatchEffectonMountedonUnmounted 等时,一定要注意在组件销毁时清理副作用,避免内存泄漏。例如,addEventListener 必须在 onUnmounted 中移除;定时器需要用 clearInterval

6.3 函数签名与 TypeScript

  • 为参数和返回值提供明确的类型,尤其是泛型,这样使用者在调用时能获得良好的智能提示。

  • 如果返回值是 refreactive,TypeScript 可以自动推导,但建议显式声明接口(如 UseFetchReturn<T>),增强可读性。

6.4 组合式函数与 Pinia 配合

你可以在 Pinia Store 的 Action 中调用组合式函数,也可以在组合式函数中使用 Pinia Store。例如,一个"用户偏好"的组合式函数可能会把结果同步到 Store 中。确保不要形成循环依赖即可。

6.5 避免在组合式函数中直接修改 DOM

组合式函数应该保持纯净,通过返回响应式数据来影响视图。直接操作 DOM 会破坏组件与组合逻辑的边界。如果确实需要,应使用 template ref 传递进来,如之前 useMouse 接收目标元素。


七、综合案例:重构 Todo 应用的部分逻辑

我们以 Todo 应用为例,将一些已有的逻辑迁移到组合式函数中,体验代码清晰度的提升。

7.1 需求分析

在 Todo 应用中,TodoInput 组件可能会增加一个"定时添加"按钮:点击后倒计时 5 秒,才真正添加任务。这个倒计时逻辑和 Todo 业务本身无关,可以抽离成一个 useCountdown 组合式函数。

7.2 实现 useCountdown

TypeScript 复制代码
import { ref, onUnmounted } from 'vue'
export function useCountdown(duration: number = 5, finishCb?: () => void) {
  const countdown = ref(0)
  let timer: ReturnType<typeof setInterval> | null = null

  function start() {
    if (countdown.value > 0) return
    countdown.value = duration
    timer = setInterval(() => {
      countdown.value--
      if (countdown.value <= 0) {
        clearInterval(timer!)
        timer = null
        // 倒计时结束执行回调
        finishCb?.()
      }
    }, 1000)
  }

  function stop() {
    if (timer) {
      clearInterval(timer)
      timer = null
    }
    countdown.value = 0
  }

  onUnmounted(stop)
  return { countdown, start, stop }
}

7.3 在 TodoInput 中使用

修改 TodoInput.vue

TypeScript 复制代码
<script setup lang="ts">
import { ref } from 'vue'
import { useCountdown } from '../composables/useCountdown'
const text = ref('')
const emit = defineEmits<{ (e: 'add', val: string): void }>()

function submit() {
  const val = text.value.trim()
  if (!val) return
  emit('add', val)
  text.value = ''
}

// 倒计时结束自动执行submit
const { countdown, start } = useCountdown(5, submit)

function delayedAdd() {
  const val = text.value.trim()
  if (!val || countdown.value > 0) return
  start()
}
</script>

<template>
  <div class="todo-input">
    <input v-model="text" @keyup.enter="submit" placeholder="输入新任务,回车添加" />
    <button @click="submit">添加</button>
    <button @click="delayedAdd" :disabled="countdown > 0">
      {{ countdown > 0 ? `${countdown}秒后添加` : '5秒后添加' }}
    </button>
  </div>
</template>

<style scoped>
.todo-input {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}
input {
  flex: 1;
  padding: 12px 16px;
  border: 1px solid #e2e8f0;
  border-radius: 10px;
  outline: none;
}
input:focus {
  border-color: #4299e1;
  box-shadow: 0 0 0 3px rgba(66,153,225,0.15);
}
button {
  padding: 12px 20px;
  background: #4299e1;
  color: #fff;
  border: none;
  border-radius: 10px;
  cursor: pointer;
}
button:hover {
  background: #3182ce;
}
button:disabled {
  background: #a0aec0;
  cursor: not-allowed;
}
</style>

你可以看到,倒计时的状态管理、定时器清理全部由 useCountdown 完成,组件内只关心何时触发开始和显示倒计时。而且这个 useCountdown 可以用于任何需要倒计时的场景(发送验证码、延迟操作等)。

7.4 抽离后的代码对比

重构前,你可能会在组件内写:

TypeScript 复制代码
const timer = ref(0)
let intervalId: number
function startCountdown() { ... }
onUnmounted(() => clearInterval(intervalId))

这会让组件脚本块变得杂乱。抽离后组件职责更纯粹,可读性更好,而且可以在多个组件间复用。


八、总结

组合式函数是 Vue 3 组合式 API 的精髓之一。它不仅解决了 Mixin 时代逻辑复用的痛点,还让你能够以更细粒度的方式组织代码。我们学习了如何从具体业务中识别可复用逻辑,并将其封装成 useXxx 函数,这些函数可以独立于组件进行测试和维护。

  • 组合式函数是使用 refcomputedwatch 和生命周期钩子的普通函数,命名以 use 开头。

  • 它与 Pinia Store 互补:全局共享状态用 Pinia,组件内逻辑复用用组合式函数。

  • 我们实现了 useMouseuseFetchuseFormValidationuseCountdown 等常见工具,掌握了编写组合式函数的基本模式。

  • 通过将 Todo 应用中的倒计时逻辑抽离,你亲身体验了逻辑分离带来的简洁性和可维护性提升。

  • 最佳实践:注意副作用清理、提供明确 TypeScript 类型、保持函数纯净。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
雨翼轻尘1 小时前
03_HTML进阶标签与CSS入门
前端·css·html·入门·进阶标签
IT_陈寒1 小时前
Java的ArrayList扩容把我坑惨了,原来是这样搞的
前端·人工智能·后端
snow@li1 小时前
Charles:软件能力深度解析 / 跨平台 HTTP/HTTPS 代理调试工具 / 客户端与互联网之间的中间人代理 / 拦截、查看、篡改所有网络流量
前端
Pearson1 小时前
特大pdf文件在线预览技术方案
javascript·nginx·pdf
UXbot1 小时前
移动端UI设计工具选型指南:iOS与Android设计标准支持对比
android·前端·低代码·ios·交互·团队开发·ui设计
GuWen_yue1 小时前
吃透二叉树与递归!60分钟掌握树结构核心+解题思路
javascript·算法
去码头整点薯条ing1 小时前
某红书笔记接口逆向【x-s参数】
javascript·爬虫·python
weixin_li152********1 小时前
《Angular 中优雅地处理枚举值:Map + *ngIf as 替代多次 *ngIf》
javascript·vue.js·angular.js