引言
最近接手了一个 Vue 2 的老项目,翻开代码的那一刻,我陷入了沉思。
一个 .vue 文件足足 5000 行代码,data 里定义了 200 多个变量,methods 里塞了 100 多个方法。
相关逻辑散落在 data、methods、computed、watch 各个角落,方法套方法,变量牵变量。
剪不断、理还乱。
终于明白了 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 等)
为什么是函数?
组合式函数选择以函数的形式存在,而不是类、对象或其他形式,这是经过深思熟虑的设计:
-
组合性:函数可以轻松地相互调用、嵌套、组合。你可以在一个组合式函数中调用另一个组合式函数,形成逻辑的层层封装。
-
作用域隔离:每次调用函数都会创建一个新的作用域,这意味着你可以在多个组件中多次调用同一个组合式函数,每次调用都是独立的实例,互不干扰。
-
参数传递灵活:函数可以接收参数,返回值,这使得逻辑的输入输出非常清晰。
-
符合 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>
可以看到,一个完整的功能被拆分到了 data、mounted、beforeUnmount、methods 四个不同的地方。当组件功能越来越多时,阅读代码就需要在不同选项之间来回跳转,理解成本极高。
其实这种编程习惯至今我仍有部分困惑,在书写 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 函数中
-
命名清晰:通过解构赋值,可以清楚地看到
x 和 y 来自 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 开头,采用驼峰命名法,如 useMouse、useFetch、useLocalStorage
-
文件命名:与函数名一致,如
useMouse.js 或 useMouse.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 形式
组合式函数返回的响应式数据应该保持 ref 或 reactive 形式,不要在返回时解包:
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
对于大型对象或数组,如果只需要监听整体变化而不需要深度响应,使用 shallowRef 或 shallowReactive 可以提升性能:
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 提供的 watchEffect 和 onCleanup:
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>
清晰、简洁、优雅。这就是组合式函数的魅力。
回到标题,相同的业务实现,我不使用组合式函数也能实现。
读完这篇文章,是否可以尝试使用组合式函数,全凭各位看官决定。
写法只是手段,业务实现才是重点。