Vue 3 Composition API:逻辑组织的新范式

深入解析 Vue 3 Composition API 的设计理念:为什么它比选项式 API 更适合复杂逻辑组织,setup 函数和 script setup 的使用场景,reactive/ref/computed/watch 的机制,以及 composables 和 hooks 的对比。

Composition API 解决的问题

Vue 2 的选项式 API(Options API) 以数据(data)、方法(methods)、计算属性(computed)、生命周期钩子(hooks)为组织代码的单位。当组件逻辑简单时,这种组织方式清晰直观——你知道自己应该去哪里找某个功能的代码。

但当组件变得复杂时,问题出现了:

  • 逻辑关注点分散:同一个功能的代码散落在 datamethodscomputedwatch 四个选项中
  • 代码复用困难:mixins 有命名冲突和隐式依赖问题,高阶组件(HOC)有嵌套地狱
  • 类型推导不完美:TypeScript 支持是后期追加的,与 Vue 2 的响应式系统结合有局限

Composition API(组合式 API)的诞生,就是为了解决这些问题。它以逻辑关注点(feature)为组织代码的单位,而不是以选项类型为单位。


一、为什么 Composition API 更适合组织逻辑

1.1 选项式 API 的逻辑分散问题

// Vue 2 选项式 API:同一功能的代码分散在多处
export default {
  data() {
    return {
      // 搜索相关的数据
      searchQuery: '',
      searchResults: [],
      isSearching: false,

      // 分页相关的数据
      currentPage: 1,
      pageSize: 10,

      // 排序相关的数据
      sortBy: 'date',
      sortOrder: 'desc'
    };
  },

  computed: {
    // 搜索相关的计算属性
    hasSearchResults() {
      return this.searchResults.length > 0;
    },

    // 分页相关的计算属性
    totalPages() {
      return Math.ceil(this.searchResults.length / this.pageSize);
    }
  },

  methods: {
    // 搜索相关的方法
    async performSearch() { /* ... */ },

    // 分页相关的方法
    changePage(page) { /* ... */ },

    // 排序相关的方法
    changeSort(field) { /* ... */ }
  },

  watch: {
    // 搜索相关的 watcher
    searchQuery() { this.performSearch(); }
  }
};

在这个例子里,searchQuerysearchResultsisSearchingperformSearchhasSearchResultswatcher 都被"打散"在不同的选项中。如果你需要修改搜索功能,必须在四个选项中来回跳转。

1.2 Composition API 的逻辑内聚

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

// 搜索功能的逻辑内聚在一起
const searchQuery = ref('');
const searchResults = ref([]);
const isSearching = ref(false);
const hasSearchResults = computed(() => searchResults.value.length > 0);

async function performSearch() {
  isSearching.value = true;
  searchResults.value = await api.search(searchQuery.value);
  isSearching.value = false;
}

watch(searchQuery, performSearch);

// 分页功能的逻辑内聚在一起
const currentPage = ref(1);
const pageSize = ref(10);
const totalPages = computed(() =>
  Math.ceil(searchResults.value.length / pageSize.value)
);

function changePage(page) {
  currentPage.value = page;
}

// 排序功能的逻辑内聚在一起
const sortBy = ref('date');
const sortOrder = ref('desc');

function changeSort(field) {
  if (sortBy.value === field) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
  } else {
    sortBy.value = field;
    sortOrder.value = 'desc';
  }
}
</script>

现在,searchpaginationsort 三个功能的代码各自内聚在一起,修改任何一个功能只需要在一个代码块中完成。


二、setup 函数和 script setup

2.1 setup 函数

setup 是 Composition API 的入口函数,在组件实例创建之前执行(beforeCreate 之前)。此时组件的响应式系统已经初始化,但 DOM 还未创建。

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

export default {
  setup() {
    const count = ref(0);

    // 访问 props
    // setup 函数的第一个参数是 props
    // 第二个参数是 context(包含 attrs、slots、emit、expose)
    function increment() {
      count.value++;
    }

    onMounted(() => {
      console.log('Component mounted');
    });

    // 通过 return 语句暴露给模板
    return {
      count,
      increment
    };
  }
};
</script>

2.2 script setup:更简洁的语法

<script setup>setup 函数的语法糖。在 script setup 中,顶层代码直接在 setup 函数作用域内执行,不需要显式 return

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

// count 和 increment 自动暴露给模板
const count = ref(0);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('Component mounted');
});

// 不需要 return 语句
</script>

script setup 的优势

  • 代码更简洁
  • 自动暴露给模板的变量不需要 return
  • 更好的 IDE 支持(Vue Volar/JSX 插件提供更准确的类型推导)
  • 略微更好的性能(不需要包装函数)

2.3 两者如何选择

  • 新项目:直接用 <script setup>,它是 Vue 3 的推荐语法
  • 需要与其他选项混合:用 setup 函数(可以与 datamethods 等共存)
  • TypeScript 优先<script setup> 的类型推导更完整

三、响应式系统核心:reactive/ref/computed/watch

3.1 ref:基础类型的响应式引用

ref 创建了一个引用(reference),可以包装任何值(基础类型和对象)。访问或修改值需要 .value

import { ref } from 'vue';

const count = ref(0);
console.log(count.value); // 0

count.value++;
console.log(count.value); // 1

// 在模板中自动解包,不需要 .value
// <template>{{ count }}</template> 渲染为 1

ref 的原理:创建一个包含 .value 属性的响应式对象,在模板中自动解包。

3.2 reactive:对象的深度响应式

reactive 创建一个深度响应式的对象。深层所有属性都会自动是响应式的。

import { reactive } from 'vue';

const state = reactive({
  count: 0,
  user: {
    name: 'Alice',
    age: 30
  }
});

state.count++;
state.user.name = 'Bob'; // 深层属性也是响应式的

ref vs reactive 的选择

场景 推荐
基础类型(string、number、boolean) ref
对象类型 reactiveref
数组 ref
需要替换整个响应式对象 ref
保持解构响应式 reactive + toRefs

3.3 computed:派生值

computed 创建基于其他响应式数据的派生值。只有当依赖变化时才会重新计算。

import { ref, computed } from 'vue';

const firstName = ref('John');
const lastName = ref('Doe');

// 只读计算属性
const fullName = computed(() => `${firstName.value} ${lastName.value}`);

// 可写计算属性
const fullNameWritable = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    [firstName.value, lastName.value] = value.split(' ');
  }
});

3.4 watch 和 watchEffect:响应变化

watch:显式指定要监听的数据源,只有这些数据变化时才执行回调。

import { ref, watch } from 'vue';

const count = ref(0);

// 监听单个 ref
watch(count, (newValue, oldValue) => {
  console.log(`count changed from ${oldValue} to ${newValue}`);
});

// 监听多个数据源
const firstName = ref('');
const lastName = ref('');

watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
  console.log(`Name changed: ${oldFirst} ${oldLast}${newFirst} ${newLast}`);
}, { immediate: true }); // immediate: true 立即执行一次

watchEffect:立即执行一次回调,追踪回调中使用的所有响应式数据,自动收集依赖。

import { ref, watchEffect } from 'vue';

const userId = ref(1);

watchEffect(() => {
  // 立即执行,并自动追踪 userId.value 的依赖
  console.log(`Fetching user ${userId.value}`);
  fetchUser(userId.value);
});
// 当 userId.value 变化时,上面的函数会自动重新执行

watch vs watchEffect 的选择

  • watch:需要显式控制监听哪些数据、访问旧值、懒执行(默认)
  • watchEffect:需要立即执行、关心回调中使用的所有响应式数据

四、Composables:逻辑复用的新方式

4.1 什么是 Composable

Composable(组合式函数)是 Vue 3 推荐的逻辑复用模式。它是一个使用 Composition API 的函数,封装了可复用的逻辑,可以被多个组件共享。

// composables/useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const double = computed(() => count.value * 2);

  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  function reset() {
    count.value = initialValue;
  }

  return {
    count,
    double,
    increment,
    decrement,
    reset
  };
}
<script setup>
import { useCounter } from '@/composables/useCounter';

const { count, double, increment, decrement } = useCounter(10);
</script>

4.2 Composable 的命名约定

Composable 函数以 use 开头(use + 名词/名词短语),这是社区共识,让其他开发者一眼认出这是 Composition API 的逻辑复用:

  • useWindowSize — 获取窗口尺寸
  • useLocalStorage — 响应式 localStorage
  • useFetch — 数据获取封装
  • useDebounce — 防抖封装

4.3 Composable 的状态共享

默认情况下,每个组件调用 Composable 获得独立的响应式状态。如果需要跨组件共享状态

// composables/useCounterStore.js
// 模块级状态:所有使用这个 Composable 的组件共享同一个状态
const count = ref(0);

export function useCounterStore() {
  function increment() {
    count.value++;
  }

  return { count, increment };
}
<!-- ComponentA.vue -->
<script setup>
import { useCounterStore } from '@/composables/useCounterStore';
const { count, increment } = useCounterStore();
</script>

<!-- ComponentB.vue -->
<script setup>
import { useCounterStore } from '@/composables/useCounterStore';
const { count, increment } = useCounterStore();
<!-- count 在两个组件间共享,改变一个,另一个也变化 -->
</script>

4.4 Composable vs React Hooks

Vue Composable 和 React Hooks 都是为了逻辑复用,但有本质区别:

方面 Vue Composables React Hooks
调用时机 组件定义时(setup)同步调用 每次渲染时同步调用
条件调用 不能条件调用(必须在组件顶层) 可以在条件语句中调用(但有 eslint 规则禁止)
状态管理 每次调用创建独立状态 每次调用可能返回同一个状态(useRef)
生命周期 使用 Vue 的生命周期钩子 使用 React 的 useEffect
响应式系统 Vue 响应式系统 React 自己的状态系统

关键区别:React Hooks 有"Hooks 的规则"(不能在条件语句中使用,必须在顶层调用),因为 React 需要在渲染之间保持 Hook 的调用顺序。Vue 的 Composable 只是普通函数,不受这些限制。


五、生命周期钩子在 Composition API 中的使用

<script setup>
import { onMounted, onUpdated, onUnmounted, onBeforeMount, onBeforeUpdate, onBeforeUnmount } from 'vue';

onBeforeMount(() => {
  // 组件挂载之前调用
});

onMounted(() => {
  // 组件挂载之后调用
  console.log('Component mounted');
});

onBeforeUpdate(() => {
  // 组件更新之前调用
});

onUpdated(() => {
  // 组件更新之后调用
});

onBeforeUnmount(() => {
  // 组件卸载之前调用
});

onUnmounted(() => {
  // 组件卸载之后调用
  console.log('Component unmounted');
});

六、Provide/Inject:跨组件通信

Composition API 中的 provide/inject 用于深层组件通信:

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

const count = ref(0);

// 提供给所有后代组件
provide('count', count);
provide('increment', () => count.value++);
</script>
<!-- Child.vue -->
<script setup>
import { inject } from 'vue';

// 从祖先组件获取
const count = inject('count');
const increment = inject('increment');
</script>

七、面试高频问题

Q1: ref 和 reactive 的区别是什么?什么时候用哪个?

ref 用于基础类型,创建包含 .value 的响应式引用,在模板中自动解包。reactive 用于对象类型,创建深度响应式对象。实践中,基础类型用 ref,对象/数组用 refreactive(取决于是否需要替换整个对象)。

Q2: Composition API 相比选项式 API 的优势?

逻辑内聚(同一功能的代码在一起)、更好的 TypeScript 支持、更灵活的逻辑复用(composables vs mixins)、更好的代码组织(复杂组件更容易维护)。

Q3: watch 和 watchEffect 的区别?

watch 需要显式指定要监听的数据源,支持旧值,默认懒执行。watchEffect 立即执行并自动追踪依赖,不需要显式指定要监听的数据。

Q4: Composable 和 mixin 的区别?

Mixin 有命名冲突(多个 mixin 可能有同名属性)、隐式依赖(不清楚 mixin 依赖哪些数据)、不清晰的来源(难以知道某个属性来自哪个 mixin)。Composable 没有这些问题:返回值清晰、显式依赖、无命名冲突。


延展阅读