「九九八十一难」组合式函数到底有什么用?

26 阅读10分钟

引言

最近接手了一个 Vue 2 的老项目,翻开代码的那一刻,我陷入了沉思。

一个 .vue 文件足足 5000 行代码,data 里定义了 200 多个变量,methods 里塞了 100 多个方法。

相关逻辑散落在 datamethodscomputedwatch 各个角落,方法套方法,变量牵变量。

剪不断、理还乱。

终于明白了 Vue 3 为什么要引入组合式函数(Composables)

Q:有同学就要问了,为什么不用 mixin 实现?

A:在实际工程中使用 mixin ,还不一定比放在同一个组件里面维护起来方便。


组合式函数(Composables)定义

在 Vue 应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑函数

这个定义中有两个关键点需要理解:有状态逻辑函数形式

什么是有状态逻辑?

在程序设计中,"状态"指的是在程序运行过程中会发生变化的数据。有状态逻辑就是指那些管理着会变化的数据,并且需要对这些数据的变化做出响应的代码逻辑。

阅读下面文章之前,先理解下这两句话:

组合式函数内部可以使用 ref 或 reactive 创建响应式数据,并且这些数据在返回给组件后依然保持响应性

组合式函数可以接收任意参数,可以是普通值或响应式引用(ref)。

举个例子:

  • 无状态逻辑:一个纯函数 add(a, b) => a + b,给定相同的输入,永远返回相同的输出,不依赖任何外部状态。
  • 有状态逻辑:一个计数器,它维护一个当前计数值,可以增加、减少、重置,并且当计数值变化时,使用这个计数值的地方需要自动更新。

在 Vue 中,有状态逻辑通常包含:

  • 响应式数据(ref、reactive)
  • 计算属性(computed)
  • 侦听器(watch)
  • 生命周期钩子(onMounted、onUnmounted 等)

为什么是函数?

组合式函数选择以函数的形式存在,而不是类、对象或其他形式,这是经过深思熟虑的设计:

  1. 组合性:函数可以轻松地相互调用、嵌套、组合。你可以在一个组合式函数中调用另一个组合式函数,形成逻辑的层层封装。

  2. 作用域隔离:每次调用函数都会创建一个新的作用域,这意味着你可以在多个组件中多次调用同一个组合式函数,每次调用都是独立的实例,互不干扰。

  3. 参数传递灵活:函数可以接收参数,返回值,这使得逻辑的输入输出非常清晰。

  4. 符合 JavaScript 惯例:JavaScript 本身就是函数式编程友好的语言,使用函数封装逻辑符合开发者的直觉。

为什么要引入组合式函数(Composables)?

Vue 2 选项式 API 的困境

在 Vue 2 中,我们使用选项式 API(Options API)来组织代码。

这种方式在组件简单时非常直观,但当组件变得复杂时,问题就暴露出来了。

问题一:逻辑碎片化

假设我们要实现一个"鼠标追踪"功能,需要追踪鼠标在页面上的位置。在 Vue 2 中,代码会散落在多个选项中:

<script>
export default {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

可以看到,一个完整的功能被拆分到了 datamountedbeforeUnmountmethods 四个不同的地方。当组件功能越来越多时,阅读代码就需要在不同选项之间来回跳转,理解成本极高。

其实这种编程习惯至今我仍有部分困惑,在书写 vue3 组合式写法时,部分同事还是喜欢将变量、方法、计算属性分类书写,方法放在一起、变量放在一堆,导致维护代码时候仍然会在多个代码块中进行跳转。

问题二:复用困难

Vue 2 提供了 Mixins 来复用逻辑,但它存在严重的问题:

<script>
const mouseTrackingMixin = {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}

export default {
  mixins: [mouseTrackingMixin],
  data() {
    return {
      x: 'I will be overwritten!'  // 命名冲突!
    }
  }
}
</script>

Mixins 的问题包括:

  • 命名冲突:多个 mixin 或组件与 mixin 之间可能有同名属性/方法,导致覆盖
  • 依赖隐式:mixin 内部可能使用了组件的某些属性,但这种依赖关系不明显
  • 数据来源不清晰:当使用了多个 mixin 时,很难分辨某个属性来自哪个 mixin

问题三:TypeScript 支持不友好

选项式 API 的类型推导相对复杂,IDE 的智能提示也不够完善,这在大型项目中是一个明显的短板。

组合式函数的解决方案

组合式函数完美解决了上述问题:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

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

  function handleMouseMove(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', handleMouseMove)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', handleMouseMove)
  })

  return { x, y }
}

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

可以看到:

  • 逻辑聚合:所有与鼠标追踪相关的代码都集中在 useMouse 函数中
  • 命名清晰:通过解构赋值,可以清楚地看到 xy 来自 useMouse
  • 无命名冲突:即使有多个组合式函数返回同名属性,也可以通过重命名解决

组合式函数的优势

1. 逻辑组织更清晰

组合式函数允许我们按照功能而不是按照选项来组织代码。相关联的状态和方法可以放在一起,形成内聚的逻辑单元。

<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
import { useTheme } from './composables/useTheme'

const { x, y } = useMouse()
const { data, error, loading } = useFetch('/api/users')
const { theme, toggleTheme } = useTheme()
</script>

每个组合式函数负责一个独立的功能,代码结构一目了然。

2. 逻辑复用更简单

组合式函数本质上是普通 JavaScript 函数,可以在任何地方调用:

import { useMouse } from './composables/useMouse'

export function useMouseWithDelay(delay = 100) {
  const { x: rawX, y: rawY } = useMouse()
  const x = ref(0)
  const y = ref(0)

  watch([rawX, rawY], debounce(([newX, newY]) => {
    x.value = newX
    y.value = newY
  }, delay))

  return { x, y }
}

你甚至可以在一个组合式函数中调用另一个组合式函数,实现逻辑的组合与扩展。

3. 类型推导更完善

组合式函数天然支持 TypeScript,类型推导非常准确:

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

function useUser(id: Ref<number>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.name} <${user.value.email}>`
  })

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`/api/users/${id.value}`)
      user.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return {
    user,
    loading,
    error,
    fullName,
    fetchUser
  }
}

IDE 可以准确推断出 user 的类型是 Ref<User | null>fullName 的类型是 ComputedRef<string>

4. 测试更方便

组合式函数是纯 JavaScript/TypeScript 函数,可以脱离 Vue 组件独立测试:

import { useCounter } from './composables/useCounter'
import { ref } from 'vue'

describe('useCounter', () => {
  it('should increment count', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('should accept initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
})

组合式函数的使用场景

1. 封装通用状态逻辑

当你发现多个组件中存在相同或相似的状态逻辑时,就应该考虑提取为组合式函数。

典型场景

  • 表单验证逻辑
  • 分页逻辑
  • 加载状态管理
  • 主题切换
  • 国际化

2. 组织复杂组件逻辑

当单个组件变得庞大时,可以使用组合式函数将不同功能的代码分离:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserProfile } from './composables/useUserProfile'
import { useUserPosts } from './composables/useUserPosts'

const { user, login, logout } = useUserAuth()
const { profile, updateProfile } = useUserProfile(user)
const { posts, fetchPosts, createPost } = useUserPosts(user)
</script>

3. 集成第三方库

将第三方库的集成逻辑封装为组合式函数,可以简化使用并提供 Vue 友好的 API:

import { ref, onMounted, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'

export function useDebouncedRef(value, delay = 300) {
  const debouncedValue = ref(value)
  const updater = debounce((newValue) => {
    debouncedValue.value = newValue
  }, delay)

  watch(() => value, (newValue) => {
    updater(newValue)
  })

  onUnmounted(() => {
    updater.cancel()
  })

  return debouncedValue
}

4. 抽象浏览器 API

将浏览器原生 API 封装为响应式的组合式函数:

import { ref, onMounted, onUnmounted } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)

  function read() {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = JSON.parse(stored)
    }
  }

  function write() {
    localStorage.setItem(key, JSON.stringify(value.value))
  }

  onMounted(() => {
    read()
    window.addEventListener('storage', read)
  })

  onUnmounted(() => {
    window.removeEventListener('storage', read)
  })

  watch(value, write, { deep: true })

  return value
}

组合式函数的实现规范

基本结构

一个标准的组合式函数通常包含以下部分:

import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

export function useFeatureName(parameter) {
  const state = ref(initialValue)
  const computedValue = computed(() => {
    return state.value * 2
  })

  function doSomething() {
    state.value++
  }

  watch(state, (newValue, oldValue) => {
    console.log(`state changed from ${oldValue} to ${newValue}`)
  })

  onMounted(() => {
    console.log('component mounted')
  })

  onUnmounted(() => {
    console.log('component unmounted')
  })

  return {
    state,
    computedValue,
    doSomething
  }
}

命名约定

  • 函数命名:以 use 开头,采用驼峰命名法,如 useMouseuseFetchuseLocalStorage
  • 文件命名:与函数名一致,如 useMouse.jsuseMouse.ts
  • 目录结构:通常放在 composables/hooks/ 目录下

返回值约定

  • 返回一个对象,包含需要暴露给外部使用的响应式状态和方法
  • 返回的对象通常使用解构赋值接收
  • 如果需要返回响应式引用,不要在返回时解包,保持 ref 形式

参数约定

  • 可以接收普通值、响应式引用(ref)、响应式对象(reactive)作为参数
  • 如果参数可能是响应式的,使用 toValue() 工具函数进行解包:
import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

组合式函数的实现示例

示例一:鼠标追踪器

这是一个经典的组合式函数示例,封装了鼠标位置追踪逻辑:

import { ref, onMounted, onUnmounted } from 'vue'

/**
 * 追踪鼠标在页面上的位置
 * @returns {Object} 包含鼠标 x、y 坐标的响应式引用
 */
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

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

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

  return { x, y }
}

在组件中使用:

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

<script setup>
import { useMouse } from './composables/useMouse'

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

示例二:数据请求

封装通用的数据获取逻辑,包含加载状态和错误处理:

import { ref, watchEffect, toValue } from 'vue'

/**
 * 封装数据获取逻辑
 * @param {string|Ref<string>|() => string} url - 请求地址,可以是响应式引用或 getter 函数
 * @returns {Object} 包含 data、error、loading 状态的对象
 */
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error, loading, refetch: fetchData }
}

在组件中使用:

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">加载失败:{{ error.message }}</div>
  <div v-else>
    <pre>{{ data }}</pre>
    <button @click="refetch">重新加载</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useFetch } from './composables/useFetch'

const userId = ref(1)
const { data, error, loading, refetch } = useFetch(
  () => `/api/users/${userId.value}`
)
</script>

示例三:计数器

一个简单但完整的计数器示例,展示参数接收和返回值:

import { ref, computed } from 'vue'

/**
 * 创建一个计数器
 * @param {number} initialValue - 初始值,默认为 0
 * @param {number} step - 步长,默认为 1
 * @returns {Object} 计数器状态和方法
 */
export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue)

  const isPositive = computed(() => count.value > 0)
  const isNegative = computed(() => count.value < 0)
  const isZero = computed(() => count.value === 0)

  function increment() {
    count.value += step
  }

  function decrement() {
    count.value -= step
  }

  function reset() {
    count.value = initialValue
  }

  function set(value) {
    count.value = value
  }

  return {
    count,
    isPositive,
    isNegative,
    isZero,
    increment,
    decrement,
    reset,
    set
  }
}

示例四:表单验证

封装表单验证逻辑,支持自定义验证规则:

import { ref, computed, reactive } from 'vue'

/**
 * 表单验证组合式函数
 * @param {Object} initialValues - 表单初始值
 * @param {Object} rules - 验证规则
 * @returns {Object} 表单状态和验证方法
 */
export function useForm(initialValues, rules) {
  const values = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  const isSubmitting = ref(false)

  const isValid = computed(() => {
    return Object.keys(errors).every(key => !errors[key])
  })

  function validateField(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = values[field]
    const result = rule(value)

    if (typeof result === 'string') {
      errors[field] = result
      return false
    } else {
      errors[field] = ''
      return true
    }
  }

  function validateAll() {
    let allValid = true
    for (const field in rules) {
      if (!validateField(field)) {
        allValid = false
      }
    }
    return allValid
  }

  function setFieldTouched(field) {
    touched[field] = true
    validateField(field)
  }

  function resetForm() {
    Object.assign(values, initialValues)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
    Object.keys(touched).forEach(key => {
      touched[key] = false
    })
  }

  async function handleSubmit(callback) {
    isSubmitting.value = true

    Object.keys(values).forEach(key => {
      touched[key] = true
    })

    if (validateAll()) {
      await callback(values)
    }

    isSubmitting.value = false
  }

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    validateField,
    validateAll,
    setFieldTouched,
    resetForm,
    handleSubmit
  }
}

在组件中使用:

<template>
  <form @submit.prevent="handleSubmit(onSubmit)">
    <div>
      <label>用户名:</label>
      <input
        v-model="values.username"
        @blur="setFieldTouched('username')"
      />
      <span v-if="touched.username && errors.username" class="error">
        {{ errors.username }}
      </span>
    </div>

    <div>
      <label>邮箱:</label>
      <input
        v-model="values.email"
        @blur="setFieldTouched('email')"
      />
      <span v-if="touched.email && errors.email" class="error">
        {{ errors.email }}
      </span>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

<script setup>
import { useForm } from './composables/useForm'

const initialValues = {
  username: '',
  email: ''
}

const rules = {
  username: (value) => {
    if (!value) return '用户名不能为空'
    if (value.length < 3) return '用户名至少 3 个字符'
    return true
  },
  email: (value) => {
    if (!value) return '邮箱不能为空'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确'
    return true
  }
}

const {
  values,
  errors,
  touched,
  isSubmitting,
  setFieldTouched,
  handleSubmit
} = useForm(initialValues, rules)

async function onSubmit(formValues) {
  console.log('表单提交:', formValues)
}
</script>

注意点与最佳实践

1. 始终在 setup 函数或 script setup 中调用

组合式函数依赖于 Vue 的组合式 API,必须在组件的 setup() 函数或 <script setup> 中同步调用:

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

错误示例

export default {
  setup() {
    setTimeout(() => {
      const { x, y } = useMouse()
    }, 1000)
  }
}

2. 返回响应式引用时保持 ref 形式

组合式函数返回的响应式数据应该保持 refreactive 形式,不要在返回时解包:

export function useCounter() {
  const count = ref(0)
  return { count }
}

这样可以让调用者明确知道这是一个响应式引用,并且可以灵活地传递给其他组合式函数。

3. 使用 toValue 处理可能是响应式的参数

当组合式函数接收的参数可能是普通值、ref 或 getter 函数时,使用 toValue 统一处理:

import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

4. 合理使用 shallowRef 和 shallowReactive

对于大型对象或数组,如果只需要监听整体变化而不需要深度响应,使用 shallowRefshallowReactive 可以提升性能:

import { shallowRef } from 'vue'

export function useLargeData() {
  const data = shallowRef([])

  async function fetchData() {
    const response = await fetch('/api/large-data')
    data.value = await response.json()
  }

  return { data, fetchData }
}

5. 清理副作用

在组合式函数中创建的副作用(事件监听、定时器等)必须在组件卸载时清理:

import { onUnmounted } from 'vue'

export function useInterval(callback, delay) {
  let timer = null

  timer = setInterval(callback, delay)

  onUnmounted(() => {
    if (timer) {
      clearInterval(timer)
    }
  })
}

或者使用 Vue 提供的 watchEffectonCleanup

import { watchEffect } from 'vue'

export function useEventListener(target, event, callback) {
  watchEffect((onCleanup) => {
    target.addEventListener(event, callback)

    onCleanup(() => {
      target.removeEventListener(event, callback)
    })
  })
}

6. 避免在组合式函数中直接修改 props

组合式函数不应该直接修改接收到的 props,而应该通过 emit 或其他方式通知父组件:

export function useModelValue(props, emit) {
  const localValue = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })

  return { localValue }
}

7. 提供合理的默认值

组合式函数的参数应该提供合理的默认值,提高易用性:

export function useDebounce(fn, delay = 300) {
}

8. 文档化你的组合式函数

使用 JSDoc 为组合式函数添加文档,说明参数、返回值和使用示例:

/**
 * 创建一个防抖的响应式引用
 * @template T
 * @param {T} initialValue - 初始值
 * @param {number} delay - 防抖延迟时间(毫秒)
 * @returns {import('vue').Ref<T>} 防抖后的响应式引用
 * @example
 * const searchTerm = useDebouncedRef('', 300)
 * watch(searchTerm, (value) => {
 *   console.log('搜索:', value)
 * })
 */
export function useDebouncedRef(initialValue, delay = 300) {
}

组合式函数 vs 其他方案对比

组合式函数 vs Mixins

特性组合式函数Mixins
数据来源清晰(解构赋值)不清晰
命名冲突可重命名解决静默覆盖
参数传递支持参数不支持
逻辑组合可嵌套调用困难
TypeScript 支持完善较差

组合式函数 vs Renderless Components

Renderless Components(无渲染组件)是 Vue 2 中另一种复用逻辑的方式:

<template>
  <slot :x="x" :y="y" />
</template>

<script>
export default {
  data() {
    return { x: 0, y: 0 }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

对比:

特性组合式函数Renderless Components
性能更好(无组件开销)有组件实例开销
使用方式函数调用组件嵌套
灵活性更高受限于组件树
TypeScript 支持完善一般

总结

组合式函数是 Vue 3 最具革命性的特性之一,它从根本上改变了我们组织和复用代码的方式。

核心价值

  • 解决逻辑碎片化:将相关联的状态和方法聚合在一起,代码更易读、易维护
  • 简化逻辑复用:以函数形式封装,可在任意组件中复用,无命名冲突之忧
  • 提升开发体验:完善的 TypeScript 支持和 IDE 智能提示
  • 便于测试:纯函数形式,可脱离组件独立测试

使用建议

  • 当发现多个组件存在相同逻辑时,提取为组合式函数
  • 当单个组件变得庞大时,使用组合式函数拆分功能模块
  • 遵循命名约定(use 前缀)和返回值约定
  • 注意清理副作用,避免内存泄漏

从 Vue 2 迁移

  • 不需要一次性重写所有代码,组合式函数可以与选项式 API 共存
  • 可以逐步将 Mixins 重构为组合式函数
  • 利用组合式函数简化新功能的开发

组合式函数不仅是一种技术方案,更是一种关注点分离组合优于继承的设计思想。掌握它,将让你的 Vue 开发体验提升一个台阶。

回到开头那个 5000 行的 Vue 2 组件,如果用组合式函数重构,或许可以变成这样:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserList } from './composables/useUserList'
import { useUserForm } from './composables/useUserForm'
import { usePagination } from './composables/usePagination'
import { useSearch } from './composables/useSearch'
import { useNotification } from './composables/useNotification'

const { user, login, logout } = useUserAuth()
const { users, fetchUsers, deleteUser } = useUserList()
const { form, submitForm, resetForm } = useUserForm()
const { page, pageSize, total, setPage } = usePagination()
const { keyword, filteredUsers } = useSearch(users)
const { showSuccess, showError } = useNotification()
</script>

清晰、简洁、优雅。这就是组合式函数的魅力。

回到标题,相同的业务实现,我不使用组合式函数也能实现。

读完这篇文章,是否可以尝试使用组合式函数,全凭各位看官决定。

写法只是手段,业务实现才是重点。