DevToolBox免费
博客

Vue Composition API 指南:响应式、组合式函数、Pinia、Vue Router 与性能优化

20 分钟阅读作者 DevToolBox Team

Vue 3 组合式 API 是构建 Vue 应用的现代方式。它提供了更好的 TypeScript 支持、通过组合函数实现更好的代码复用,以及更灵活的组件组织方式。本指南涵盖了从响应式原语到 Pinia、Vue Router 4、Vitest 测试和性能优化等高级模式,帮助你构建生产级 Vue 应用。

TL;DR: 组合式 API 用基于函数的 setup 逻辑替代了选项式 API。使用 ref() 处理原始值,reactive() 处理对象,computed() 处理派生状态。将可复用逻辑提取为组合函数。使用 Pinia 进行状态管理,Vue Router 4 进行导航,Vitest 进行测试。通过 shallowRef、v-memo 和虚拟滚动进行优化。

核心要点

  • 组合式 API 通过将相关逻辑组织在一起而非分散在各选项中,实现了更好的代码组织。
  • 组合函数(use* 函数)以完整的 TypeScript 支持取代混入,成为主要的代码复用模式。
  • Pinia 是官方状态管理方案,提供比 Vuex 更简洁的 API 和完整的 TypeScript 类型推断。
  • 使用 defineProps 和 defineEmits 的 script setup 是单文件组件的推荐语法。
  • shallowRef、v-memo 和 computed 缓存等性能工具可防止不必要的重新渲染。
  • Vue Router 4 组合函数(useRouter、useRoute)与组合式 API 无缝集成进行导航。
  • Teleport 和 Suspense 是内置组件,无需第三方库即可解决常见 UI 模式。

1. 组合式 API 与选项式 API

选项式 API 按选项类型(data、methods、computed、watch)组织代码,而组合式 API 按逻辑关注点分组。在一个 500 行的组件中,单个功能的相关逻辑可能分散在 data、computed、methods 和 watch 中。组合式 API 让你将所有相关逻辑放在一起。

两种 API 都使用相同的底层 Vue 响应式系统。组合式 API 不会废弃选项式 API。你甚至可以在增量迁移期间在同一组件中同时使用两者。关键优势是组合函数可以独立提取、测试和共享。

在实际应用中,当组件处理多个关注点时,组合式 API 优势明显。例如仪表板组件可能管理用户偏好、实时数据获取和图表渲染。使用选项式 API,这三个关注点会交织在 data、methods 和 watchers 中。使用组合式 API,每个关注点都在自己的组合函数中:usePreferences()、useRealtimeData() 和 useChartRenderer()。

// Options API (Vue 2 style)
export default {
  data() {
    return { count: 0, name: "Vue" };
  },
  computed: {
    double() { return this.count * 2; }
  },
  methods: {
    increment() { this.count++; }
  }
};

// Composition API (Vue 3)
import { ref, computed } from "vue";
const count = ref(0);
const name = ref("Vue");
const double = computed(() => count.value * 2);
const increment = () => count.value++;
提示: 迁移提示:迁移期间可以在同一组件中同时使用两种 API。组合式 API 的 setup() 在选项式 API 钩子之前运行,它们共享同一组件实例。

2. 响应式系统:ref、reactive、computed、watch

Vue 3 提供多种响应式原语。使用 ref() 处理原始值(字符串、数字、布尔值),reactive() 处理对象。computed() 创建缓存的派生状态,仅在依赖变化时重新计算。watch() 和 watchEffect() 处理响应式数据变化时的副作用。

watch 和 watchEffect 的关键区别在于依赖追踪。watch() 需要你明确指定源并提供旧值和新值。watchEffect() 自动追踪回调中使用的所有响应式依赖,更简单但不那么精确。

toRef() 和 toRefs() 是解构响应式对象而不丢失响应性的重要工具。toRef 创建链接到 reactive 对象特定属性的 ref,而 toRefs 将所有属性转换为独立的 ref。这在从组合函数返回状态时特别有用。

import { ref, reactive, computed,
  watch, watchEffect } from "vue";

// ref: wraps primitives (access via .value)
const count = ref(0);
count.value++; // 1

// reactive: deep reactive object (no .value)
const state = reactive({ items: [], loading: false });
state.loading = true;

// computed: cached derived state
const total = computed(() => state.items.length);

// watch: react to specific source changes
watch(count, (newVal, oldVal) => {
  console.log(`Changed: \${oldVal} -> \${newVal}`);
});

// watchEffect: auto-tracks dependencies
watchEffect(() => {
  console.log("Count:", count.value);
});
提示: 最佳实践:顶层状态优先使用 ref() 而非 reactive()。ref() 同时支持原始值和对象,其 .value 约定使响应式更明确。仅对不需要重新赋值的局部对象使用 reactive()。

3. 组合函数:可复用逻辑

组合函数是以 "use" 为前缀的函数,使用组合式 API 封装和复用有状态逻辑。它们以更干净、类型安全的模式替代 Vue 2 的混入,避免命名冲突。每个组合函数都是独立的函数,具有明确的输入和输出。

常见的组合函数模式包括 useFetch 用于数据获取、useLocalStorage 用于持久化状态、useEventListener 用于 DOM 事件、useMediaQuery 用于响应式设计。VueUse 库提供了 200 多个生产就绪的组合函数。

构建组合函数时,遵循清理模式:如果组合函数设置了事件监听器、定时器或订阅,使用 onUnmounted 进行清理。使用 Vue 3.3 的 toValue() 接受 ref 或 getter 参数作为响应式输入,它统一处理 ref 值和 getter 函数。

// composables/useFetch.ts
import { ref, watchEffect } from "vue";

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null);
  const error = ref<string | null>(null);
  const loading = ref(true);

  watchEffect(async () => {
    loading.value = true;
    try {
      const res = await fetch(url);
      data.value = await res.json();
    } catch (e: any) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  });
  return { data, error, loading };
}
提示: 命名约定:始终以 "use" 为前缀命名组合函数。以普通对象返回响应式 ref 和函数,而非 reactive 包装器。组合函数可以调用其他组合函数来组合复杂行为。

4. Props 与 Emits:defineProps 和 defineEmits

script setup 提供编译器宏 defineProps 和 defineEmits,实现类型安全的组件通信。这些宏在构建时编译掉,无需导入。withDefaults 在保持完整类型推断的同时设置默认属性值。

Vue 3.3 引入了 defineModel 宏用于双向绑定,简化了 v-model 模式。无需手动声明 prop 和 emit,defineModel 创建一个自动与父组件 v-model 值同步的 ref。

对于具有大量 props 的复杂组件,考虑将相关 props 分组到接口中并导入。这使 props 定义保持干净且可在多个组件间复用。defineSlots 宏提供类型安全的作用域插槽,实现插槽内容的完整 TypeScript 检查。

<script setup lang="ts">
// defineProps with TypeScript types
interface Props {
  title: string;
  count?: number;
  items: string[];
}
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => [],
});

// defineEmits with typed events
const emit = defineEmits<{
  update: [value: string];
  delete: [id: number];
  submit: [];
}>(); 

emit("update", "new value");
emit("delete", 42);
</script>
提示: 类型声明 Props:defineProps 支持运行时声明和仅类型声明。仅类型方式提供更好的 TypeScript 集成,推荐用于 TypeScript 项目。

5. Provide/Inject:依赖注入

provide 和 inject 允许在组件树中传递数据而无需逐层传递 props。使用 TypeScript 的 InjectionKey 可在提供者和消费者之间实现完整的类型安全。此模式特别适用于插件、主题系统和共享服务。

与 props 不同,提供的值默认不是响应式的。要使其响应式,提供 ref 或 reactive 对象。注入组件将自动追踪变化。你也可以提供函数让子组件更新共享状态。

常见模式是为每个上下文创建 useProvide/useInject 对。提供者组合函数调用 provide(),消费者组合函数调用 inject() 并进行适当的错误处理。这封装了注入键和类型,使消费者只需调用 useTheme() 或 useAuth() 即可。

// types/injection-keys.ts
import type { InjectionKey, Ref } from "vue";

export interface UserContext {
  name: Ref<string>;
  logout: () => void;
}
export const UserKey: InjectionKey<UserContext>
  = Symbol("UserContext");

// ParentComponent.vue
import { provide, ref } from "vue";
import { UserKey } from "./types/injection-keys";
const name = ref("Alice");
provide(UserKey, {
  name,
  logout: () => { /* clear session */ }
});

// ChildComponent.vue (any depth)
import { inject } from "vue";
import { UserKey } from "./types/injection-keys";
const user = inject(UserKey); // fully typed
提示: 安全提示:使用 inject() 时始终提供默认值或处理 undefined 情况。使用 InjectionKey<T> 确保提供和注入的类型在编译时匹配。

6. Vue Router 4:组合式 API 集成

Vue Router 4 提供 useRouter() 和 useRoute() 等组合函数在 setup() 中进行导航和路由访问。导航守卫可通过 onBeforeRouteLeave 和 onBeforeRouteUpdate 在组件级别定义。

使用懒加载的路由级代码分割对性能至关重要。在路由定义中使用动态导入将每个页面分割为单独的块。结合预取,确保快速初始加载的同时保持导航即时性。

Vue Router 4 通过 RouteRecordRaw 接口支持类型化的路由定义。将路由定义在单独的文件中以更好地组织代码,使用路由 meta 字段进行认证守卫、面包屑或页面标题。全局 beforeEach 守卫检查 meta.requiresAuth 来保护路由。

import { useRouter, useRoute,
  onBeforeRouteLeave } from "vue-router";

const router = useRouter();
const route = useRoute();

// Programmatic navigation
router.push({ name: "user", params: { id: "123" } });
router.replace("/dashboard");

// Access route params reactively
watch(() => route.params.id, (newId) => {
  fetchUser(newId as string);
});

// Per-component navigation guard
onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    return confirm("Discard unsaved changes?");
  }
});
提示: 性能提示:使用 defineAsyncComponent 配合动态导入进行路由级代码分割。仅在用户导航到该路由时才加载对应组件,显著减少初始包大小。

7. Pinia 状态管理

Pinia 是 Vue 官方状态管理库,替代 Vuex。它通过 defineStore 提供简洁的 API,同时支持选项式和 setup 语法,无需额外配置即可提供完整的 TypeScript 类型推断。

Pinia store 自动支持 tree-shaking,未使用的 store 会从生产包中排除。Store 可以相互订阅,支持用于持久化或日志记录的插件,并与 Vue DevTools 集成进行时间旅行调试。

Pinia 开箱即用支持服务端渲染。在 SSR 应用中,每个请求获得自己的 Pinia 实例以避免用户之间的状态泄露。使用 storeToRefs() 辅助函数解构 store 状态同时保持响应式,类似于 reactive 对象的 toRefs()。

import { defineStore } from "pinia";
import { ref, computed } from "vue";

// Setup store syntax (recommended)
export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([]);

  const total = computed(() =>
    items.value.reduce((s, i) => s + i.price * i.qty, 0)
  );

  function addItem(product: Product) {
    const existing = items.value.find(
      i => i.id === product.id
    );
    if (existing) existing.qty++;
    else items.value.push({ ...product, qty: 1 });
  }

  function clear() { items.value = []; }

  return { items, total, addItem, clear };
});
提示: 架构提示:保持 store 小而专注于单一领域。使用 setup store 语法获得更好的 TypeScript 推断,并在 store 中充分利用组合式 API。

8. Script Setup:defineExpose、defineOptions、顶层 await

script setup 是语法糖,将整个脚本块编译为 setup() 函数。顶层声明的变量、函数和导入自动在模板中可用,无需显式 return 语句。

使用模板 ref 调用子组件方法时 defineExpose 至关重要。没有它,script setup 中的内容对父组件不可访问。defineOptions 设置组件名称和 inheritAttrs 等选项。

从选项式 API 迁移时,注意 script setup 组件不再有 this 上下文。使用模板 ref 变量替代 this.$refs,使用 defineEmits 的 emit 函数替代 this.$emit。Vue DevTools 完全支持 script setup 组件,具有相同的检查功能。

<script setup lang="ts">
import { ref } from "vue";

// defineOptions: set component name, inheritAttrs
defineOptions({
  name: "MyDialog",
  inheritAttrs: false
});

// Component logic
const visible = ref(false);
const open = () => (visible.value = true);
const close = () => (visible.value = false);

// defineExpose: expose methods to parent via ref
defineExpose({ open, close });

// Top-level await (needs Suspense parent)
const config = await fetch("/api/config")
  .then(r => r.json());
</script>
提示: 注意:顶层 await 需要父组件中的 Suspense 边界。组件在 await 的 promise 解决前不会渲染,Suspense 会在加载期间显示回退内容。

9. Teleport 与 Suspense

Teleport 将插槽内容渲染到 CSS 选择器指定的不同 DOM 位置,解决模态框和工具提示常见的 z-index 和 overflow 问题。Suspense 提供了处理组件树中异步依赖的方式,内置加载状态。

Suspense 可以同时处理多个异步依赖。如果组件树有三个异步子组件,Suspense 等待所有子组件完成后才从回退切换到默认内容。你也可以嵌套 Suspense 边界实现更精细的加载状态。

Teleport 支持 disabled 属性,可条件性地将内容保留在原始位置。这对于响应式设计很有用,模态框仅在大屏幕上才进行 teleport。你也可以通过使用多个具有不同 to 属性的 Teleport 组件来传送到多个目标。

<!-- Teleport: render modal at document body -->
<template>
  <button @click="showModal = true">Open</button>
  <Teleport to="body">
    <div v-if="showModal" class="modal-overlay">
      <div class="modal-content">
        <slot />
        <button @click="showModal = false">
          Close
        </button>
      </div>
    </div>
  </Teleport>
</template>

<!-- Suspense: async component with fallback -->
<Suspense>
  <template #default><AsyncDashboard /></template>
  <template #fallback>
    <div>Loading dashboard...</div>
  </template>
</Suspense>
提示: 模式:将 Teleport 与 Transition 结合用于动画模态框。Teleport 在 body 层渲染模态框,Transition 无缝处理进入和离开动画。

10. 自定义指令

当需要模板之外的直接 DOM 操作时,自定义指令提供底层 DOM 访问。Vue 3 中指令使用与组件生命周期对应的钩子:created、beforeMount、mounted、beforeUpdate、updated、beforeUnmount 和 unmounted。

指令接收三个参数:DOM 元素、包含值、参数和修饰符的绑定对象,以及虚拟节点。对于只需要 mounted 和 updated 且行为相同的简单情况,可以使用简写函数语法。

在 script setup 中,任何以 "v" 开头的驼峰式变量自动作为自定义指令在模板中可用。例如声明 const vFocus = { mounted: el => el.focus() } 就可以使用 v-focus,无需任何注册步骤。全局指令应在 app 实例上注册。

// directives/vFocus.ts
import type { Directive } from "vue";

export const vFocus: Directive<HTMLInputElement> = {
  mounted(el) { el.focus(); }
};

// directives/vIntersection.ts
export const vIntersection: Directive<
  HTMLElement, () => void
> = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) binding.value();
      },
      { threshold: 0.1 }
    );
    observer.observe(el);
    (el as any).__ob = observer;
  },
  unmounted(el) {
    (el as any).__ob?.disconnect();
  }
};
提示: 使用时机:大多数可复用逻辑优先使用组合函数。仅在无法通过模板绑定实现的直接 DOM 操作时使用指令,如焦点管理或交叉观察。

11. 过渡与动画

Vue 提供内置的 Transition 和 TransitionGroup 组件,在元素进入、离开或在 DOM 中移动时应用 CSS 和 JavaScript 动画。它们在过渡生命周期的特定阶段自动应用 CSS 类。

TransitionGroup 渲染实际元素(由 tag 属性指定),使用 FLIP 动画技术应用移动过渡。这实现了流畅的列表重排动画。JavaScript 钩子允许集成 GSAP 等动画库。

对于复杂的多步骤动画,在 Transition 上使用 mode 属性。out-in 模式等待当前元素离开后新元素再进入,防止布局闪烁。in-out 模式相反。不设置 mode 时,两个过渡同时进行,可能导致视觉重叠。

<!-- CSS Transition -->
<Transition name="fade">
  <p v-if="show">Hello</p>
</Transition>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

<!-- TransitionGroup for lists -->
<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">
    {{ item.text }}
  </li>
</TransitionGroup>
提示: 性能提示:尽可能使用 CSS 过渡而非 JavaScript 钩子。对于 TransitionGroup 列表动画,添加 move 类来流畅处理 FLIP 动画。设置 appear 属性以在初始渲染时添加动画。

12. 使用 Vitest 测试

Vitest 是 Vue 3 项目推荐的测试框架,基于 Vite 构建,实现即时测试执行。它与 Vue Test Utils 无缝集成进行组件测试,支持快照测试、模拟和代码覆盖率。

对于端到端测试,将 Vitest 单元测试与 Playwright 或 Cypress 配对。Vitest 处理组件和组合函数测试,E2E 工具验证完整的用户旅程。使用 Vitest 覆盖率报告识别应用中未测试的代码路径。

使用 vi.mock() 模拟测试中的 API 调用和外部依赖。Vitest 支持定时器模拟(vi.useFakeTimers)用于测试防抖组合函数,以及通过 happy-dom 或 jsdom 模拟 DOM 环境。始终测试正常路径和错误状态。

import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import Counter from "./Counter.vue";

// Component test
describe("Counter", () => {
  it("increments on click", async () => {
    const w = mount(Counter);
    await w.find("button").trigger("click");
    expect(w.text()).toContain("1");
  });

  it("renders with props", () => {
    const w = mount(Counter, {
      props: { initial: 5 }
    });
    expect(w.text()).toContain("5");
  });
});
提示: 测试策略:当组合函数没有生命周期依赖时,作为纯函数测试。对于使用 onMounted 或 watch 的组合函数,使用 Vue Test Utils 将它们挂载在简单的包装组件中。

13. 性能优化

Vue 3 提供多种工具优化渲染性能。shallowRef 避免大对象的深层响应式追踪,computed 缓存昂贵的计算并仅在依赖变化时重新求值,v-memo 在指定值未变化时跳过列表项的重新渲染。

对于有数千项的列表,使用 vue-virtual-scroller 等库实现虚拟滚动。这只渲染视口中可见的项,大幅减少 DOM 节点并提高滚动性能。结合 shallowRef 用于列表数据以避免深层响应式开销。

另一个重要优化是使用 markRaw() 处理永远不应该是响应式的大对象,如第三方类实例、大型常量数据集或 Web Worker 引用。这告诉 Vue 完全跳过响应式代理,避免对永不改变的对象进行深度观察的开销。

import { shallowRef, computed,
  defineAsyncComponent } from "vue";

// shallowRef: only triggers on reassignment
const bigList = shallowRef<Item[]>([]);
// This triggers reactivity:
bigList.value = [...bigList.value, newItem];
// This does NOT trigger:
bigList.value.push(newItem);

// computed: cached, only recalculates on change
const sorted = computed(() =>
  [...items.value].sort((a, b) =>
    a.name.localeCompare(b.name)
  )
);

// Lazy-load heavy components
const Chart = defineAsyncComponent(
  () => import("./HeavyChart.vue")
);

<!-- v-memo: skip re-render if unchanged -->
<div v-for="item in list" :key="item.id"
  v-memo="[item.id, item.selected]">
  {{ item.name }}
</div>
提示: 性能分析:使用 Vue DevTools 性能面板识别慢组件。寻找频繁重新渲染但无可见变化的组件,它们是 shallowRef、v-memo 或 computed 优化的候选者。

总结:构建现代 Vue 3 应用

组合式 API 从根本上改变了我们组织 Vue 组件的方式。通过按功能而非按选项类型分组逻辑,组件变得更易于阅读、维护和重构。关键构建块是 ref 和 reactive 用于状态,computed 用于派生值,watch/watchEffect 用于副作用。

组合函数是 Vue 3 代码复用的基石。它们提供了干净、类型安全的混入替代方案,具有明确的依赖和返回值。结合 Pinia 用于全局状态和 Vue Router 4 用于导航,组合式 API 提供了一致、可扩展的开发体验。

对于生产应用,始终从一开始就考虑性能。使用 shallowRef 处理大型数据结构,computed 处理昂贵的派生计算,v-memo 优化列表渲染,懒加载实现路由级代码分割。使用 Vitest 和 Vue Test Utils 测试组件,在应用增长时保持信心。

2026 年的 Vue 生态系统成熟且紧密连接。Pinia、Vue Router、Vitest 和 VitePress 等官方工具都围绕组合式 API 设计,从状态管理到文档提供一致的开发体验。从 script setup 开始,为共享逻辑构建组合函数,随着应用需求逐步采用高级模式。

常见问题

应该使用选项式 API 还是组合式 API?

新项目推荐使用组合式 API。它提供更好的 TypeScript 支持、通过组合函数改善代码复用,以及更灵活的代码组织方式。选项式 API 仍然受支持,适合简单组件,但组合式 API 在复杂应用中扩展性更好。

ref 和 reactive 有什么区别?

ref() 将任何值包装在通过 .value 访问的响应式引用中。reactive() 使对象深度响应式,无需 .value。原始值使用 ref,需要重新赋值整个值时也用 ref。复杂对象就地修改时使用 reactive。

组合函数与混入有什么不同?

组合函数是返回响应式状态和方法的普通函数。与混入不同,它们有明确的输入输出、无命名冲突、完整的 TypeScript 支持和清晰的数据流。它们可以组合在一起,不会出现混入的隐式合并行为。

Pinia 比 Vuex 好吗?

是的,Pinia 是 Vue 3 官方推荐的状态管理方案。它拥有更简洁的 API(无需 mutations)、完整的 TypeScript 推断、模块化设计,并支持选项式和组合式 API 模式。Vuex 已进入维护模式。

什么时候用 provide/inject,什么时候用 props?

直接的父子通信使用 props。当数据需要跳过多个层级(避免 prop 逐层传递)或插件级依赖注入时使用 provide/inject。层级较浅时始终优先使用 props。

如何测试组合函数?

在组件上下文中调用组合函数进行测试。使用 Vue Test Utils 的辅助工具或创建简单包装组件。对于没有生命周期钩子的组合函数,可直接作为函数测试。

什么是 script setup,应该使用它吗?

script setup 是减少单文件组件样板代码的编译时语法。变量和导入自动在模板中可用。它是所有使用组合式 API 的新 Vue 3 组件的推荐方式。

如何优化 Vue 3 性能?

对整体变化的大对象使用 shallowRef,对昂贵的派生计算使用 computed,用 v-memo 跳过未变化列表项的重新渲染,对数千项的列表使用虚拟滚动。避免不必要的 watcher,尽可能使用 computed。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

{ }JSON FormatterJSTypeScript to JavaScript.*Regex Tester

相关文章

React 设计模式指南:复合组件、自定义 Hook、HOC、Render Props 与状态机

完整的 React 设计模式指南,涵盖复合组件、render props、自定义 hooks、高阶组件、Provider 模式、状态机、受控与非受控、组合模式、观察者模式、错误边界和模块模式。

高级TypeScript指南:泛型、条件类型、映射类型、装饰器和类型收窄

掌握高级TypeScript模式。涵盖泛型约束、带infer的条件类型、映射类型(Partial/Pick/Omit)、模板字面量类型、判别联合、工具类型深入、装饰器、模块增强、类型收窄、协变/逆变以及satisfies运算符。

代码整洁之道:命名规范、SOLID 原则、代码异味、重构与最佳实践

全面的代码整洁指南,涵盖命名规范、函数设计、SOLID 原则、DRY/KISS/YAGNI、代码异味与重构、错误处理模式、测试、代码审查、契约式设计和整洁架构。