跳到主要内容
EN
返回

Vue 3 组合式 API 最佳实践模式

Zhou Xunyou 25 分钟阅读 frontend
分享

Vue 3 的组合式 API(Composition API)是对 Options API 的彻底重构,它提供了更灵活的逻辑组织和复用方式。本文将深入解析组合式 API 的设计模式、响应式系统原理、TypeScript 集成以及从 Options API 迁移的实战经验。

为什么需要组合式 API

Options API 的核心问题在于逻辑分散。一个功能的代码被拆散到 datacomputedmethodswatch 等多个选项中:

// Options API:搜索功能的代码分散在各处
export default {
  data() {
    return {
      query: '',
      results: [],
      loading: false,
      // 其他功能的 data...
      currentPage: 1,
      pageSize: 10,
    };
  },
  computed: {
    filteredResults() { /* ... */ },
    // 其他功能的 computed...
  },
  methods: {
    async search() { /* ... */ },
    // 其他功能的 methods...
  },
  watch: {
    query(val) { /* ... */ },
    // 其他功能的 watch...
  },
};

组合式 API 让同一功能的代码聚合在一起:

// Composition API:搜索功能的所有逻辑聚合在一处
function useSearch() {
  const query = ref('');
  const results = ref([]);
  const loading = ref(false);

  async function search() {
    loading.value = true;
    results.value = await fetchResults(query.value);
    loading.value = false;
  }

  watch(query, debounce(search, 300));

  return { query, results, loading, search };
}

// 使用
export default {
  setup() {
    const search = useSearch();
    const pagination = usePagination();
    return { ...search, ...pagination };
  },
};

响应式系统原理

Proxy-based 响应式

Vue 3 使用 ES6 Proxy 替代了 Vue 2 的 Object.defineProperty

// Vue 2 的局限性
const obj = {};
Object.defineProperty(obj, 'name', {
  get() { /* ... */ },
  set(val) { /* ... */ },
});
// 无法检测:属性新增、数组索引修改、数组长度变化

// Vue 3 的 Proxy 方案
const reactive = (target) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key); // 收集依赖
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key); // 触发更新
      }
      return result;
    },
    deleteProperty(target, key) {
      const hadKey = key in target;
      const result = Reflect.deleteProperty(target, key);
      if (hadKey) {
        trigger(target, key); // 检测属性删除
      }
      return result;
    },
  });
};

ref 与 reactive 的选择

import { ref, reactive, toRefs } from 'vue';

// ref:适用于基础类型和需要重新赋值的场景
const count = ref(0);
const user = ref({ name: 'Alice' });
user.value = { name: 'Bob' }; // 可以整体替换

// reactive:适用于复杂对象,不需要重新赋值
const state = reactive({
  users: [],
  loading: false,
  filters: { status: 'active' },
});
state.users.push(newUser); // 直接操作
// state = { users: [] }; // 错误!不能整体替换

// 最佳实践:在 composable 中优先使用 ref
function useUser() {
  const user = ref(null); // 初始为 null,后续赋值
  const loading = ref(false);

  async function fetchUser(id) {
    loading.value = true;
    user.value = await api.getUser(id); // 整体替换 OK
    loading.value = false;
  }

  return { user, loading, fetchUser };
}

// 解构 reactive 对象会丢失响应性,使用 toRefs
const state = reactive({ name: 'Alice', age: 30 });
const { name, age } = toRefs(state); // 保持响应性

Composable 函数设计模式

基础模式

// composables/useFetch.ts
import { ref, shallowRef, watchEffect, type Ref } from 'vue';

interface UseFetchOptions<T> {
  immediate?: boolean;
  initialValue?: T;
  refetch?: boolean;
}

interface UseFetchReturn<T> {
  data: Ref<T | undefined>;
  error: Ref<Error | null>;
  loading: Ref<boolean>;
  execute: () => Promise<void>;
}

export function useFetch<T>(
  url: Ref<string> | string,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const data = shallowRef<T | undefined>(options.initialValue);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      const resolvedUrl = typeof url === 'string' ? url : url.value;
      const response = await fetch(resolvedUrl);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      data.value = await response.json();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  };

  if (options.immediate !== false) {
    execute();
  }

  if (options.refetch && typeof url !== 'string') {
    watchEffect(() => { execute(); });
  }

  return { data, error, loading, execute };
}

异步状态管理模式

// composables/useAsyncState.ts
import { ref, computed, type Ref } from 'vue';

interface State<T> {
  data: T | null;
  error: Error | null;
  status: 'idle' | 'loading' | 'success' | 'error';
}

export function useAsyncState<T>(
  promiseFn: () => Promise<T>,
  initialValue: T | null = null
) {
  const state = ref<State<T>>({
    data: initialValue,
    error: null,
    status: 'idle',
  });

  const isLoading = computed(() => state.value.status === 'loading');
  const isError = computed(() => state.value.status === 'error');
  const isSuccess = computed(() => state.value.status === 'success');

  async function execute() {
    state.value.status = 'loading';
    state.value.error = null;
    try {
      state.value.data = await promiseFn();
      state.value.status = 'success';
    } catch (e) {
      state.value.error = e as Error;
      state.value.status = 'error';
    }
  }

  function reset() {
    state.value = { data: initialValue, error: null, status: 'idle' };
  }

  return {
    state,
    data: computed(() => state.value.data),
    error: computed(() => state.value.error),
    isLoading,
    isError,
    isSuccess,
    execute,
    reset,
  };
}

事件总线模式

// composables/useEventBus.ts
type Handler<T = void> = (payload: T) => void;

export function useEventBus<T = void>() {
  const handlers = new Set<Handler<T>>();

  function on(handler: Handler<T>) {
    handlers.add(handler);
    return () => handlers.delete(handler); // 返回取消函数
  }

  function emit(payload: T) {
    handlers.forEach(handler => handler(payload));
  }

  function once(handler: Handler<T>) {
    const wrapped: Handler<T> = (payload) => {
      handler(payload);
      handlers.delete(wrapped);
    };
    handlers.add(wrapped);
  }

  return { on, emit, once };
}

// 使用
const bus = useEventBus<{ type: string; payload: any }>();
const off = bus.on((event) => console.log(event));
bus.emit({ type: 'user:login', payload: { id: 1 } });
off(); // 取消监听

TypeScript 集成

类型化的 Props 和 Emits

// 推荐:使用类型声明而非运行时声明
<script setup lang="ts">
interface Props {
  title: string;
  count?: number;
  items: Array<{ id: number; name: string }>;
  status: 'active' | 'inactive';
}

interface Emits {
  (e: 'update', id: number): void;
  (e: 'delete', id: number): void;
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  status: 'active',
});

const emit = defineEmits<Emits>();

// 泛型组件
defineOptions({ inheritAttrs: false });
</script>

类型化的 provide/inject

// types.ts
import type { InjectionKey, Ref } from 'vue';

export interface UserInfo {
  id: number;
  name: string;
  role: 'admin' | 'user';
}

export const UserKey: InjectionKey<Ref<UserInfo>> = Symbol('user');

// Provider.vue
<script setup lang="ts">
import { provide, ref } from 'vue';
import { UserKey } from './types';

const user = ref<UserInfo>({ id: 1, name: 'Alice', role: 'admin' });
provide(UserKey, user);
</script>

// Consumer.vue
<script setup lang="ts">
import { inject } from 'vue';
import { UserKey } from './types';

const user = inject(UserKey); // 类型自动推断为 Ref<UserInfo>
if (!user) throw new Error('UserKey not provided');
</script>

状态管理模式

轻量状态:composable + reactive

// stores/useCart.ts
import { reactive, computed } from 'vue';

const state = reactive({
  items: [] as Array<{ id: number; name: string; price: number; qty: number }>,
});

const total = computed(() =>
  state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
);

const itemCount = computed(() =>
  state.items.reduce((sum, item) => sum + item.qty, 0)
);

function addItem(item: { id: number; name: string; price: number }) {
  const existing = state.items.find(i => i.id === item.id);
  if (existing) {
    existing.qty++;
  } else {
    state.items.push({ ...item, qty: 1 });
  }
}

function removeItem(id: number) {
  const index = state.items.findIndex(i => i.id === id);
  if (index > -1) state.items.splice(index, 1);
}

// 导出只读状态和操作方法
export function useCart() {
  return {
    items: computed(() => state.items),
    total,
    itemCount,
    addItem,
    removeItem,
  };
}

使用 Pinia

// stores/user.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  const user = ref<UserInfo | null>(null);
  const token = ref<string | null>(localStorage.getItem('token'));

  const isLoggedIn = computed(() => !!token.value);

  async function login(credentials: LoginParams) {
    const res = await api.login(credentials);
    token.value = res.token;
    user.value = res.user;
    localStorage.setItem('token', res.token);
  }

  function logout() {
    token.value = null;
    user.value = null;
    localStorage.removeItem('token');
  }

  return { user, token, isLoggedIn, login, logout };
});

从 Options API 迁移

渐进式迁移策略

<!-- 方式一:setup 选项(最小改动) -->
<script>
import { ref } from 'vue';
export default {
  data() {
    return { legacyData: 'old' };
  },
  setup() {
    const newData = ref('new');
    return { newData };
  },
};
</script>

<!-- 方式二:<script setup>(推荐) -->
<script setup lang="ts">
import { ref } from 'vue';

// 直接定义,无需 return
const count = ref(0);
function increment() {
  count.value++;
}

// 生命周期
onMounted(() => console.log('mounted'));

// Props 和 Emits
const props = defineProps<{ title: string }>();
const emit = defineEmits<{ (e: 'change', value: number): void }>();
</script>

生命周期映射

// Options API           → Composition API
beforeCreate              setup() 本身
created                   setup() 本身
beforeMount               onBeforeMount
mounted                   onMounted
beforeUpdate              onBeforeUpdate
updated                   onUpdated
beforeUnmount             onBeforeUnmount
unmounted                 onUnmounted
errorCaptured             onErrorCaptured
renderTracked             onRenderTracked
renderTriggered           onRenderTriggered

Watch 迁移

// Options API
export default {
  watch: {
    'form.name'(val) {
      this.validateName(val);
    },
    query: {
      handler(val) { this.search(val); },
      immediate: true,
      deep: true,
    },
  },
};

// Composition API
watch(() => form.name, (val) => {
  validateName(val);
});

watch(query, (val) => {
  search(val);
}, { immediate: true, deep: true });

// watchEffect:自动追踪依赖
watchEffect(() => {
  // 自动追踪 formData 内的所有响应式依赖
  console.log('Form changed:', formData.name, formData.email);
});

常见陷阱

1. 在 setup 中使用 this

// 错误:setup 中没有 this
setup() {
  this.method(); // undefined!
}

// 正确:直接引用函数
setup() {
  function method() { /* ... */ }
  return { method };
}

2. ref 的 .value 遗忘

const count = ref(0);

// 错误:模板中自动解包,但 <script> 中不会
if (count === 0) { } // 永远为 false

// 正确
if (count.value === 0) { }

3. 响应性丢失

const state = reactive({ list: [] });

// 错误:解构 reactive 丢失响应性
const { list } = state;

// 正确方案一:toRefs
const { list } = toRefs(state);

// 正确方案二:直接使用
state.list.push(item);

// 正确方案三:改用 ref
const list = ref([]);

4. shallowRef 的正确使用

// shallowRef:只有 .value 的替换触发更新
const items = shallowRef([{ name: 'A' }]);

items.value[0].name = 'B'; // 不触发更新!
items.value = [...items.value]; // 触发更新

// 对比 ref + reactive:深层响应
const items = ref([{ name: 'A' }]);
items.value[0].name = 'B'; // 触发更新

总结

组合式 API 为 Vue 带来了更好的逻辑复用和代码组织能力:

  • Composable 函数是组合式 API 的核心抽象,设计时注意返回值类型和清理逻辑
  • ref vs reactive:优先使用 ref,reactive 适合不需要替换的复杂对象
  • TypeScript 集成:充分利用 defineProps<T>()InjectionKey<T> 等类型工具
  • 迁移策略:优先使用 <script setup>,新功能用 composable 组织,旧代码渐进迁移

核心原则:以功能为单位组织代码,而不是以选项类型。这将显著提升代码的可读性、可测试性和可复用性。

评论