setup 的艺术:如何组织我们的组合式函数?

前言

Composition API 给了我们极大的自由去组织我们的代码,但是自由同样也意味着责任。很多开发者从 Options API 切换到 Composition API 时,会遇到这样的困惑:我们的代码是写在一起了,但写得很乱,像大杂烩,尤其是对于新手而言,一个 Vue 文件可以长达数千行代码。也有人调侃:"以前需要在 data、methods、computed 之间跳转,现在且需要在一个长函数里上下滚动。"

这种困惑的背后,是因为我们缺少一套 如何正确组织组合式函数 的方法论。本文将深入探讨组合式函数的设计原则、命名规范和最佳实践,帮助我们写出清晰、可维护、可测试的组合式函数。

什么是好的代码组织?

在讨论具体的设计模式之前,我们先要明确一个核心问题:好的代码组织是什么样,有哪些特性?

可读性:一眼就能看懂这个组件在做什么

不好的例子

我们先来看一个不好的例子:

html 复制代码
<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>

这段代码中,处理了太多功能逻辑,比如:获取数据、处理表单、控制模态框。当我们阅读这段代码时,可能需要花很长时间才能理清各个部分之间的关系。

好的例子

上述给了一个不好的例子,那好的例子应该是什么样的呢?

html 复制代码
<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>

我们可以通过合理的抽象,让组件的职责一目了然。它其实只用协调了各个组合式函数,每个函数的用途通过命名就清晰可见。

可维护性:修改一个功能不影响其他功能

可维护性的核心是 关注点分离,即:当我们需要修改某个功能时,应该能够准确定位到相关代码,而不必担心影响其他功能。

不好的例子

我们先来看一个不好的例子:

javascript 复制代码
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 }
}

这段代码中,功能之间耦合性太高了,当我们修改 fetchPostsfetchFriends 时,又会影响到其他的功能逻辑。

好的例子

针对上述情况,我们可以采用 功能分离 的方式,将各个功能剥离成完全独立的功能模块,如此一下各模块之间独立运作,互不影响:

javascript 复制代码
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 }
}

可测试性:每个功能可以独立测试

好的代码组织应该让测试变得简单,每个组合式函数都应该能够独立测试,而不需要模拟整个组件环境:

容易测试的例子

javascript 复制代码
export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  return {
    count,
    increment,
    decrement
  }
}

上述代码的组织结构和功能都十分清晰,我们可以轻松地设计出它的测试代码:

javascript 复制代码
describe('useCounter', () => {
  it('should increment count', () => {
    const { count, increment } = useCounter(5)
    expect(count.value).toBe(5)
    
    increment()
    expect(count.value).toBe(6)
  })
})

组合式函数的设计模式

工厂模式

工厂模式 是最基本的模式,工厂函数会返回一个包含响应式状态和操作方法的对象:

javascript 复制代码
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()

这种模式适用于大多数场景,特别是当组合式函数需要暴露多个操作方法时。

参数化设计

参数化设计 通过接收配置参数,返回定制化的功能,通过参数让组合式函数更加灵活:

javascript 复制代码
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
  }
}

在这种参数化设计中,其使用方式也是多样的,可以根据自身需要和编码风格自行选择:

javascript 复制代码
// 方式一:最简单的调用
const { data } = useFetch({ url: '/api/users' })

// 方式二:复杂的 options 配置
const { data, execute} = useFetch({ 
  url: '/api/posts',
  immediate: false,
  transform: (data) => data.filter(p => p.published)
})

依赖注入模式

依赖注入模式 适用于需要跨组件共享但又不想用全局状态管理的场景:

javascript 复制代码
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() // 拿到响应式的主题配置

生命周期集成

在组合式函数中集成生命周期钩子时,应该在组合式函数内部管理自己的生命周期,同时要谨记相关资源的清理:

javascript 复制代码
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 的导出方式:适合简单、扁平的返回值

javascript 复制代码
export function useCounter() {
  const count = ref(0)
  const double = computed(() => count.value * 2)
  
  return {
    count,  // ref,使用时需要 .value
    double  // 虽然是 computed,但也是 ref
  }
}

使用 reactive 的导出方式:适合相关的一组状态

javascript 复制代码
export function useMouse() {
  const state = reactive({
    x: 0,
    y: 0,
    isMoving: false
  })
  
  return {
    state  // reactive,使用时 state.x
  }
}

混合使用:使用 toRefs 保持解构能力

javascript 复制代码
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

javascript 复制代码
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 时,我们无法改变数据,可以避免很多危险操作:

javascript 复制代码
const { user } = useUserAuth()
user.value = { hacked: true } // ❌ 报错!user 是只读的

何时使用 readonly:

  • 状态只应该由组合式函数内部修改
  • 对外暴露计算属性(本身就是只读)
  • 防止组件直接修改全局状态

代码组织的黄金法则

单一职责原则

每个组合式函数应该只做一件事,并且做好这件事。如果一个函数变得复杂,可以考虑拆分成更小的函数:

javascript 复制代码
export function useUserProfile() { /* 只处理用户基本信息 */ }
export function useUserPermissions() { /* 只处理权限 */ }
export function useUserOrders() { /* 只处理订单 */ }

显式优于隐式

依赖关系要明确,不要隐藏副作用:

javascript 复制代码
export function usePosts(userId) {
  const posts = ref([])
  watch(userId, () => fetchPosts())
}

组合优于继承

通过组合多个小的函数来构建复杂功能,而不是创建庞大的函数:

javascript 复制代码
export function useAdvancedFeature() {
  const { user } = useUser()
  const { posts } = usePosts(user.id)
  const { comments } = useComments(posts)
  const { stats } = useStats(comments)
  
  return { user, posts, comments, stats }
}

命名要自文档化

好的命名让代码自解释,减少注释需求:

javascript 复制代码
// 好的命名
const { isAuthenticated } = useAuth()
const { data: products, loading: productsLoading } = useProducts()
const { formData, submitForm } = useCheckoutForm()

// 差的命名
const { a, b, c } = useStuff()
const { d, e } = useData()

保持函数的纯洁性

尽量让组合式函数无副作用,或者将副作用限制在函数内部:

javascript 复制代码
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 真正的艺术。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
Coffeeee几秒前
AGP9.0的主要变更项,给Gradle来一次大变样
android·前端·gradle
数聚天成DeepSData15 分钟前
外贸海关进出口数据去哪免费下载?从统计到明细的查找指南
linux·服务器·开发语言·前端·网络·人工智能·自然语言处理
半夜里咳嗽的狼21 分钟前
组件进侧栏就变形?用 CSS Container Queries 把响应式边界收回组件
前端·css
xx240628 分钟前
前端性能优化笔记
前端·笔记·性能优化
带娃的IT创业者34 分钟前
单文件架构的极致美学:深入解析 Bento 的 HTML 幻灯片技术实现
前端·架构·html·web开发·bento·单文件架构
muddjsv36 分钟前
CSS 值的完整计算过程:指定值、计算值、使用值、实际值与动态依赖
前端·css
恋猫de小郭37 分钟前
KotlinLLM 开源 ,一个可以在运行时自己生成永久 Kotlin 代码的 Agent 库
android·前端·人工智能
名字还没想好☜40 分钟前
React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理
前端·javascript·react.js·ecmascript·react·createportal
用户059540174461 小时前
Playwright测试AI记忆存储踩坑实录:这个时序问题让我排查了6小时
前端·css
IT_陈寒1 小时前
Redis踩了个大坑,原来DEL命令也会卡住整个实例
前端·人工智能·后端