Skip to content
第 121 / 250 章前端⏱ 10 分钟阅读

第 121 章:setup 与组合式 API

学习目标

  • 深入理解 Composition API 的设计思想
  • 掌握 setup 函数的各种写法
  • 学会逻辑复用与自定义 composables
  • 对比 Options API 和 Composition API 的优劣

一、为什么需要 Composition API

1.1 Options API 的局限

typescript
// ❌ Options API:同一功能分散在多个选项中
<script>
export default {
  data() {
    return { user: null, loading: false };
  },
  computed: {
    userName() { return this.user?.name; }
  },
  methods: {
    async fetchUser() {
      this.loading = true;
      this.user = await api.getUser();
      this.loading = false;
    }
  },
  mounted() {
    this.fetchUser();
  }
};
</script>

问题:

  • 相关代码分散(data、computed、methods、生命周期)
  • 大组件难以维护
  • 逻辑复用靠 mixin(有命名冲突、来源不清问题)

1.2 Composition API 的优势

typescript
// ✅ Composition API:同一功能集中在一起
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';

// 用户相关:全部集中
const user = ref<User | null>(null);
const loading = ref(false);
const userName = computed(() => user.value?.name);

const fetchUser = async () => {
  loading.value = true;
  user.value = await api.getUser();
  loading.value = false;
};

onMounted(fetchUser);
</script>

优势:

  • 同一业务逻辑集中
  • 逻辑复用靠 composable(显式、类型友好)
  • 更好的 TS 支持

二、setup 函数

2.1 两种写法对比

Options API 风格

vue
<script lang="ts">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  setup() {
    const count = ref(0);
    const increment = () => count.value++;

    // 必须 return 才能在模板用
    return { count, increment };
  }
});
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

script setup 风格(推荐)

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

// 顶层变量自动暴露给模板
const count = ref(0);
const increment = () => count.value++;
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

2.2 script setup 编译产物

vue
<script setup>
// 这些代码会被编译成 setup() 函数
const count = ref(0);
const increment = () => count.value++;
</script>

<!-- 等价于: -->
<script>
export default {
  setup() {
    const count = ref(0);
    const increment = () => count.value++;
    return { count, increment };
  }
};
</script>

2.3 script setup 的特性

顶层绑定自动暴露

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

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

// ❌ 不要用 const 之外的方式
let name = 'Tom';  // 不推荐
</script>

<template>
  <p>{{ count }}</p>
</template>

组件自动注册

vue
<script setup lang="ts">
// 自动注册,无需在 components 选项中声明
import ChildComponent from './Child.vue';
import AnotherChild from './Another.vue';
</script>

<template>
  <ChildComponent />
  <AnotherChild />
</template>

宏的自动可用

vue
<script setup lang="ts">
// 这些宏不需要 import
const props = defineProps<{ msg: string }>();
const emit = defineEmits<{ change: [v: string] }>();
const slots = defineSlots<{ default(): any }>();
defineExpose({ someMethod: () => {} });
defineOptions({ name: 'MyComponent' });
</script>

2.4 defineOptions

vue
<script setup lang="ts">
defineOptions({
  name: 'MyButton',
  inheritAttrs: false
});
</script>

2.5 defineExpose

vue
<!-- Child.vue -->
<script setup lang="ts">
const count = ref(0);
const increment = () => count.value++;

// 默认不暴露,父组件拿不到
defineExpose({
  count,
  increment
});
</script>
vue
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';

const childRef = ref<InstanceType<typeof Child> | null>(null);

const handleClick = () => {
  childRef.value?.increment();
  console.log(childRef.value?.count);
};
</script>

<template>
  <Child ref="childRef" />
</template>

三、组合式函数(Composables)

3.1 什么是 composable

composable = 封装响应式逻辑的函数,通常以 use 开头。

3.2 第一个 composable:鼠标位置

typescript
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(e: MouseEvent) {
    x.value = e.clientX;
    y.value = e.clientY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}
vue
<!-- 使用 -->
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse';

const { x, y } = useMouse();
</script>

<template>
  <p>鼠标位置: {{ x }}, {{ y }}</p>
</template>

3.3 composable:异步请求

typescript
// composables/useFetch.ts
import { ref } from 'vue';

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

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url);
      data.value = await res.json();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  }

  fetchData();

  return { data, error, loading, refetch: fetchData };
}
vue
<script setup lang="ts">
const { data, loading, error, refetch } = useFetch<User[]>('/api/users');
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">错误: {{ error.message }}</div>
  <ul v-else>
    <li v-for="user in data" :key="user.id">{{ user.name }}</li>
  </ul>
  <button @click="refetch">刷新</button>
</template>

3.4 composable:本地存储

typescript
// composables/useLocalStorage.ts
import { ref, watch } from 'vue';

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const data = ref<T>(
    JSON.parse(localStorage.getItem(key) || JSON.stringify(defaultValue))
  );

  watch(data, (val) => {
    localStorage.setItem(key, JSON.stringify(val));
  }, { deep: true });

  return data;
}
vue
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage';

const name = useLocalStorage('name', '');
const settings = useLocalStorage('settings', { theme: 'light' });
</script>

3.5 composable:防抖

typescript
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue';

export function useDebounce<T>(value: Ref<T>, delay = 300): Ref<T> {
  const debounced = ref(value.value) as Ref<T>;
  let timer: number;

  watch(value, (val) => {
    clearTimeout(timer);
    timer = window.setTimeout(() => {
      debounced.value = val;
    }, delay);
  });

  return debounced;
}

3.6 composable:计数器(对比 mixin)

typescript
// ❌ mixin:来源不清、命名冲突
export const useCounterMixin = {
  data() {
    return { count: 0 };
  },
  methods: {
    increment() { this.count++; }
  }
};

// ✅ composable:显式、可推断
export function useCounter(initial = 0) {
  const count = ref(initial);
  const increment = () => count.value++;
  const decrement = () => count.value--;
  const reset = () => (count.value = initial);

  return { count, increment, decrement, reset };
}

四、composable 设计原则

4.1 输入参数与返回值

typescript
// ✅ 推荐:返回多个 ref / reactive 对象
export function useCounter() {
  return {
    count: ref(0),
    increment: () => {},
    decrement: () => {}
  };
}

// 父组件解构
const { count, increment } = useCounter();

4.2 副作用封装

typescript
// ✅ 把生命周期封装在内部
export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => target.removeEventListener(event, handler));
}

// ✅ 让用户决定生命周期
export function useMouse() {
  // 返回数据,不在内部调用生命周期
  return { x, y };
}

4.3 可组合

typescript
// ✅ composable 可以嵌套使用
export function useUser() {
  return useFetch<User>('/api/me');
}

export function useUserPosts() {
  const { data: user } = useUser();
  return useFetch<Post[]>(`/api/users/${user.value?.id}/posts`);
}

五、组合式 API 完整结构

六、Options API vs Composition API

6.1 对比表

维度Options APIComposition API
学习曲线平缓较陡
代码组织按选项类型按业务逻辑
复用方式mixincomposable
TS 友好一般优秀
适合场景小型组件中大型组件
性能相同相同

6.2 选择建议

建议:新项目统一用 <script setup> + Composition API

6.3 混合使用

vue
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  inheritAttrs: false,
  // Options API
  data() {
    return { count: 0 };
  },
  methods: {
    increment() { this.count++; }
  }
});
</script>

<script setup lang="ts">
// Composition API
import { ref } from 'vue';
const message = ref('hello');

// 两个 script 块可以共存(3.x 特性)
</script>

七、实战案例

7.1 表单验证 composable

typescript
// composables/useFormValidation.ts
import { reactive, computed } from 'vue';

interface Rules<T> {
  [K in keyof T]?: (value: T[K]) => string | null;
}

export function useFormValidation<T extends object>(
  form: T,
  rules: Rules<T>
) {
  const errors = reactive<Record<keyof T, string>>({} as any);

  function validateField(field: keyof T) {
    const rule = rules[field];
    if (!rule) return;
    const value = form[field];
    const error = rule(value);
    errors[field] = error || '';
  }

  function validateAll() {
    let valid = true;
    for (const field in rules) {
      validateField(field);
      if (errors[field]) valid = false;
    }
    return valid;
  }

  const isValid = computed(() =>
    Object.values(errors).every((e) => !e)
  );

  return { errors, validateField, validateAll, isValid };
}
vue
<script setup lang="ts">
import { reactive } from 'vue';
import { useFormValidation } from '@/composables/useFormValidation';

const form = reactive({
  username: '',
  email: ''
});

const { errors, validateField, validateAll } = useFormValidation(form, {
  username: (v: string) => (v.length < 3 ? '至少 3 个字符' : null),
  email: (v: string) => (!v.includes('@') ? '邮箱格式错误' : null)
});
</script>

<template>
  <form @submit.prevent="validateAll() && submit()">
    <input v-model="form.username" @blur="validateField('username')" />
    <p v-if="errors.username">{{ errors.username }}</p>

    <input v-model="form.email" @blur="validateField('email')" />
    <p v-if="errors.email">{{ errors.email }}</p>

    <button type="submit">提交</button>
  </form>
</template>

7.2 分页 composable

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

export function usePagination<T>(
  fetcher: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>,
  pageSize = 10
) {
  const page = ref(1);
  const items = ref<T[]>([]);
  const total = ref(0);
  const loading = ref(false);

  const totalPages = computed(() => Math.ceil(total.value / pageSize));

  async function load() {
    loading.value = true;
    try {
      const res = await fetcher(page.value, pageSize);
      items.value = res.items;
      total.value = res.total;
    } finally {
      loading.value = false;
    }
  }

  watch(page, load, { immediate: true });

  return {
    page,
    pageSize,
    total,
    totalPages,
    items,
    loading,
    next: () => page.value < totalPages.value && page.value++,
    prev: () => page.value > 1 && page.value--
  };
}

八、常见错误

8.1 忘了 .value

typescript
// ❌ 模板中漏了 .value
const count = ref(0);
console.log(count);  // ref 对象

// ✅ 模板外要 .value
console.log(count.value);

8.2 composable 命名不规范

typescript
// ❌ 不是 use 开头
function fetchMouse() {}

// ✅ 以 use 开头
function useMouse() {}

8.3 composable 滥用生命周期

typescript
// ❌ 不应该在普通工具函数中用生命周期
function formatDate(d: Date) {
  onMounted(() => {});  // 错误:必须在 setup 中
}

九、本章小结

要点关键
script setup推荐写法,顶层变量自动暴露
defineProps / defineEmits / defineExpose
composable封装响应式逻辑的 useXxx 函数
复用composable 替代 mixin
与 Options新项目统一用 Composition API
设计原则输入参数、返回值、副作用封装

动手练习

  1. useMouse:实现一个鼠标位置跟踪的 composable
  2. useFetch:实现一个支持手动触发的 fetch composable
  3. useLocalStorage:实现一个支持深监听的 localStorage composable
  4. useForm:实现一个完整的表单验证 composable

推荐阅读


下一章第 122 章:computed 与 watch

本站基于 VitePress 构建 · 由 Codebook 团队维护