返回文章列表

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,它带来了诸多改进:

二、创建 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 的核心内容:

组合式 API 相比选项式 API 的最大优势在于:逻辑可以按功能而非选项类型组织,便于在大型组件中维护,也更容易提取和复用。随着项目规模增长,这种优势会更加明显。建议在实际项目中多加练习,逐步熟练掌握这些核心概念。

动手挑战

学到这里,不妨动手试一试以下练习,巩固你的理解:

  1. 基础练习:回顾本文核心概念,用自己的话总结关键知识点。
  2. 进阶实践:将文中的示例代码运行一遍,尝试修改参数观察变化。
  3. 拓展思考:想一想这个技术/方法还能应用在哪些场景中?

小贴士:遇到问题时,先独立思考,再查阅资料,最后请教他人——这是成长最快的学习方式。

赞赏支持

本文更新于 2026-08-22,环境 Python 3.12