前言
Composition API 给了我们极大的自由去组织我们的代码,但是自由同样也意味着责任。很多开发者从 Options API 切换到 Composition API 时,会遇到这样的困惑:我们的代码是写在一起了,但写得很乱,像大杂烩,尤其是对于新手而言,一个 Vue 文件可以长达数千行代码。也有人调侃:"以前需要在 data、methods、computed 之间跳转,现在且需要在一个长函数里上下滚动。"
这种困惑的背后,是因为我们缺少一套 如何正确组织组合式函数 的方法论。本文将深入探讨组合式函数的设计原则、命名规范和最佳实践,帮助我们写出清晰、可维护、可测试的组合式函数。
什么是好的代码组织?
在讨论具体的设计模式之前,我们先要明确一个核心问题:好的代码组织是什么样,有哪些特性?
可读性:一眼就能看懂这个组件在做什么
不好的例子
我们先来看一个不好的例子:
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from './store'
const route = useRoute()
const store = useStore()
const data = ref(null)
const loading = ref(false)
const error = ref(null)
const formData = ref({})
const validationErrors = ref({})
const isSubmitting = ref(false)
const showModal = ref(false)
const selectedId = ref(null)
onMounted(() => {
fetchData()
})
watch(() => route.params.id, (newId) => {
selectedId.value = newId
fetchData()
})
async function fetchData() {
loading.value = true
error.value = null
try {
data.value = await store.fetchItem(selectedId.value)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
async function handleSubmit() {
isSubmitting.value = true
try {
await store.saveItem(formData.value)
showModal.value = false
} catch (e) {
validationErrors.value = e.errors
} finally {
isSubmitting.value = false
}
}
function resetForm() {
formData.value = {}
validationErrors.value = {}
}
</script>
<template>
<!-- 模板部分也很长 -->
</template>
这段代码中,处理了太多功能逻辑,比如:获取数据、处理表单、控制模态框。当我们阅读这段代码时,可能需要花很长时间才能理清各个部分之间的关系。
好的例子
上述给了一个不好的例子,那好的例子应该是什么样的呢?
<script setup>
import { useRouteParams } from './composables/useRouteParams'
import { useItemDetail } from './composables/useItemDetail'
import { useItemForm } from './composables/useItemForm'
// 一眼就能看出这个组件有三个主要功能
const { id } = useRouteParams('id')
const { data, loading, error } = useItemDetail(id)
const { form, isSubmitting, validationErrors, submit, reset } = useItemForm({
onSubmit: (formData) => saveItem(formData)
})
async function saveItem(formData) {
// 具体的保存逻辑
}
</script>
<template>
<ItemDetail
:data="data"
:loading="loading"
:error="error"
/>
<ItemForm
v-model="form"
:submitting="isSubmitting"
:errors="validationErrors"
@submit="submit"
@reset="reset"
/>
</template>
我们可以通过合理的抽象,让组件的职责一目了然。它其实只用协调了各个组合式函数,每个函数的用途通过命名就清晰可见。
可维护性:修改一个功能不影响其他功能
可维护性的核心是 关注点分离,即:当我们需要修改某个功能时,应该能够准确定位到相关代码,而不必担心影响其他功能。
不好的例子
我们先来看一个不好的例子:
export function useUserProfile() {
const user = ref(null)
const posts = ref([])
const friends = ref([])
async function fetchUser() { /* ... */ }
async function fetchPosts() { /* ... */ }
async function fetchFriends() { /* ... */ }
// 三个功能混在一起,修改用户逻辑时可能影响其他
watch(user, () => {
fetchPosts() // 隐式依赖
fetchFriends() // 隐式依赖
})
return { user, posts, friends }
}
这段代码中,功能之间耦合性太高了,当我们修改 fetchPosts 或 fetchFriends 时,又会影响到其他的功能逻辑。
好的例子
针对上述情况,我们可以采用 功能分离 的方式,将各个功能剥离成完全独立的功能模块,如此一下各模块之间独立运作,互不影响:
export function useUser(id) {
const user = ref(null)
async function fetchUser() { /* ... */ }
return { user, fetchUser }
}
export function useUserPosts(userId) {
const posts = ref([])
async function fetchPosts() { /* ... */ }
return { posts, fetchPosts }
}
export function useUserFriends(userId) {
const friends = ref([])
async function fetchFriends() { /* ... */ }
return { friends, fetchFriends }
}
可测试性:每个功能可以独立测试
好的代码组织应该让测试变得简单,每个组合式函数都应该能够独立测试,而不需要模拟整个组件环境:
容易测试的例子
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function decrement() {
count.value--
}
return {
count,
increment,
decrement
}
}
上述代码的组织结构和功能都十分清晰,我们可以轻松地设计出它的测试代码:
describe('useCounter', () => {
it('should increment count', () => {
const { count, increment } = useCounter(5)
expect(count.value).toBe(5)
increment()
expect(count.value).toBe(6)
})
})
组合式函数的设计模式
工厂模式
工厂模式 是最基本的模式,工厂函数会返回一个包含响应式状态和操作方法的对象:
export function useToggle(initialValue = false) {
const state = ref(initialValue)
const setTrue = () => state.value = true
const setFalse = () => state.value = false
const toggle = () => state.value = !state.value
return {
state: readonly(state), // 只读导出,防止外部直接修改
setTrue,
setFalse,
toggle
}
}
// 使用
const { state, toggle } = useToggle()
这种模式适用于大多数场景,特别是当组合式函数需要暴露多个操作方法时。
参数化设计
参数化设计 通过接收配置参数,返回定制化的功能,通过参数让组合式函数更加灵活:
export function useFetch(options) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const response = await fetch(options.url)
let result = await response.json()
if (options.transform) {
result = options.transform(result)
}
data.value = result
options.onSuccess?.(result)
} catch (e) {
error.value = e
options.onError?.(e)
} finally {
loading.value = false
}
}
if (options.immediate !== false) {
execute()
}
return {
data,
error,
loading,
execute
}
}
在这种参数化设计中,其使用方式也是多样的,可以根据自身需要和编码风格自行选择:
// 方式一:最简单的调用
const { data } = useFetch({ url: '/api/users' })
// 方式二:复杂的 options 配置
const { data, execute} = useFetch({
url: '/api/posts',
immediate: false,
transform: (data) => data.filter(p => p.published)
})
依赖注入模式
依赖注入模式 适用于需要跨组件共享但又不想用全局状态管理的场景:
const ThemeSymbol = Symbol()
export function provideTheme(config) {
const theme = reactive({
primary: config.primary || '#1890ff',
secondary: config.secondary || '#52c41a',
// ... 更多主题配置
})
provide(ThemeSymbol, theme)
return theme
}
export function useTheme() {
const theme = inject(ThemeSymbol)
if (!theme) {
throw new Error('useTheme must be used after provideTheme')
}
return theme
}
// 在根组件提供
provideTheme({
primary: '#ff4d4f'
})
// 在任意子组件使用
const theme = useTheme() // 拿到响应式的主题配置
生命周期集成
在组合式函数中集成生命周期钩子时,应该在组合式函数内部管理自己的生命周期,同时要谨记相关资源的清理:
export function useWebSocket(url) {
const socket = ref(null)
const message = ref(null)
const isConnected = ref(false)
onMounted(() => {
socket.value = new WebSocket(url)
socket.value.onopen = () => isConnected.value = true
socket.value.onmessage = (e) => message.value = JSON.parse(e.data)
target.addEventListener(event, handler)
})
onUnmounted(() => {
// 关闭连接、取消监听等
socket.value?.close()
socket.value = null
target.removeEventListener(event, handler)
})
// 暴露发送消息的方法
function send(data: any) {
if (socket.value?.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify(data))
}
}
return {
message,
isConnected: readonly(isConnected),
send
}
}
组合式函数的命名规范
use 前缀的语义
组合式函数约定俗成地以 use 开头,use 前缀表明这个函数:
- 统一风格,清晰地语义
- 创建响应式状态:返回的通常包含 ref/reactive
- 可能有副作用:可能监听事件、发起请求
- 需要特定上下文:可能在内部使用 Vue API
返回值是 ref 还是 reactive?
这是一个常见的选择题,Vue 官方有明确的指导原则:
使用 ref 的导出方式:适合简单、扁平的返回值
export function useCounter() {
const count = ref(0)
const double = computed(() => count.value * 2)
return {
count, // ref,使用时需要 .value
double // 虽然是 computed,但也是 ref
}
}
使用 reactive 的导出方式:适合相关的一组状态
export function useMouse() {
const state = reactive({
x: 0,
y: 0,
isMoving: false
})
return {
state // reactive,使用时 state.x
}
}
混合使用:使用 toRefs 保持解构能力
export function useTimer() {
const state = reactive({
seconds: 0,
minutes: 0,
hours: 0
})
return {
...toRefs(state), // 导出为 refs,可解构
reset: () => {
state.seconds = 0
state.minutes = 0
state.hours = 0
}
}
}
选择原则:
- 如果返回值是多个独立的变量:使用
ref - 如果返回值是逻辑上相关的一组状态:使用
reactive - 如果既想保持响应式连接,又想支持解构:使用
toRefs
注:由于使用
reactive时,解构会丢失响应性连接,如果需要解构,需要使用toRefs或转为ref;同时,直接对reactive赋值会导致响应式丢失。因此现在社区存在争议,部分开发者(包括笔者本人)倾向于统一使用ref以保持一致性。这个问题,在我后面的文章中会专门讲解。
何时返回只读状态
为了保护内部状态不被意外修改,可以返回只读版本 readonly:
export function useUserAuth() {
const user = ref(null)
const token = ref(null)
const isAuthenticated = computed(() => !!user.value)
async function login(credentials) {
const { user: userData, token: authToken } = await api.login(credentials)
user.value = userData
token.value = authToken
localStorage.setItem('token', authToken)
}
async function logout() {
user.value = null
token.value = null
localStorage.removeItem('token')
}
return {
user: readonly(user), // 外部只能读取,不能修改
isAuthenticated: isAuthenticated, // 没有 readonly 包裹,但是通过 computed 计算得来的,自带 readonly
login,
logout
}
}
当返回只读版本 readonly 时,我们无法改变数据,可以避免很多危险操作:
const { user } = useUserAuth()
user.value = { hacked: true } // ❌ 报错!user 是只读的
何时使用 readonly:
- 状态只应该由组合式函数内部修改
- 对外暴露计算属性(本身就是只读)
- 防止组件直接修改全局状态
代码组织的黄金法则
单一职责原则
每个组合式函数应该只做一件事,并且做好这件事。如果一个函数变得复杂,可以考虑拆分成更小的函数:
export function useUserProfile() { /* 只处理用户基本信息 */ }
export function useUserPermissions() { /* 只处理权限 */ }
export function useUserOrders() { /* 只处理订单 */ }
显式优于隐式
依赖关系要明确,不要隐藏副作用:
export function usePosts(userId) {
const posts = ref([])
watch(userId, () => fetchPosts())
}
组合优于继承
通过组合多个小的函数来构建复杂功能,而不是创建庞大的函数:
export function useAdvancedFeature() {
const { user } = useUser()
const { posts } = usePosts(user.id)
const { comments } = useComments(posts)
const { stats } = useStats(comments)
return { user, posts, comments, stats }
}
命名要自文档化
好的命名让代码自解释,减少注释需求:
// 好的命名
const { isAuthenticated } = useAuth()
const { data: products, loading: productsLoading } = useProducts()
const { formData, submitForm } = useCheckoutForm()
// 差的命名
const { a, b, c } = useStuff()
const { d, e } = useData()
保持函数的纯洁性
尽量让组合式函数无副作用,或者将副作用限制在函数内部:
export function useFilteredItems<T>(
items: Ref<T[]>,
filterFn: (item: T, search: string) => boolean
) {
const search = ref('')
const filtered = computed(() =>
items.value.filter(item => filterFn(item, search.value))
)
return {
search,
filtered: readonly(filtered)
}
}
遵循这些黄金法则,我们的组合式函数将会:
- 易于理解:每个函数的职责清晰
- 易于维护:修改一个功能不影响其他
- 易于测试:可以独立测试每个函数
- 易于复用:可以在不同组件中自由组合
结语
掌握 Composition API,会让我们的 Vue 组件会变成一个个清晰的积木组装,而不是一团混乱的代码,我们在开发时可以按需引用。这才是 Composition API 真正的艺术。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!