Vue 3 组合式 API 实战指南
Created on
前言
Vue 3 带来了革命性的 Composition API,相比 Options API,它提供了更好的逻辑复用和代码组织方式。本文将系统讲解 Composition API 的使用方法和最佳实践。
为什么需要 Composition API?
Options API 的局限性
<!-- Options API -->
<script>
export default {
data() {
return {
count: 0,
user: null,
loading: false,
};
},
methods: {
increment() {
this.count++;
},
async fetchUser() {
this.loading = true;
this.user = await fetch("/api/user").then((r) => r.json());
this.loading = false;
},
},
mounted() {
this.fetchUser();
},
};
</script>
问题:
- 相关逻辑分散在不同选项中
- 逻辑复用依赖 mixins (会有命名冲突)
- this 指向容易混淆
- TypeScript 支持不够好
Composition API 的优势
<!-- Composition API -->
<script setup>
import { ref, onMounted } from "vue";
// 计数器逻辑
const count = ref(0);
const increment = () => count.value++;
// 用户数据逻辑
const user = ref(null);
const loading = ref(false);
async function fetchUser() {
loading.value = true;
user.value = await fetch("/api/user").then((r) => r.json());
loading.value = false;
}
onMounted(() => {
fetchUser();
});
</script>
优势:
- 相关逻辑组织在一起
- 易于复用 (composables)
- 更好的 TypeScript 支持
- 更灵活的代码组织
setup 函数
基础用法
<script>
import { ref } from "vue";
export default {
setup() {
const count = ref(0);
function increment() {
count.value++;
}
// 返回暴露给模板的内容
return {
count,
increment,
};
},
};
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
script setup 语法糖 (推荐)
<script setup>
import { ref } from "vue";
// 顶层变量自动暴露给模板
const count = ref(0);
function increment() {
count.value++;
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
响应式核心 API
ref - 基本类型响应式
<script setup>
import { ref } from "vue";
const count = ref(0);
const message = ref("Hello");
// 访问值需要 .value
console.log(count.value); // 0
count.value++;
console.log(count.value); // 1
// 模板中自动解包,不需要 .value
</script>
<template>
<div>{{ count }}</div>
<div>{{ message }}</div>
</template>
reactive - 对象响应式
<script setup>
import { reactive } from "vue";
const state = reactive({
count: 0,
user: {
name: "John",
age: 30,
},
});
// 直接访问,不需要 .value
console.log(state.count); // 0
state.count++;
state.user.name = "Jane";
</script>
<template>
<div>{{ state.count }}</div>
<div>{{ state.user.name }}</div>
</template>
ref vs reactive
// ✅ 使用 ref
const count = ref(0);
const message = ref("Hello");
const user = ref({ name: "John" });
// ✅ 使用 reactive
const state = reactive({
count: 0,
message: "Hello",
user: { name: "John" },
});
// ❌ reactive 的局限性
let state = reactive({ count: 0 });
state = reactive({ count: 1 }); // 响应性丢失!
// ✅ ref 可以重新赋值
let count = ref(0);
count.value = 100; // OK
推荐做法:
- 基本类型: 使用 ref
- 对象: ref 或 reactive 都可以
- 需要替换整个对象: 使用 ref
computed - 计算属性
<script setup>
import { ref, computed } from "vue";
const firstName = ref("John");
const lastName = ref("Doe");
// 只读计算属性
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
// 可写计算属性
const fullNameWritable = computed({
get() {
return `${firstName.value} ${lastName.value}`;
},
set(newValue) {
[firstName.value, lastName.value] = newValue.split(" ");
},
});
fullNameWritable.value = "Jane Smith";
console.log(firstName.value); // 'Jane'
</script>
<template>
<div>{{ fullName }}</div>
</template>
watch - 侦听器
侦听 single source
<script setup>
import { ref, watch } from "vue";
const count = ref(0);
// 侦听 ref
watch(count, (newValue, oldValue) => {
console.log(`count changed from ${oldValue} to ${newValue}`);
});
// 侦听 reactive 对象的属性
const state = reactive({ count: 0 });
watch(
() => state.count,
(newValue, oldValue) => {
console.log(`state.count changed from ${oldValue} to ${newValue}`);
}
);
</script>
侦听 multiple sources
<script setup>
import { ref, watch } from "vue";
const x = ref(0);
const y = ref(0);
watch([x, y], ([newX, newY], [oldX, oldY]) => {
console.log(`x: ${oldX} -> ${newX}`);
console.log(`y: ${oldY} -> ${newY}`);
});
</script>
深度侦听
<script setup>
import { reactive, watch } from "vue";
const state = reactive({
user: {
name: "John",
age: 30,
},
});
// 深度侦听
watch(
state,
(newValue) => {
console.log("State changed:", newValue);
},
{ deep: true }
);
// 侦听嵌套属性
watch(
() => state.user.name,
(newName) => {
console.log("Name changed:", newName);
}
);
</script>
watchEffect - 自动追踪依赖
<script setup>
import { ref, watchEffect } from "vue";
const count = ref(0);
const multiplier = ref(2);
// 自动追踪 count 和 multiplier
watchEffect(() => {
console.log(`Result: ${count.value * multiplier.value}`);
});
count.value++; // 日志: Result: 2
multiplier.value = 3; // 日志: Result: 3
</script>
生命周期钩子
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
} from "vue";
// 创建后立即执行
console.log("setup executed");
onBeforeMount(() => {
console.log("Before mount");
});
onMounted(() => {
console.log("Mounted");
// DOM 已挂载,可以访问 $refs
});
onBeforeUpdate(() => {
console.log("Before update");
});
onUpdated(() => {
console.log("Updated");
});
onBeforeUnmount(() => {
console.log("Before unmount");
});
onUnmounted(() => {
console.log("Unmounted");
// 清理副作用
});
</script>
Composables - 可复用逻辑
useCounter
// composables/useCounter.js
import { ref } from "vue";
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
function increment() {
count.value++;
}
function decrement() {
count.value--;
}
function reset() {
count.value = initialValue;
}
return {
count,
increment,
decrement,
reset,
};
}
使用:
<script setup>
import { useCounter } from "./composables/useCounter";
const { count, increment, decrement, reset } = useCounter(0);
</script>
<template>
<div>Count: {{ count }}</div>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</template>
useFetch
// composables/useFetch.js
import { ref, watchEffect, toValue } from "vue";
export function useFetch(url) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
async function fetchData() {
loading.value = true;
error.value = null;
try {
const response = await fetch(toValue(url));
if (!response.ok) throw new Error("Failed to fetch");
data.value = await response.json();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
// 响应 URL 变化
watchEffect(() => {
fetchData();
});
return {
data,
error,
loading,
refetch: fetchData,
};
}
使用:
<script setup>
import { ref } from "vue";
import { useFetch } from "./composables/useFetch";
const userId = ref(1);
const {
data: user,
loading,
error,
refetch,
} = useFetch(computed(() => `/api/users/${userId.value}`));
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<button @click="refetch">Refresh</button>
</div>
</template>
useLocalStorage
// composables/useLocalStorage.js
import { ref, watch } from "vue";
export function useLocalStorage(key, defaultValue) {
const storedValue = localStorage.getItem(key);
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue);
// 监听变化并同步到 localStorage
watch(
data,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
},
{ deep: true }
);
return data;
}
使用:
<script setup>
import { useLocalStorage } from "./composables/useLocalStorage";
const theme = useLocalStorage("theme", "light");
function toggleTheme() {
theme.value = theme.value === "light" ? "dark" : "light";
}
</script>
<template>
<button @click="toggleTheme">Current: {{ theme }}</button>
</template>
组件通信
Props
<!-- Parent.vue -->
<script setup>
import { ref } from "vue";
import Child from "./Child.vue";
const message = ref("Hello");
</script>
<template>
<Child :message="message" :count="100" />
</template>
<!-- Child.vue -->
<script setup>
// 使用 TypeScript 定义 props
interface Props {
message: string
count?: number
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
console.log(props.message)
</script>
<template>
<div>{{ message }} - {{ count }}</div>
</template>
Emits
<!-- Child.vue -->
<script setup>
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
function handleClick() {
emit('update', 'new value')
emit('delete', 123)
}
</script>
<template>
<button @click="handleClick">Click</button>
</template>
<!-- Parent.vue -->
<script setup>
import Child from "./Child.vue";
function handleUpdate(value) {
console.log("Updated:", value);
}
function handleDelete(id) {
console.log("Deleted:", id);
}
</script>
<template>
<Child @update="handleUpdate" @delete="handleDelete" />
</template>
Provide / Inject
<!-- Parent.vue -->
<script setup>
import { provide, ref } from "vue";
const theme = ref("light");
provide("theme", theme);
</script>
<!-- Child.vue (任意深度) -->
<script setup>
import { inject } from "vue";
const theme = inject("theme");
console.log(theme.value); // 'light'
</script>
defineExpose - 暴露组件实例
<!-- Child.vue -->
<script setup>
import { ref } from "vue";
const count = ref(0);
function increment() {
count.value++;
}
// 暴露给父组件
defineExpose({
count,
increment,
});
</script>
<!-- Parent.vue -->
<script setup>
import { ref } from "vue";
import Child from "./Child.vue";
const childRef = ref(null);
function callChildMethod() {
childRef.value.increment();
console.log(childRef.value.count);
}
</script>
<template>
<Child ref="childRef" />
<button @click="callChildMethod">Call Child</button>
</template>
Pinia 状态管理
// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useUserStore = defineStore("user", () => {
// State
const user = ref(null);
const token = ref(null);
// Getters
const isLoggedIn = computed(() => !!token.value);
const userName = computed(() => user.value?.name || "Guest");
// Actions
async function login(credentials) {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(credentials),
});
const data = await response.json();
user.value = data.user;
token.value = data.token;
}
function logout() {
user.value = null;
token.value = null;
}
return {
user,
token,
isLoggedIn,
userName,
login,
logout,
};
});
使用:
<script setup>
import { useUserStore } from "./stores/user";
const userStore = useUserStore();
async function handleLogin() {
await userStore.login({
email: "[email protected]",
password: "password",
});
}
</script>
<template>
<div v-if="userStore.isLoggedIn">
Welcome, {{ userStore.userName }}
<button @click="userStore.logout">Logout</button>
</div>
<div v-else>
<button @click="handleLogin">Login</button>
</div>
</template>
最佳实践
1. Composables 命名规范
// ✅ 推荐:使用 use 前缀
export function useMouse() {}
export function useCounter() {}
export function useFetch() {}
// ❌ 不推荐
export function mouse() {}
export function counter() {}
2. 响应式解构
import { reactive, toRefs } from "vue";
function useUser() {
const state = reactive({
name: "John",
age: 30,
});
// ✅ 使用 toRefs 保持响应性
return {
...toRefs(state),
};
}
// 使用时保持响应性
const { name, age } = useUser();
3. 条件性副作用
import { watchEffect } from "vue";
const enabled = ref(false);
// ❌ 不好:总是执行
watchEffect(() => {
if (enabled.value) {
console.log("Enabled");
}
});
// ✅ 好:只在需要时执行
let stop = null;
watch(enabled, (value) => {
if (value && !stop) {
stop = watchEffect(() => {
console.log("Enabled");
});
} else if (!value && stop) {
stop();
stop = null;
}
});
Options API vs Composition API 迁移
<!-- Before: Options API -->
<script>
export default {
data() {
return { count: 0 };
},
computed: {
double() {
return this.count * 2;
},
},
methods: {
increment() {
this.count++;
},
},
mounted() {
console.log("mounted");
},
};
</script>
<!-- After: Composition API -->
<script setup>
import { ref, computed, onMounted } from "vue";
const count = ref(0);
const double = computed(() => count.value * 2);
function increment() {
count.value++;
}
onMounted(() => {
console.log("mounted");
});
</script>
总结
Vue 3 Composition API 的核心优势:
- 更好的逻辑组织: 相关代码聚合在一起
- 更强的复用能力: Composables 替代 mixins
- 更好的类型推导: 完美支持 TypeScript
- 更灵活: 不受选项约束
记住:Composition API 不是为了替代 Options API,而是提供更灵活的选择。
参考资源
- Vue 3 官方文档
- Composition API RFC
- Pinia 官方文档
- VueUse - 常用 Composables 合集