第 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 API | Composition API |
|---|---|---|
| 学习曲线 | 平缓 | 较陡 |
| 代码组织 | 按选项类型 | 按业务逻辑 |
| 复用方式 | mixin | composable |
| 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 |
| 设计原则 | 输入参数、返回值、副作用封装 |
动手练习
- useMouse:实现一个鼠标位置跟踪的 composable
- useFetch:实现一个支持手动触发的 fetch composable
- useLocalStorage:实现一个支持深监听的 localStorage composable
- useForm:实现一个完整的表单验证 composable
推荐阅读
- 📖 Vue 3 Composition API — 官方 FAQ
- 📖 Vue 3 script setup — script setup 文档
- 🌐 VueUse — 工具库,大量 composable 范例
下一章:第 122 章:computed 与 watch →