Skip to content

Composition API与响应式系统

✏️Composition API 是 Vue 3 最重要的新特性,也是面试中的高频考点。它不仅改变了我们组织代码的方式,还为逻辑复用提供了更优雅的解决方案。掌握它,你就掌握了 Vue 3 的精髓。

为什么需要 Composition API

概念

Composition API 的诞生是为了解决 Options API大型、复杂组件开发中遇到的痛点。

在 Options API 中,一个功能的实现逻辑(数据、方法、计算属性等)被迫分散在 datamethodscomputed 等不同选项中。当组件变得复杂时,相关联的代码被拆分,而不相关的代码却聚合在一起。这导致开发者在理解和维护某个特定功能时,需要在单个文件中反复跳转,极大地降低了开发效率和代码的可读性。

Composition API 提出了一个核心思想:根据逻辑功能来组织代码,而不是根据选项类型

代码示例

❌ Options API 的痛点:逻辑分散

在下面的示例中,用户文章搜索这三个功能的逻辑分别散落在 datacomputedmethods 中。

javascript
// 示例:一个包含用户、文章、搜索三个功能的复杂组件
export default {
  // 1. 数据定义区
  data() {
    return {
      // 用户相关数据
      user: null,
      userLoading: false,

      // 文章相关数据
      articles: [],
      articlesLoading: false,

      // 搜索相关数据
      searchQuery: '',
    }
  },

  // 2. 计算属性区
  computed: {
    // 用户相关计算属性
    userFullName() {
      /* ... */
    },
    // 文章相关计算属性
    publishedArticles() {
      /* ... */
    },
  },

  // 3. 方法区
  methods: {
    // 用户相关方法
    async fetchUser() { /* ... */ },
    updateUser() { /* ... */ },

    // 文章相关方法
    async fetchArticles() { /* ... */ },
    deleteArticle() { /* ... */ },

    // 搜索相关方法
    performSearch() { /* ... */ },
  }
  // 😩 当需要修改“用户”功能时,你需要在 data, computed, methods 之间来回跳转。
}

✅ Composition API 的优势:逻辑聚合

使用 Composition API,我们可以将同一个功能的代码封装在独立的函数(称为 "Composable")中,然后在 setup 函数中组合使用。

javascript
import { ref, computed } from 'vue'

// ------------------------------------
// 功能一:用户管理 (User Logic)
// ------------------------------------
function useUser() {
  // 数据
  const user = ref(null)
  const userLoading = ref(false)

  // 计算属性
  const userFullName = computed(() => /* ... */)

  // 方法
  async function fetchUser() { /* ... */ }
  function updateUser(data) { /* ... */ }

  // 将所有与用户相关的 API 一起返回
  return { user, userLoading, userFullName, fetchUser, updateUser }
}

// ------------------------------------
// 功能二:文章管理 (Articles Logic)
// ------------------------------------
function useArticles() {
  const articles = ref([])
  const articlesLoading = ref(false)
  const publishedArticles = computed(() => /* ... */)
  async function fetchArticles() { /* ... */ }
  function deleteArticle(id) { /* ... */ }

  return { articles, articlesLoading, publishedArticles, fetchArticles, deleteArticle }
}


// ------------------------------------
// 在组件中组合使用
// ------------------------------------
export default {
  setup() {
    // 引入用户功能
    const { user, userFullName, fetchUser } = useUser()
    // 引入文章功能
    const { articles, publishedArticles, fetchArticles } = useArticles()

    // 😊 相关逻辑聚合,不相关逻辑分离,代码组织清晰!

    // 将需要暴露给模板的变量和方法返回
    return {
      user,
      userFullName,
      articles,
      publishedArticles,
      fetchUser,
      fetchArticles
    }
  }
}

深入解析

Composition API 带来了思维模式的转变:

  1. 更优的逻辑复用:通过组合函数 (Composables) 来复用逻辑,取代了 mixins。Composables 是普通的 JavaScript 函数,解决了 mixins 的命名冲突、数据来源不清晰和隐式依赖等问题。

  2. 更好的类型推断:代码基于普通的函数和变量,TypeScript 可以完美地进行类型推断,解决了 Options API 中 this 的类型推断难题。

  3. 更灵活的代码组织:你可以将复杂逻辑拆分到多个独立的文件中,让大型项目维护起来更加轻松。

  4. 更小的打包体积:Composition API 的函数是按需导入的,例如 ref, computed 等。没有用到的代码可以被构建工具进行 "Tree-shaking",从而减小最终的打包体积。

面试建议

问:"为什么 Vue 3 要引入 Composition API?它解决了什么问题?"

回答要点:

  1. 先肯定 Options API:"Options API 在中小型项目中非常直观易懂,这是 Vue 易学性的重要体现。"

  2. 再指出其痛点:"然而,在开发大型复杂组件时,Options API 会导致逻辑关注点分散,相关代码被拆分到 data, methods 等不同选项中,难以维护。同时,依赖 mixins 的逻辑复用方案存在命名冲突和数据来源不清晰等问题,且对 TypeScript 的类型推断不够友好。"

  3. 最后引出解决方案:"Composition API 正是为了解决这些问题而生的。它允许我们通过组合函数的方式,将相关逻辑聚合在一起,实现了更清晰、更可维护、类型更安全的代码组织和逻辑复用模式。"

  4. 补充说明:"需要强调的是,Composition API 是对 Options API 的补充而非替代,开发者可以根据项目复杂度和团队习惯灵活选择。"

核心 API 详解

ref() - 创建基础类型的响应式引用

ref 用于将一个基础类型的值(如 string, number, boolean)包装成一个响应式的对象。

基本用法

javascript
// MyComponent.vue

import { ref } from 'vue'

export default {
  setup() {
    // ref() 返回一个包含 .value 属性的响应式对象
    const count = ref(0)
    const message = ref('Hello Vue 3')

    // 在 <script> 中,必须通过 .value 访问或修改其值
    console.log(count.value) // 输出: 0

    const increment = () => {
      count.value++
    }

    // 将 ref 对象返回,使其在模板中可用
    return {
      count,
      message,
      increment
    }
  }
}
html
<template>
  <!-- 在模板中使用时,Vue 会自动“解包” (unwrap),无需 .value -->
  <div>{{ count }}</div>         <!-- 渲染: 0 -->
  <div>{{ message }}</div>       <!-- 渲染: Hello Vue 3 -->
  <button @click="increment">+1</button>
</template>
✏️面试标准回答
ref 用于创建一个响应式的引用,通常用于基本类型数据。它返回一个对象,这个对象只有一个 .value 属性,指向内部的值。在 JavaScript 逻辑中,我们必须通过 .value 来访问和修改这个值;但在 Vue 模板中,Vue 会自动解包,我们可以直接使用变量名。ref 的响应式能力是基于 Proxy 实现的,当 .value 被修改时,会触发所有依赖它的更新。

reactive() - 创建对象的响应式代理

reactive 用于创建一个对象的深度响应式代理。

基本用法

javascript
import { reactive } from 'vue'

export default {
  setup() {
    // reactive() 接收一个对象,并返回其响应式代理
    const state = reactive({
      count: 0,
      user: {
        name: 'John',
        age: 25
      },
      items: []
    })

    const updateUser = () => {
      // 可以直接修改属性,无需 .value
      state.user.name = 'Jane'
      state.items.push({ id: 1, title: 'New Item' })
    }

    // 返回整个 state 对象
    return {
      state,
      updateUser
    }
  }
}

ref vs reactive 选择指南

  • ref:推荐用于处理基本数据类型(String, Number, Boolean)。

  • reactive:推荐用于处理对象或数组。

javascript
// ✅ 推荐用法,代码语义更清晰
const count = ref(0);
const user = reactive({ name: 'John' });

// ❌ 不推荐
const countAsObject = reactive({ value: 0 }); // 用 reactive 包装基本类型,过于繁琐
const userAsRef = ref({ name: 'John' });      // 用 ref 包装对象,每次访问都需要 .value (userAsRef.value.name)
✏️面试标准回答
reactive 用于创建对象的响应式代理,它会深度地遍历对象所有属性,将其转换为 Proxy。与 ref 不同,reactive 返回的代理对象可以直接访问和修改其属性,无需 .value。 选择原则:坚持“基本类型用 ref,对象类型用 reactive”的约定,可以使代码的意图更加清晰,提高可读性。

computed() - 计算属性

computed 用于创建一个根据其他响应式数据派生出来的值。它具有缓存特性。

基本用法

javascript
import { ref, computed } from 'vue'

export default {
  setup() {
    const firstName = ref('John')
    const lastName = ref('Doe')

    // 1. 只读的计算属性 (传入一个 getter 函数)
    // 只有当 firstName.value 或 lastName.value 变化时才会重新计算
    const fullName = computed(() => `${firstName.value} ${lastName.value}`)

    // 2. 可写的计算属性 (传入一个包含 get 和 set 的对象)
    const editableName = computed({
      get: () => `${firstName.value} ${lastName.value}`,
      set: (newValue) => {
        // 当你尝试给 editableName.value 赋值时,set 函数会被调用
        const names = newValue.split(' ')
        firstName.value = names[0]
        lastName.value = names[1] || ''
      }
    })

    return {
      fullName,
      editableName
    }
  }
}
✏️面试标准回答
computed 用于创建计算属性,它会基于其响应式依赖进行缓存。这意味着只要依赖项没有发生变化,多次访问计算属性会立即返回之前计算过的结果,而不会重新执行计算函数。这是它与 methods 的核心区别。computed 可以是只读的,也可以通过提供 get 和 set 函数来创建可写的计算属性。

watch() & watchEffect() - 侦听器

watch:明确指定侦听源

watch 需要明确指定要侦听的数据源,并在数据变化时执行回调函数。

javascript
import { ref, reactive, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const state = reactive({ name: 'John' })

    // 1. 侦听单个 ref
    watch(count, (newVal, oldVal) => {
      console.log(`count 从 ${oldVal} 变为 ${newVal}`)
    })

    // 2. 侦听 getter 函数,可以访问对象属性
    watch(() => state.name, (newName, oldName) => {
      console.log(`name 变化了: ${newName}`)
    })
    
    // 3. 侦听多个源
    watch([count, () => state.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log('count 或 name 发生了变化')
    })

    // 4. 选项:deep (深度侦听) 和 immediate (立即执行)
    watch(
      () => state,
      (newState, oldState) => { console.log('state 深度变化') },
      { deep: true, immediate: true } // immediate 会让 watch 在初始化时立即执行一次
    )
    
    return { count, state }
  }
}

watchEffect:自动追踪依赖

watchEffect 会立即执行一次,并自动追踪回调函数中所有使用到的响应式依赖。当任何依赖发生变化时,它会重新运行。

javascript
import { ref, watchEffect } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const message = ref('hello')

    // watchEffect 会立即执行,并自动追踪 count 和 message 的变化
    watchEffect(() => {
      // 在这个函数体内用到了 count.value 和 message.value
      // 所以 Vue 会自动侦听这两个响应式引用的变化
      console.log(`count: ${count.value}, message: ${message.value}`)
    })

    // 它等价于一个立即执行 (immediate: true) 且自动依赖收集的 watch
    
    return { count, message }
  }
}

对比

特性atchwatchEffect
侦听源需要手动指定自动追踪依赖,无需指定
执行时机默认在数据变化后执行立即执行一次,然后依赖变化后执行
回调参数可以访问新值和旧值无法访问旧值
使用场景需要精确控制侦听目标,或需要访问旧值时逻辑简单,只需依赖变化就执行副作用时
✏️面试标准回答
watch 和 watchEffect 都用于在数据变化时执行副作用。主要区别在于:watch 需要显式指定侦听的数据源,让你能精确控制副作用的触发时机,并且可以访问到新值和旧值。而 watchEffect 会自动收集其回调函数中的响应式依赖,并在初始化时立即执行一次,代码更简洁,但无法访问旧值。

响应式陷阱与解决方案

陷阱 1:解构 reactive 对象导致失去响应性

直接解构 reactive 对象会使其属性变为普通变量,从而失去响应性。

javascript
import { reactive, toRefs } from 'vue'

export default {
  setup() {
    const state = reactive({ count: 0, name: 'John' })

    // ❌ 错误:count 和 name 只是普通变量,不再具有响应性
    // 当 state.count 改变时,这里的 count 不会更新
    const { count, name } = state

    // ✅ 正确:使用 toRefs()
    // toRefs 会将 reactive 对象的每个属性都转换为一个 ref
    const stateAsRefs = toRefs(state)
    // 现在 stateAsRefs.count 和 stateAsRefs.name 都是 ref,可以在模板中安全使用

    return {
      // 如果直接返回 count, 它不是响应式的
      // 必须返回 toRefs 转换后的结果
      ...stateAsRefs // 使用扩展运算符将 { count: ref, name: ref } 返回
    }
  }
}

陷阱 2:错误地替换响应式对象

对于 ref 包裹的对象或数组,修改时必须通过 .value 属性进行

javascript
import { ref } from 'vue'

export default {
  setup() {
    let list = ref([1, 2, 3])

    const updateList = () => {
      // ❌ 错误:这会使 list 变量指向一个新的普通数组,原来的响应式引用丢失了
      // list = [4, 5, 6] 

      // ✅ 正确:始终通过 .value 来修改 ref 的值
      list.value = [4, 5, 6]
    }
    return { list, updateList }
  }
}

与 React Hooks 对比

特性Vue Composition APIReact Hooks
执行时机setup 函数只在组件创建时执行一次。Hooks 函数在每次组件渲染时都会执行。
响应式系统基于 Proxy 的响应式系统,数据变更自动触发更新。需要通过 useState 的 set 函数手动更新状态,触发重渲染。
依赖管理computed 和 watchEffect 自动追踪依赖,无需手动声明。useMemo, useCallback 需要手动管理依赖数组,否则可能导致性能问题或陈旧闭包。
心智负担心智负担较低,更接近原生 JavaScript 的编程体验。心智负担较高,需要理解闭包、依赖数组等规则。
123123123123123123123123123

高级应用:自定义组合函数

组合函数是 Composition API 的精髓,它允许我们将可复用的有状态逻辑封装起来。

示例:设计一个 useFetch 组合函数

javascript
// composables/useFetch.js

import { ref, watchEffect, toValue } from 'vue'

// 一个组合函数就是一个返回响应式状态的普通函数
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(true)

  const fetchData = async () => {
    // 重置状态
    loading.value = true
    error.value = null
    
    try {
      // toValue 可以将 ref 或 getter 解包为普通值
      const response = await fetch(toValue(url))
      if (!response.ok) throw new Error('Network response was not ok')
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  // 使用 watchEffect 侦听 URL 的变化
  // 如果 url 是一个 ref (例如 computed),当它变化时会自动重新 fetch
  watchEffect(fetchData)

  // 返回响应式状态和方法
  return { data, error, loading, refetch: fetchData }
}

使用示例

javascript
// MyComponent.vue

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

export default {
  setup() {
    const userId = ref(1)
    
    // 创建一个计算属性作为 URL,当 userId 变化时,URL 会自动更新
    const url = computed(() => `https://api.example.com/users/${userId.value}`)

    // 使用自定义的 useFetch Hook
    // 当 url 变化时,useFetch 内部的 watchEffect 会自动重新请求数据
    const { data: user, loading, error } = useFetch(url)

    const changeUser = () => {
      userId.value++ // 这会触发 url 的重新计算,进而触发 useFetch 重新执行
    }

    return { user, loading, error, changeUser }
  }
}

不知道说啥了很无语了