Vue 3 组合式 API 入门教程
Vue 3 引入了全新的组合式 API(Composition API),这是一种更灵活、更可扩展的组件逻辑组织方式。相比 Vue 2 的选项式 API(Options API),组合式 API 让我们能够更好地复用逻辑、理解类型推断,并构建更大型、更复杂的应用。本文将从零开始讲解 Vue 3 组合式 API 的核心概念,并通过一个完整的待办事项应用实战来巩固所学知识。
一、Vue 3 简介
Vue 3 是 Vue.js 框架的最新主版本,相较于 Vue 2,它带来了诸多改进:
- 性能提升:更快的虚拟 DOM、更小的打包体积
- 组合式 API:更灵活的逻辑组织和复用方式
- TypeScript 支持:更好的类型推断和类型安全
- 新的内置组件:Teleport、Suspense、Fragment 等
- 响应式系统重构:基于 Proxy 实现更完善的响应式
二、创建 Vue 3 应用
2.1 使用 CDN 引入
最简单的方式是通过 CDN 在 HTML 中直接引入 Vue 3:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Vue 3 示例</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<h1>{{ message }}</h1>
<button @click="count++">点击了 {{ count }} 次</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
setup() {
const message = ref('Hello Vue 3!');
const count = ref(0);
return { message, count };
}
}).mount('#app');
</script>
</body>
</html>
2.2 使用 Vite 创建项目(推荐)
对于实际项目开发,推荐使用 Vite 创建 Vue 3 项目:
# 创建项目
npm create vite@latest my-vue-app -- --template vue
# 进入项目目录
cd my-vue-app
# 安装依赖
npm install
# 启动开发服务器
npm run dev
使用单文件组件(SFC)的写法:
<!-- App.vue -->
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3!');
const count = ref(0);
</script>
<template>
<h1>{{ message }}</h1>
<button @click="count++">点击了 {{ count }} 次</button>
</template>
<style scoped>
h1 {
color: #42b883;
}
</style>
<script setup> 是 Vue 3.2 引入的语法糖,它让组合式 API 的写法更加简洁,是当前推荐的开发方式。
三、组合式 API 核心
3.1 ref:基本类型的响应式
ref 用于创建基本类型(字符串、数字、布尔值等)的响应式数据。在 JavaScript 中需要通过 .value 访问,但在模板中会自动解包:
import { ref } from 'vue';
const count = ref(0); // 数字
const message = ref('Hello'); // 字符串
const isVisible = ref(false); // 布尔值
// 在 JavaScript 中访问/修改需要 .value
console.log(count.value); // 0
count.value++;
console.log(count.value); // 1
// 在模板中自动解包(不需要 .value)
// <div>{{ count }}</div>
3.2 reactive:对象的响应式
reactive 用于创建对象类型的响应式数据,它返回的是 Proxy 代理对象,访问时不需要 .value:
import { reactive } from 'vue';
const user = reactive({
name: '平平',
age: 25,
hobbies: ['编程', '阅读']
});
// 直接访问和修改(不需要 .value)
console.log(user.name); // '平平'
user.age = 26;
user.hobbies.push('游戏');
ref 与 reactive 的选择:基本类型用 ref,对象/数组用 reactive。但需要注意的是,reactive 不能整体替换,而 ref 可以通过 .value 赋值新对象。
3.3 computed:计算属性
computed 用于创建基于其他响应式数据的派生数据,它会自动缓存依赖值:
import { ref, computed } from 'vue';
const firstName = ref('平');
const lastName = ref('平');
// 计算属性
const fullName = computed({
get() {
return firstName.value + lastName.value;
},
set(newValue) {
// 可以设置计算属性的值
[firstName.value, lastName.value] = newValue.split(' ');
}
});
console.log(fullName.value); // '平平'
// 设置值会触发 set
fullName.value = '小 明';
console.log(firstName.value); // '小'
console.log(lastName.value); // '明'
3.4 watch:侦听器
watch 用于侦听响应式数据的变化并执行副作用:
import { ref, reactive, watch } from 'vue';
const count = ref(0);
const user = reactive({ name: '平平', age: 25 });
// 侦听 ref
watch(count, (newValue, oldValue) => {
console.log(`count 从 ${oldValue} 变为 ${newValue}`);
});
// 侦听 reactive 的某个属性(需要用函数返回)
watch(
() => user.age,
(newValue, oldValue) => {
console.log(`age 从 ${oldValue} 变为 ${newValue}`);
}
);
// 侦听多个数据源
watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
console.log(`count: ${oldCount} -> ${newCount}, name: ${oldName} -> ${newName}`);
});
// 深度侦听(侦听对象内部变化)
watch(
user,
(newValue, oldValue) => {
// 注意:深度侦听时,newValue 和 oldValue 指向同一对象
console.log('user 发生变化', newValue);
},
{ deep: true }
);
// 立即执行(创建时立即触发一次)
watch(
count,
(newValue, oldValue) => {
console.log('立即执行', newValue);
},
{ immediate: true }
);
3.5 watchEffect:自动追踪依赖
watchEffect 会自动收集依赖,无需显式指定侦听的数据源:
import { ref, watchEffect } from 'vue';
const count = ref(0);
const message = ref('Hello');
// 自动追踪内部使用的响应式数据
watchEffect(() => {
console.log(`count 是 ${count.value}, message 是 ${message.value}`);
// 会在初始化时立即执行一次
// 之后 count 或 message 变化时都会重新执行
});
// 清除副作用
watchEffect((onCleanup) => {
const timer = setInterval(() => {
console.log(count.value);
}, 1000);
// 在下次执行前或组件卸载时清除
onCleanup(() => {
clearInterval(timer);
});
});
四、生命周期钩子
在组合式 API 中,生命周期钩子需要从 vue 导入,并加上 on 前缀:
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onErrorCaptured
} from 'vue';
export default {
setup() {
// 组件挂载到 DOM 之前
onBeforeMount(() => {
console.log('组件即将挂载');
});
// 组件挂载到 DOM 之后(最常用)
onMounted(() => {
console.log('组件已挂载');
// 可以在这里访问 DOM 元素、发起请求等
});
// 响应式数据变更之前
onBeforeUpdate(() => {
console.log('组件即将更新');
});
// 响应式数据变更之后
onUpdated(() => {
console.log('组件已更新');
});
// 组件卸载之前(常用于清理定时器、事件监听等)
onBeforeUnmount(() => {
console.log('组件即将卸载');
});
// 组件卸载之后
onUnmounted(() => {
console.log('组件已卸载');
});
// 捕获后代组件错误
onErrorCaptured((err, instance, info) => {
console.error('捕获到错误:', err);
return false; // 阻止错误继续向上传播
});
}
}
五、组件通信
5.1 Props:父传子
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue';
import Child from './Child.vue';
const message = ref('来自父组件的消息');
const user = ref({ name: '平平', age: 25 });
</script>
<template>
<Child
title="用户信息"
:message="message"
:user="user"
:items="['苹果', '香蕉', '橙子']"
/>
</template>
<!-- 子组件 Child.vue -->
<script setup>
// 定义 props
const props = defineProps({
title: {
type: String,
required: true
},
message: {
type: String,
default: '默认消息'
},
user: {
type: Object,
default: () => ({})
},
items: {
type: Array,
default: () => []
}
});
// 使用 props
console.log(props.title);
console.log(props.user.name);
</script>
<template>
<div class="child">
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<p>姓名:{{ user.name }},年龄:{{ user.age }}</p>
<ul>
<li v-for="(item, index) in items" :key="index">{{ item }}</li>
</ul>
</div>
</template>
使用 TypeScript 时的类型声明方式:
// 使用类型声明(仅 TypeScript)
const props = defineProps<{
title: string;
message?: string; // 可选属性
user: { name: string; age: number };
items?: string[];
}>();
// 带默认值的类型声明
const props = withDefaults(defineProps<{
title: string;
message?: string;
items?: string[];
}>(), {
message: '默认消息',
items: () => []
});
5.2 Emits:子传父
<!-- 子组件 Child.vue -->
<script setup>
// 定义 emits
const emit = defineEmits(['update', 'delete', 'submit']);
function handleClick() {
// 触发事件并传参
emit('update', { id: 1, name: '新名称' });
}
function handleDelete(id) {
emit('delete', id);
}
</script>
<template>
<button @click="handleClick">更新</button>
<button @click="handleDelete(1)">删除</button>
</template>
<!-- 父组件 Parent.vue -->
<script setup>
import Child from './Child.vue';
function handleUpdate(data) {
console.log('收到更新事件:', data);
}
function handleDelete(id) {
console.log('收到删除事件, id:', id);
}
</script>
<template>
<Child
@update="handleUpdate"
@delete="handleDelete"
/>
</template>
5.3 v-model:双向绑定
<!-- 父组件 -->
<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const text = ref('');
</script>
<template>
<CustomInput v-model="text" />
<p>输入的内容:{{ text }}</p>
</template>
<!-- 子组件 CustomInput.vue -->
<script setup>
const model = defineModel(); // Vue 3.4+ 推荐写法
</script>
<template>
<input v-model="model" placeholder="请输入" />
</template>
六、模板语法与指令
<script setup>
import { ref, reactive } from 'vue';
const show = ref(true);
const items = ref(['苹果', '香蕉', '橙子']);
const user = reactive({
name: '平平',
role: 'admin'
});
const url = ref('https://vuejs.org');
const htmlContent = ref('<strong>加粗文字</strong>');
</script>
<template>
<!-- 文本插值 -->
<p>{{ user.name }}</p>
<!-- 属性绑定(v-bind 或简写 :) -->
<a :href="url" target="_blank">Vue 官网</a>
<img :src="imageUrl" :alt="user.name">
<!-- 事件绑定(v-on 或简写 @) -->
<button @click="show = !show">切换显示</button>
<input @keyup.enter="handleSubmit">
<!-- 双向绑定(v-model) -->
<input v-model="user.name">
<!-- 条件渲染 -->
<p v-if="show">显示这段文字</p>
<p v-else>show 为 false 时显示</p>
<p v-show="show">通过 display 控制显示</p>
<!-- 列表渲染 -->
<ul>
<li v-for="(item, index) in items" :key="index">
{{ index + 1 }}. {{ item }}
</li>
</ul>
<!-- v-html 渲染 HTML(注意 XSS 风险) -->
<div v-html="htmlContent"></div>
<!-- class 和 style 绑定 -->
<div :class="{ active: show, 'text-danger': !show }">动态 class</div>
<div :class="['base-class', show ? 'active' : '']">数组 class</div>
<div :style="{ color: show ? 'green' : 'red', fontSize: '16px' }">动态 style</div>
</template>
七、实战案例:待办事项应用
下面用组合式 API 实现一个完整的待办事项应用,包含添加、删除、完成、筛选和本地存储功能:
<!-- TodoApp.vue -->
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
// 响应式数据
const todos = ref([]);
const newTodoText = ref('');
const filter = ref('all'); // all | active | completed
const editingId = ref(null);
const editingText = ref('');
// 计算属性:筛选后的待办事项
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed);
case 'completed':
return todos.value.filter(t => t.completed);
default:
return todos.value;
}
});
// 计算属性:统计信息
const stats = computed(() => ({
total: todos.value.length,
active: todos.value.filter(t => !t.completed).length,
completed: todos.value.filter(t => t.completed).length
}));
// 计算属性:剩余数量
const remaining = computed(() => stats.value.active);
// 添加待办事项
function addTodo() {
const text = newTodoText.value.trim();
if (!text) return;
todos.value.push({
id: Date.now(),
text: text,
completed: false,
createdAt: new Date().toISOString()
});
newTodoText.value = '';
}
// 删除待办事项
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
// 切换完成状态
function toggleTodo(todo) {
todo.completed = !todo.completed;
}
// 开始编辑
function startEdit(todo) {
editingId.value = todo.id;
editingText.value = todo.text;
}
// 保存编辑
function finishEdit(todo) {
const text = editingText.value.trim();
if (text) {
todo.text = text;
}
editingId.value = null;
editingText.value = '';
}
// 取消编辑
function cancelEdit() {
editingId.value = null;
editingText.value = '';
}
// 清除已完成
function clearCompleted() {
todos.value = todos.value.filter(t => !t.completed);
}
// 全部标记为完成/未完成
function toggleAll() {
const allCompleted = todos.value.every(t => t.completed);
todos.value.forEach(t => { t.completed = !allCompleted; });
}
// 本地存储:持久化数据
watch(todos, (newTodos) => {
localStorage.setItem('todos', JSON.stringify(newTodos));
}, { deep: true });
// 组件挂载时从本地存储读取
onMounted(() => {
const saved = localStorage.getItem('todos');
if (saved) {
todos.value = JSON.parse(saved);
}
});
</script>
<template>
<div class="todo-app">
<h1>待办事项</h1>
<!-- 添加输入框 -->
<div class="todo-input">
<input
v-model="newTodoText"
@keyup.enter="addTodo"
placeholder="输入待办事项,按回车添加"
>
<button @click="addTodo">添加</button>
</div>
<!-- 筛选按钮 -->
<div class="todo-filters">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>全部 ({{ stats.total }})</button>
<button
:class="{ active: filter === 'active' }"
@click="filter = 'active'"
>进行中 ({{ stats.active }})</button>
<button
:class="{ active: filter === 'completed' }"
@click="filter = 'completed'"
>已完成 ({{ stats.completed }})</button>
</div>
<!-- 待办列表 -->
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed, editing: editingId === todo.id }"
>
<!-- 正常显示 -->
<template v-if="editingId !== todo.id">
<input
type="checkbox"
v-model="todo.completed"
>
<span @dblclick="startEdit(todo)">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">删除</button>
</template>
<!-- 编辑状态 -->
<template v-else>
<input
v-model="editingText"
@keyup.enter="finishEdit(todo)"
@keyup.esc="cancelEdit"
v-focus
>
<button @click="finishEdit(todo)">保存</button>
<button @click="cancelEdit">取消</button>
</template>
</li>
</ul>
<!-- 底部操作 -->
<div class="todo-footer" v-if="todos.length">
<span>剩余 {{ remaining }} 项</span>
<button @click="toggleAll">全部切换</button>
<button @click="clearCompleted" v-if="stats.completed">清除已完成</button>
</div>
</div>
</template>
<style scoped>
.todo-app {
max-width: 500px;
margin: 0 auto;
padding: 20px;
}
.todo-input {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.todo-filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-filters button.active {
background: #42b883;
color: white;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-list li.completed span {
text-decoration: line-through;
color: #999;
}
.todo-footer {
display: flex;
justify-content: space-between;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
</style>
自定义指令 v-focus,用于编辑时自动聚焦输入框:
// main.js 或组件内
import { createApp } from 'vue';
const app = createApp({});
// 全局注册自定义指令
app.directive('focus', {
mounted(el) {
el.focus();
}
});
app.mount('#app');
八、组合式函数:逻辑复用
组合式 API 的一个重要优势是逻辑复用。我们可以将逻辑提取到独立的函数中(称为"组合式函数"或"Composables"),约定函数名以 use 开头:
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function update(event) {
x.value = event.pageX;
y.value = event.pageY;
}
onMounted(() => {
window.addEventListener('mousemove', update);
});
onUnmounted(() => {
window.removeEventListener('mousemove', update);
});
return { x, y };
}
// composables/useLocalStorage.js
import { ref, watch } from 'vue';
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key);
const data = ref(stored ? JSON.parse(stored) : defaultValue);
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
}, { deep: true });
return data;
}
在组件中使用:
<script setup>
import { useMouse } from './composables/useMouse';
import { useLocalStorage } from './composables/useLocalStorage';
const { x, y } = useMouse();
const settings = useLocalStorage('app-settings', {
theme: 'light',
fontSize: 14
});
</script>
<template>
<p>鼠标位置:({{ x }}, {{ y }})</p>
<p>主题:{{ settings.theme }}</p>
<button @click="settings.theme = 'dark'">切换暗色</button>
</template>
九、总结
通过本文的学习,你应该掌握了 Vue 3 组合式 API 的核心内容:
- Vue 3 应用的创建方式(CDN 和 Vite)
<script setup>语法糖的使用- 响应式 API:
ref、reactive、computed、watch、watchEffect - 生命周期钩子的组合式 API 写法
- 组件通信:
props、emits、v-model - 模板语法与常用指令
- 完整的待办事项应用实战
- 组合式函数(Composables)实现逻辑复用
组合式 API 相比选项式 API 的最大优势在于:逻辑可以按功能而非选项类型组织,便于在大型组件中维护,也更容易提取和复用。随着项目规模增长,这种优势会更加明显。建议在实际项目中多加练习,逐步熟练掌握这些核心概念。