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 真正的艺术。

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

相关推荐
kmblack17 分钟前
javascript计算年龄
开发语言·javascript·ecmascript
老马聊技术10 分钟前
AI对话功能之SpringBoot整合Vue3
vue.js·人工智能·spring boot·后端
甲维斯15 分钟前
测一波Kimi K2.7,消耗一周配额!
前端·人工智能·游戏开发
Dick50716 分钟前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人
英勇无比的消炎药41 分钟前
一站式汇总TinyVue工具案例与真实落地经验
vue.js·前端框架
xiaofeichaichai1 小时前
前端安全 XSS 与 CSRF
前端·安全·xss
JS菌1 小时前
Skills 动态加载系统:让 AI Agent 按需获取领域知识
前端·人工智能·后端
weedsfly1 小时前
Sass 代码复用完全指南:从变量到模块化
前端
张拭心1 小时前
Android 17 新特性:后台音频交互限制加强
android·前端