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

19 阅读9分钟

前言

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 }
}

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

好的例子

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

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

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