Skip to content

Vue3 组合式 API 最佳实践

Vue3 的组合式 API(Composition API)是一种全新的编写 Vue 组件的方式,它让我们可以更灵活地组织组件逻辑。本文将探讨组合式 API 的最佳实践和常见模式,帮助你更高效地构建 Vue 应用。

为什么使用组合式 API?

在开始之前,让我们先了解为什么 Vue3 引入了组合式 API:

  1. 更好的逻辑复用 - 与混入(mixins)相比,组合式 API 提供了更清晰的逻辑复用机制
  2. 更灵活的代码组织 - 按功能/关注点组织代码,而不是选项类型
  3. 更好的类型推导 - 对 TypeScript 的支持更加友好
  4. 更小的打包体积 - 更好的 Tree-shaking 支持

组合式 API 基础

如果你刚接触组合式 API,以下是一个基本的使用示例:

vue
<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// 响应式状态
const count = ref(0)

// 方法
function increment() {
  count.value++
}
</script>

这个简单的例子展示了组合式 API 的基本用法,使用 ref 创建响应式变量,并在模板中直接使用它。

组织组件代码的最佳实践

1. 使用 <script setup> 语法

<script setup> 是组合式 API 的语法糖,它简化了组件的编写:

vue
<script setup>
// 导入的组件自动注册
import ChildComponent from './ChildComponent.vue'

// 导出的变量自动暴露给模板
const message = 'Hello World'

// 定义的函数自动暴露给模板
function handleClick() {
  console.log('Clicked!')
}
</script>

<template>
  <div>
    {{ message }}
    <button @click="handleClick">Click me</button>
    <ChildComponent />
  </div>
</template>

<script setup> 的优势:

  • 更少的样板代码
  • 能够使用纯 TypeScript 编写组件逻辑
  • 更好的运行时性能
  • 更好的 IDE 类型推断

2. 按功能组织代码

组合式 API 的一个主要优势是可以按功能组织代码,而不是按选项类型:

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { fetchUserData } from '@/api/user'

// 用户功能
const userId = ref(1)
const userData = ref(null)
const userError = ref(null)
const isLoading = ref(false)

async function loadUser() {
  isLoading.value = true
  try {
    userData.value = await fetchUserData(userId.value)
  } catch (error) {
    userError.value = error
  } finally {
    isLoading.value = false
  }
}

// 计数功能
const count = ref(0)
const doubleCount = computed(() => count.value * 2)

function increment() {
  count.value++
}

// 生命周期
onMounted(() => {
  loadUser()
})
</script>

3. 使用组合函数(Composables)提取和重用逻辑

组合函数是组合式 API 最强大的功能之一,它让你能够提取和重用逻辑:

js
// useUser.js
import { ref } from 'vue'
import { fetchUserData } from '@/api/user'

export function useUser(initialUserId = 1) {
  const userId = ref(initialUserId)
  const userData = ref(null)
  const userError = ref(null)
  const isLoading = ref(false)

  async function loadUser() {
    isLoading.value = true
    try {
      userData.value = await fetchUserData(userId.value)
    } catch (error) {
      userError.value = error
    } finally {
      isLoading.value = false
    }
  }

  return {
    userId,
    userData,
    userError,
    isLoading,
    loadUser
  }
}

然后在组件中使用:

vue
<script setup>
import { useUser } from '@/composables/useUser'

const { userId, userData, userError, isLoading, loadUser } = useUser()

// 使用解构返回的变量和方法
</script>

4. 使用 provideinject 管理跨层级的状态

当你需要在多个组件之间共享状态时,provideinject 是比 props 更好的选择:

vue
<!-- ParentComponent.vue -->
<script setup>
import { provide, readonly, ref } from 'vue'

const theme = ref('light')

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// 提供只读值防止子组件修改
provide('theme', readonly(theme))
// 提供方法让子组件可以修改值
provide('toggleTheme', toggleTheme)
</script>

<!-- ChildComponent.vue -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>

5. 使用 definePropsdefineEmits 处理组件通信

<script setup> 中,使用 definePropsdefineEmits 替代 propsemits 选项:

vue
<script setup>
const props = defineProps({
  modelValue: {
    type: String,
    required: true
  },
  placeholder: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:modelValue', 'focus', 'blur'])

function updateValue(event) {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <input
    :value="modelValue"
    :placeholder="placeholder"
    @input="updateValue"
    @focus="emit('focus', $event)"
    @blur="emit('blur', $event)"
  />
</template>

6. 使用 TypeScript 增强类型安全

组合式 API 对 TypeScript 有很好的支持:

vue
<script setup lang="ts">
import { ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

const user = ref<User | null>(null)

const props = defineProps<{
  initialCount: number
  step?: number
}>()

const count = ref(props.initialCount)
const step = computed(() => props.step ?? 1)

function increment() {
  count.value += step.value
}

// 使用 defineEmits 的类型版本
const emit = defineEmits<{
  (e: 'change', count: number): void
  (e: 'reset'): void
}>()

function reset() {
  count.value = props.initialCount
  emit('reset')
}
</script>

高级模式和技巧

异步组件和加载状态管理

vue
<script setup>
import { ref, onMounted } from 'vue'

const data = ref(null)
const error = ref(null)
const isLoading = ref(false)

async function fetchData() {
  isLoading.value = true
  try {
    const response = await fetch('https://api.example.com/data')
    data.value = await response.json()
  } catch (e) {
    error.value = e
  } finally {
    isLoading.value = false
  }
}

onMounted(fetchData)
</script>

<template>
  <div>
    <div v-if="isLoading">Loading...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <div v-else-if="data">
      <!-- 渲染数据 -->
      <pre>{{ data }}</pre>
    </div>
    <div v-else>No data</div>
  </div>
</template>

这种模式可以进一步提取为可复用的组合函数:

js
// useAsync.js
import { ref } from 'vue'

export function useAsync(asyncFunction) {
  const data = ref(null)
  const error = ref(null)
  const isLoading = ref(false)

  async function execute(...args) {
    isLoading.value = true
    data.value = null
    error.value = null
    
    try {
      data.value = await asyncFunction(...args)
    } catch (e) {
      error.value = e
    } finally {
      isLoading.value = false
    }
  }

  return {
    data,
    error,
    isLoading,
    execute
  }
}

响应式状态管理

对于简单的状态管理,可以使用 reactiveprovide/inject

js
// store.js
import { reactive, readonly } from 'vue'

const state = reactive({
  count: 0,
  users: []
})

// 只导出只读状态,防止直接修改
export const useStore = () => {
  const increment = () => {
    state.count++
  }
  
  const decrement = () => {
    state.count--
  }
  
  const addUser = (user) => {
    state.users.push(user)
  }
  
  return {
    state: readonly(state),
    increment,
    decrement,
    addUser
  }
}
vue
<!-- App.vue -->
<script setup>
import { provide } from 'vue'
import { useStore } from './store'

const store = useStore()
provide('store', store)
</script>

<!-- ChildComponent.vue -->
<script setup>
import { inject } from 'vue'

const { state, increment } = inject('store')
</script>

<template>
  <div>
    <p>Count: {{ state.count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

使用 watchEffect 处理副作用

watchEffect 是一个强大的 API,适合处理副作用:

vue
<script setup>
import { ref, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref(null)

watchEffect(async () => {
  // 会自动追踪 userId.value 的依赖
  userData.value = await fetch(`/api/users/${userId.value}`)
    .then(r => r.json())
})

// 更改 userId 将自动触发 watchEffect 重新执行
function nextUser() {
  userId.value++
}
</script>

使用 toRefs 解构保持响应性

当你需要解构一个响应式对象但又想保持响应性时,toRefs 是最佳选择:

vue
<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({
  name: 'John',
  age: 30
})

// 解构后 name 和 age 仍然是响应式的
const { name, age } = toRefs(state)

function updateName(newName) {
  name.value = newName  // 会更新 state.name
}
</script>

使用 shallowRefshallowReactive 优化性能

对于大对象或仅需要跟踪引用变化的场景,可以使用浅层响应式 API:

vue
<script setup>
import { shallowRef, shallowReactive } from 'vue'

// 只有 .value 的变化会被追踪,不会深度追踪对象属性
const userData = shallowRef({ name: 'John', age: 30 })

// 只有对象本身的属性变化会被追踪,不会追踪嵌套对象
const options = shallowReactive({
  theme: 'dark',
  settings: { notification: true }
})

// 更改 .value 会触发更新
userData.value = { name: 'Jane', age: 28 }

// 更改顶层属性会触发更新
options.theme = 'light'

// 更改嵌套属性不会触发更新
options.settings.notification = false
</script>

常见陷阱和解决方案

1. 引用响应式变量时忘记 .value

js
const count = ref(0)

// 错误 - 不会改变响应式状态
function increment() {
  count++  // 应该是 count.value++
}

// 正确
function increment() {
  count.value++
}

2. 解构响应式对象导致丢失响应性

js
const state = reactive({ count: 0 })

// 错误 - count 不再是响应式的
const { count } = state

// 正确 - 使用 toRefs
const { count } = toRefs(state)

// 或者直接在模板中访问
// <div>{{ state.count }}</div>

3. 初始化 ref 时值类型不匹配

js
// 错误 - 后续将字符串赋值给数字 ref 会导致意外行为
const count = ref(0)
count.value = '1'  // 不会报错,但可能导致问题

// 正确 - 使用 TypeScript
const count = ref<number>(0)
count.value = '1'  // TypeScript 会报错

4. 组合函数内部使用生命周期钩子

js
// useUser.js - 错误
import { ref, onMounted } from 'vue'

export function useUser() {
  const user = ref(null)
  
  // 错误 - 生命周期钩子应该在 setup 或 <script setup> 中直接调用
  onMounted(() => {
    // 获取用户
  })
  
  return { user }
}

// 正确 - 返回一个可以在组件中调用的初始化函数
export function useUser() {
  const user = ref(null)
  
  function loadUser() {
    // 获取用户
  }
  
  return { 
    user,
    loadUser // 组件可以在 onMounted 中调用
  }
}

性能优化

1. 使用 computed 缓存计算结果

vue
<script setup>
import { ref, computed } from 'vue'

const items = ref([1, 2, 3, 4, 5])

// 不好 - 每次重新渲染都会重新计算
const doubledBad = () => items.value.map(item => item * 2)

// 好 - 只有当 items 变化时才会重新计算
const doubledGood = computed(() => items.value.map(item => item * 2))
</script>

2. 使用 v-memo 减少不必要的组件重新渲染

vue
<template>
  <div>
    <!-- 只有当 item.id 变化时才会重新渲染 -->
    <div v-for="item in items" :key="item.id" v-memo="[item.id]">
      {{ expensiveOperation(item) }}
    </div>
  </div>
</template>

3. 使用 v-once 渲染一次性内容

vue
<template>
  <div>
    <!-- 只渲染一次,永不更新 -->
    <header v-once>
      <h1>{{ title }}</h1>
      <p>{{ description }}</p>
    </header>
    
    <!-- 动态内容 -->
    <main>{{ dynamicContent }}</main>
  </div>
</template>

4. 使用 defineAsyncComponent 延迟加载组件

vue
<script setup>
import { defineAsyncComponent } from 'vue'

// 异步加载重量级组件
const HeavyComponent = defineAsyncComponent(() => 
  import('./HeavyComponent.vue')
)
</script>

工具和生态系统

1. 使用 Volar 提升开发体验

Volar 是专为 Vue 3 设计的 VS Code 插件,它提供了:

  • 完整的组合式 API 类型支持
  • 模板表达式类型检查
  • 组件 props 类型检查
  • 定义块跳转

2. 与 TypeScript 的配合使用

vue
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'

// 使用类型声明 props
const props = defineProps<{
  title: string
  items?: string[]
}>()

// 使用类型声明 emits
const emit = defineEmits<{
  (e: 'select', id: number): void
  (e: 'update', value: string): void
}>()

// 为 props 提供默认值
withDefaults(defineProps<{
  title: string
  count?: number
}>(), {
  count: 0
})
</script>

3. 与 Pinia 结合使用

Pinia 是 Vue 团队推荐的状态管理库,它与组合式 API 完美结合:

js
// store.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})
vue
<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()

// 直接访问 state
console.log(counter.count)

// 调用 action
counter.increment()

// 访问 getter
console.log(counter.doubleCount)
</script>

结论

Vue3 的组合式 API 为构建复杂应用提供了更好的工具。通过按逻辑关注点组织代码、提取可重用的组合函数,以及利用 TypeScript 的类型系统,我们可以编写更可维护和健壮的 Vue 应用。

虽然学习曲线可能比选项式 API 更陡峭,但长期来看,组合式 API 提供的灵活性和可扩展性是值得的投资。随着项目规模和复杂度增长,组合式 API 的优势会变得更加明显。

最后,记住没有万能的解决方案 - 根据项目需求选择最合适的工具和模式,有时候混合使用选项式 API 和组合式 API 可能是最佳选择。

参考资料