前端开发··2 阅读·预计 17 分钟

Vue 3 组合式 API 实战:从 Options API 迁移到 Composition API 的完整指南

引言

Vue 3 的 Composition API 已经发布多年,但许多团队仍在使用 Options API。这不是因为 Composition API 不好,而是因为迁移成本和认知负担。本文将从实战角度出发,带你理解为什么 Composition API 是更好的选择,以及如何平滑迁移。

为什么需要 Composition API?

Options API 的问题

在大型组件中,Options API 有一个致命问题:逻辑碎片化

<script>
export default {
  data() {
    return {
      searchQuery: '',
      results: [],
      loading: false,
      error: null,
      page: 1,
      totalPages: 0
    }
  },
  computed: {
    filteredResults() {
      return this.results.filter(item => 
        item.name.includes(this.searchQuery)
      )
    }
  },
  watch: {
    searchQuery: 'debouncedSearch',
    page: 'fetchData'
  },
  methods: {
    async fetchData() {
      this.loading = true
      this.error = null
      try {
        const res = await fetch(`/api/search?q=${this.searchQuery}&page=${this.page}`)
        const data = await res.json()
        this.results = data.items
        this.totalPages = data.totalPages
      } catch (e) {
        this.error = e.message
      } finally {
        this.loading = false
      }
    },
    debouncedSearch() {
      // 防抖逻辑
    },
    nextPage() {
      if (this.page < this.totalPages) {
        this.page++
      }
    }
  },
  mounted() {
    this.fetchData()
  }
}
</script>

这个组件的问题很明显:

  1. 搜索相关的逻辑(searchQuery、debouncedSearch、filteredResults)分散在 data、methods、computed、watch 中
  2. 分页逻辑(page、totalPages、nextPage)也分散各处
  3. 数据获取逻辑(loading、error、fetchData)同样分散

当组件变大时,你需要不断上下滚动来理解一个功能的完整逻辑。

Composition API 的解决方案

Composition API 允许我们将相关逻辑组合在一起:

<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useDebounceFn } from '@vueuse/core'

// ===== 搜索功能 =====
const searchQuery = ref('')
const debouncedSearch = useDebounceFn(() => {
  page.value = 1
  fetchData()
}, 300)
watch(searchQuery, debouncedSearch)

// ===== 分页功能 =====
const page = ref(1)
const totalPages = ref(0)
const nextPage = () => {
  if (page.value < totalPages.value) {
    page.value++
  }
}

// ===== 数据获取 =====
const results = ref([])
const loading = ref(false)
const error = ref(null)

const filteredResults = computed(() => 
  results.value.filter(item => 
    item.name.includes(searchQuery.value)
  )
)

async function fetchData() {
  loading.value = true
  error.value = null
  try {
    const res = await fetch(`/api/search?q=${searchQuery.value}&page=${page.value}`)
    const data = await res.json()
    results.value = data.items
    totalPages.value = data.totalPages
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false
  }
}

watch(page, fetchData)
onMounted(fetchData)
</script>

现在,相关代码都在一起。你可以一眼看出每个功能的完整逻辑。

实战:提取可复用逻辑

Composition API 的真正威力在于提取可复用的组合式函数(Composables)。

示例:useSearch

// composables/useSearch.ts
import { ref, computed } from 'vue'
import { useDebounceFn } from '@vueuse/core'

interface UseSearchOptions<T> {
  searchFn: (query: string) => Promise<T[]>
  debounceMs?: number
}

export function useSearch<T>(options: UseSearchOptions<T>) {
  const { searchFn, debounceMs = 300 } = options
  
  const query = ref('')
  const results = ref<T[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  const debouncedSearch = useDebounceFn(async () => {
    if (!query.value.trim()) {
      results.value = []
      return
    }
    
    loading.value = true
    error.value = null
    
    try {
      results.value = await searchFn(query.value)
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Search failed'
      results.value = []
    } finally {
      loading.value = false
    }
  }, debounceMs)
  
  const filteredResults = computed(() => {
    return results.value
  })
  
  const clear = () => {
    query.value = ''
    results.value = []
    error.value = null
  }
  
  return {
    query,
    results: filteredResults,
    loading,
    error,
    search: debouncedSearch,
    clear
  }
}

示例:usePagination

// composables/usePagination.ts
import { ref, computed } from 'vue'

interface UsePaginationOptions {
  pageSize?: number
  total?: number
}

export function usePagination(options: UsePaginationOptions = {}) {
  const { pageSize = 10, total = 0 } = options
  
  const currentPage = ref(1)
  const totalItems = ref(total)
  
  const totalPages = computed(() => 
    Math.ceil(totalItems.value / pageSize)
  )
  
  const hasNextPage = computed(() => 
    currentPage.value < totalPages.value
  )
  
  const hasPrevPage = computed(() => 
    currentPage.value > 1
  )
  
  const nextPage = () => {
    if (hasNextPage.value) {
      currentPage.value++
    }
  }
  
  const prevPage = () => {
    if (hasPrevPage.value) {
      currentPage.value--
    }
  }
  
  const goToPage = (page: number) => {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }
  
  const offset = computed(() => (currentPage.value - 1) * pageSize)
  
  const reset = () => {
    currentPage.value = 1
  }
  
  return {
    currentPage,
    totalPages,
    totalItems,
    pageSize,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    goToPage,
    offset,
    reset
  }
}

组合多个 Composables

真正的威力在于组合使用:

<script setup>
import { watch } from 'vue'
import { useSearch } from './composables/useSearch'
import { usePagination } from './composables/usePagination'

const { 
  query, 
  results, 
  loading, 
  error,
  search 
} = useSearch({
  searchFn: async (q) => {
    const res = await fetch(`/api/items?q=${q}&offset=${pagination.offset}&limit=${pagination.pageSize}`)
    const data = await res.json()
    pagination.totalItems = data.total
    return data.items
  }
})

const pagination = usePagination({ pageSize: 20 })

// 页码变化时重新搜索
watch(() => pagination.currentPage, () => {
  if (query.value) {
    search()
  }
})
</script>

迁移策略

1. 新功能使用 Composition API

不要重写现有代码,新功能直接使用 <script setup>

2. 逐步提取 Composables

找到重复逻辑,提取为 Composables。

3. 使用 defineProps 和 defineEmits

<script setup>
const props = defineProps<{
  title: string
  modelValue?: string
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

4. 使用 provide/inject 替代全局状态

const UserKey: InjectionKey<UserContext> = Symbol()

export function provideUser(user: UserContext) {
  provide(UserKey, user)
}

export function useUser() {
  const user = inject(UserKey)
  if (!user) {
    throw new Error('useUser must be used within provideUser')
  }
  return user
}

常见陷阱

1. 解构丢失响应式

// ❌ 错误:解构后失去响应式
const { count, increment } = useCounter()
count // 不是响应式的

// ✅ 正确:保持 ref 的引用
const counter = useCounter()
counter.count // 响应式

2. 忘记解包 ref

// ❌ 错误
const count = ref(0)
console.log(count) // Ref 对象

// ✅ 正确
console.log(count.value) // 0

3. 在异步函数中使用响应式数据

// ❌ 错误:闭包捕获了旧值
async function fetchData() {
  const currentPage = page.value
  setTimeout(() => {
    console.log(currentPage) // 可能是旧值
  }, 1000)
}

// ✅ 正确:始终访问最新值
async function fetchData() {
  setTimeout(() => {
    console.log(page.value) // 最新值
  }, 1000)
}

总结

Composition API 不是银弹,但它确实解决了 Options API 在大型组件中的逻辑碎片化问题。通过提取可复用的 Composables,你可以构建更模块化、更可测试的代码。

迁移不需要一步到位。新功能使用 Composition API,逐步重构旧代码,最终你会发现整个项目的代码质量都有提升。

记住:好的代码是读得懂的代码。Composition API 让相关逻辑聚集在一起,这就是它最大的价值。

0 评论

评论区

登录 后参与评论