Vue 3 组合式函数实战:Composables 封装与逻辑复用指南

Vue 3 组合式函数实战:Composables 封装与逻辑复用指南

admin
2026-07-08 / 0 评论 / 6 阅读

组合式函数(Composables)是 Vue 3 逻辑复用的官方答案。它取代了 Vue 2 时代的 mixins,解决了命名冲突、来源不清、类型推导弱三大顽疾。本文从零封装五个高频 Composable,讲透设计要点与避坑细节。

为什么是 Composables 而不是 Mixins

先看 mixins 的三大原罪:

  1. 来源不透明:组件里用了一个 this.fetchData(),根本不知道是组件自己的还是某个 mixin 的
  2. 命名冲突:两个 mixin 定义了同名属性,静默覆盖
  3. 类型黑洞:TS 无法推导 mixin 注入的内容

Composables 是普通函数,数据来源明确(const { data } = useFetch(url)),返回值命名完全由调用方决定,天然解决以上所有问题:

const { data: orderList, loading } = useFetch('/api/orders')
const { data: userList, loading: userLoading } = useFetch('/api/users')
// 命名冲突?重命名就行

Composable 的三条铁律

  1. 命名以 use 开头,这是社区强约定
  2. 只在 setup 上下文中同步调用,不能在回调、事件处理器里调用(因为内部可能依赖 onMounted 等生命周期钩子,它们要求在 setup 同步调用期间注册)
  3. 返回值用普通对象包 ref,而不是数组里混 ref 和普通值,方便调用方解构重命名

实战一:useRequest —— 通用请求管理

这是使用频率最高的 Composable,覆盖请求、竞态、手动触发:

// composables/useRequest.ts
import { ref, shallowRef, onUnmounted, type Ref, unref, watchEffect } from 'vue'

interface UseRequestOptions<T> {
  immediate?: boolean          // 是否立即执行
  debounce?: number            // 防抖毫秒数
}

export function useRequest<T>(
  url: () => string | Ref<string>,
  options: UseRequestOptions<T> = {}
) {
  const { immediate = true, debounce } = options

  const data = shallowRef<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  let abortController: AbortController | null = null
  let timer: ReturnType<typeof setTimeout> | null = null

  async function execute() {
    // 竞态处理:取消上一次未完成的请求
    abortController?.abort()
    abortController = new AbortController()

    loading.value = true
    error.value = null
    try {
      const res = await fetch(unref(url), {
        signal: abortController.signal
      })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (e: any) {
      if (e.name !== 'AbortError') {
        error.value = e
      }
    } finally {
      loading.value = false
    }
  }

  function run() {
    if (debounce) {
      timer && clearTimeout(timer)
      timer = setTimeout(execute, debounce)
    } else {
      execute()
    }
  }

  // url 是响应式 ref 时,自动重新请求
  watchEffect(() => {
    unref(url)
    if (immediate) run()
  })

  // 组件卸载时取消进行中的请求,防止内存泄漏
  onUnmounted(() => {
    abortController?.abort()
    timer && clearTimeout(timer)
  })

  return { data, loading, error, run }
}

使用起来非常顺手:

<script setup>
import { computed } from 'vue'

const page = ref(1)
const url = computed(() => `/api/orders?page=${page.value}`)

const { data, loading, run } = useRequest(url)

function nextPage() {
  page.value++
  // url 变了会自动重新请求,也可以手动 run()
}
</script>

几个设计细节值得注意:

  • shallowRef 存 data:大列表数据不必深度响应式,浅层够用且省内存
  • AbortController 防竞态:搜索框快速输入时,只有最后一次请求的结果会生效
  • onUnmounted 清理:Composable 内注册生命周期钩子,自动绑定到调用它的组件上,这是 Composable 相比普通函数的核心优势

实战二:useMouse —— 事件监听封装

// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)

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

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

调用方代码极其干净:

<script setup>
const { x, y } = useMouse()
</script>

<template>鼠标位置:{{ x }}, {{ y }}</template>

如果希望返回值可以直接当数字用,用 toValue 系列工具或返回 getter:

import { computed } from 'vue'
const { x, y } = useMouse()
const pos = computed(() => `(${x.value}, ${y.value})`)

实战三:useLocalStorage —— 持久化响应式状态

// composables/useLocalStorage.ts
import { ref, watch, type WatchCallback } from 'vue'

export function useLocalStorage<T>(key: string, initialValue: T) {
  // 初始化:优先读缓存
  const stored = localStorage.getItem(key)
  const state = ref<T>(
    stored ? JSON.parse(stored) : initialValue
  ) as Ref<T>

  // 监听变化自动写入,深度监听覆盖对象/数组
  watch(state, (val) => {
    try {
      localStorage.setItem(key, JSON.stringify(val))
    } catch (e) {
      console.warn('localStorage 写入失败', e)
    }
  }, { deep: true })

  // 多标签页同步:storage 事件
  if (typeof window !== 'undefined') {
    window.addEventListener('storage', (e) => {
      if (e.key === key && e.newValue) {
        state.value = JSON.parse(e.newValue)
      }
    })
  }

  function remove() {
    localStorage.removeItem(key)
    state.value = initialValue
  }

  return { state, remove }
}
const { state: settings } = useLocalStorage('app-settings', {
  theme: 'dark',
  fontSize: 14
})

// 改 settings.value.theme 自动持久化,刷新页面不丢

实战四:useToggle —— 布尔状态切换

export function useToggle(initial = false) {
  const state = ref(initial)

  const toggle = (val?: boolean) => {
    state.value = val ?? !state.value
  }

  const setTrue = () => toggle(true)
  const setFalse = () => toggle(false)

  return { state, toggle, setTrue, setFalse }
}

看着简单,但弹窗、下拉、折叠面板全用得上,能把散落各处的 showXxx = !showXxx 收编成统一模式。

实战五:useInterval —— 定时器管理

import { ref, onUnmounted } from 'vue'

export function useInterval(cb: () => void, delay = 1000, immediate = true) {
  const timer = ref<ReturnType<typeof setInterval> | null>(null)

  function start() {
    if (timer.value) return
    timer.value = setInterval(cb, delay)
  }

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

  if (immediate) start()
  onUnmounted(stop) // 组件销毁自动清理,杜绝定时器泄漏

  return { start, stop }
}

倒计时验证码是典型场景:

const seconds = ref(60)
const { start, stop } = useInterval(() => {
  if (--seconds.value <= 0) stop()
}, 1000)

响应式输入:ref、getter 还是原始值?

设计 Composable 的输入参数时,Vue 3.3+ 提供了统一的 toValue()

import { toValue, type MaybeRefOrGetter } from 'vue'

export function useFoo(source: MaybeRefOrGetter<string>) {
  const value = toValue(source) // ref / getter / 原始值通吃
}

这样调用方想传什么就传什么,Composable 内部不需要关心:

useFoo('静态字符串')
useFoo(computed(() => dynamicUrl.value))
useFoo(() => props.someProp) // getter,保持响应性

常见错误与修正

错误一:返回响应式丢失

// ❌ 返回解构后的值,丢失响应性
return { data: data.value, loading: loading.value }

// ✅ 返回 ref 本身
return { data, loading }

错误二:异步函数里调用 Composable

// ❌ setup 已经执行完,onMounted 无法注册
async function setup() {
  const { x } = await Promise.resolve().then(() => useMouse())
}

// ✅ 同步调用,内部再异步
const { x } = useMouse()

错误三:忘记清理副作用

事件监听、定时器、WebSocket 连接必须在 onUnmounted 中清理。判断标准很简单:你注册了什么,就要注销什么

总结

  • Composables 用函数作用域取代 mixins 的属性合并,来源清晰、类型完整
  • 生命周期钩子可以在 Composable 中注册,自动绑定到调用组件——这是它管理副作用的杀手锏
  • 数据用 shallowRef、输入用 MaybeRefOrGetter + toValue(),是高阶设计模式
  • useRequest/useMouse/useLocalStorage 是三个最值得亲手写一遍的练手项目
  • VueUse 已经收录了 200+ 高质量 Composables,先读它的源码再动手,事半功倍

把业务里的重复逻辑沉淀成 Composables,是 Vue 项目从"堆页面"走向"有架构"的第一步。

0

评论 (0)

取消
0:00