Vue.js 前端开发入门教程
本教程面向零基础学习者,从零开始掌握 Vue.js 前端开发的核心知识与实战技能。
目录
- 什么是 Vue.js
- 环境搭建与安装
- 模板语法
- 响应式数据
- 组件化开发
- Props 与 Emit:组件间通信
- 生命周期钩子
- 列表渲染与条件渲染
- 事件处理与表单绑定
- 计算属性与侦听器
- Vue Router:路由管理
- Pinia:状态管理
- 实战项目:待办事项应用
- 下一步学习建议
1. 什么是 Vue.js
Vue.js(通常简称 Vue)是一个用于构建用户界面的渐进式 JavaScript 框架。它的核心理念是渐进式——你可以只用它做页面上的一个小交互区域,也可以用它构建完整的单页应用(SPA)。
Vue 的核心特点
- 易学易用:基于标准 HTML、CSS、JavaScript,学习曲线平缓
- 响应式数据绑定:数据变化时,界面自动更新,无需手动操作 DOM
- 组件化架构:将界面拆分为独立、可复用的组件
- 虚拟 DOM:高效更新界面,性能优异
- 生态系统完善:配套路由(Vue Router)、状态管理(Pinia)、构建工具(Vite)等
Vue 2 vs Vue 3
Vue 3 是当前的主版本,带来了 Composition API、更好的 TypeScript 支持、性能提升等改进。本教程基于 Vue 3。
2. 环境搭建与安装
2.1 前置条件
在开始之前,确保你的电脑已安装:
- Node.js(版本 18 或更高):访问 nodejs.org 下载安装
- 包管理器:npm(随 Node.js 附带)或 pnpm(推荐)
验证安装:
node --version
npm --version
2.2 创建 Vue 项目
推荐使用 Vite 创建项目,它是新一代前端构建工具,启动速度极快:
npm create vue@latest
执行后会进入交互式引导:
✔ Project name: … my-vue-app
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router? … Yes
✔ Add Pinia? … Yes
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? … No
✔ Add ESLint for code quality? … Yes
然后进入项目目录并启动:
cd my-vue-app
npm install
npm run dev
打开浏览器访问 http://localhost:5173,你会看到 Vue 的欢迎页面。
2.3 项目结构概览
my-vue-app/
├── src/
│ ├── assets/ # 静态资源(图片、CSS 等)
│ ├── components/ # 可复用组件
│ ├── router/ # 路由配置
│ ├── stores/ # Pinia 状态管理
│ ├── views/ # 页面级组件
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── index.html # HTML 入口
├── package.json # 项目配置
└── vite.config.js # Vite 配置
2.4 入口文件 main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
这段代码做了三件事:创建 Vue 应用实例、注册路由和状态管理、挂载到 HTML 中的 #app 元素。
3. 模板语法
Vue 使用基于 HTML 的模板语法,允许你声明式地将数据绑定到 DOM。
3.1 文本插值 {{ }}
最基本的数据绑定形式,使用双花括号:
<template>
<h1>{{ message }}</h1>
<p>你有 {{ count }} 条消息</p>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('你好,Vue!')
const count = ref(5)
</script>
{{ }} 中可以放任何 JavaScript 表达式:
<template>
<p>{{ ok ? '是' : '否' }}</p>
<p>{{ message.split('').reverse().join('') }}</p>
<p>{{ count + 1 }}</p>
</template>
3.2 属性绑定 v-bind
使用 v-bind(简写 :)将数据动态绑定到 HTML 属性:
<template>
<img :src="imageUrl" :alt="imageAlt" />
<a :href="link" :class="{ active: isActive }">链接</a>
<button :disabled="isDisabled">提交</button>
</template>
<script setup>
import { ref } from 'vue'
const imageUrl = ref('/logo.png')
const imageAlt = ref('网站Logo')
const link = ref('https://vuejs.org')
const isActive = ref(true)
const isDisabled = ref(false)
</script>
3.3 原始 HTML v-html
如果需要输出真正的 HTML(注意 XSS 风险,仅用于可信内容):
<template>
<div v-html="rawHtml"></div>
</template>
<script setup>
import { ref } from 'vue'
const rawHtml = ref('<span style="color: red">这是红色文字</span>')
</script>
4. 响应式数据
响应式是 Vue 的核心特性:当数据变化时,依赖该数据的界面会自动更新。
4.1 ref — 基本类型的响应式
<script setup>
import { ref } from 'vue'
// ref 包裹基本类型数据
const count = ref(0)
const name = ref('张三')
const isVisible = ref(true)
// 在 JS 中修改值需要 .value
function increment() {
count.value++
console.log(count.value) // 1
}
</script>
<template>
<!-- 在模板中直接使用,无需 .value -->
<p>{{ count }}</p>
<button @click="increment">+1</button>
</template>
4.2 reactive — 对象类型的响应式
<script setup>
import { reactive } from 'vue'
// reactive 包裹对象或数组
const user = reactive({
name: '李四',
age: 25,
hobbies: ['读书', '编程']
})
// 直接修改属性,无需 .value
function birthday() {
user.age++
}
function addHobby(hobby) {
user.hobbies.push(hobby)
}
</script>
<template>
<p>{{ user.name }},{{ user.age }} 岁</p>
<ul>
<li v-for="hobby in user.hobbies" :key="hobby">{{ hobby }}</li>
</ul>
<button @click="birthday">过生日</button>
<button @click="addHobby('游泳')">添加爱好</button>
</template>
4.3 ref vs reactive 的选择建议
| 特性 | ref |
reactive |
|---|---|---|
| 适用类型 | 任意类型(推荐基本类型) | 对象、数组 |
| 访问方式 | .value |
直接访问属性 |
| 可否整体替换 | 可以 ref.value = newObj |
不可以 |
| 解构 | 解构后丢失响应性 | 解构后丢失响应性 |
经验法则:大多数场景用 ref 即可,简单直接。
5. 组件化开发
组件是 Vue 应用的基本构建块。每个 .vue 文件就是一个组件。
5.1 单文件组件(SFC)结构
<!-- MyComponent.vue -->
<template>
<!-- HTML 模板:必须有且只有一个根元素 -->
<div class="my-component">
<h2>{{ title }}</h2>
<p>{{ content }}</p>
</div>
</template>
<script setup>
// JavaScript 逻辑
import { ref } from 'vue'
const title = ref('组件标题')
const content = ref('这是组件内容')
</script>
<style scoped>
/* 样式:scoped 表示只作用于当前组件 */
.my-component {
padding: 16px;
border: 1px solid #eee;
border-radius: 8px;
}
</style>
5.2 使用子组件
<!-- App.vue -->
<template>
<div id="app">
<h1>我的应用</h1>
<MyComponent />
<MyComponent /> <!-- 可以复用多次 -->
</div>
</template>
<script setup>
import MyComponent from './components/MyComponent.vue'
</script>
5.3 插槽(Slot)
插槽让父组件向子组件传递 HTML 内容:
<!-- Card.vue - 子组件 -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<slot>默认内容</slot> <!-- 默认插槽 -->
</div>
<div class="card-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
<!-- 父组件中使用 -->
<template>
<Card>
<template #header>
<h3>卡片标题</h3>
</template>
<p>这是卡片的主体内容</p>
<template #footer>
<button>确定</button>
</template>
</Card>
</template>
<script setup>
import Card from './components/Card.vue'
</script>
6. Props 与 Emit:组件间通信
6.1 Props:父传子
父组件通过属性向子组件传递数据:
<!-- UserCard.vue - 子组件 -->
<template>
<div class="user-card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
<span :class="['badge', role]">{{ role }}</span>
</div>
</template>
<script setup>
// 使用 defineProps 声明接收的属性
const props = defineProps({
name: {
type: String,
required: true
},
age: {
type: Number,
default: 18
},
role: {
type: String,
default: 'user',
validator(value) {
return ['admin', 'user', 'guest'].includes(value)
}
}
})
</script>
<!-- 父组件 -->
<template>
<UserCard name="王五" :age="30" role="admin" />
<UserCard name="赵六" :age="22" />
</template>
<script setup>
import UserCard from './components/UserCard.vue'
</script>
6.2 Emit:子传父
子组件通过事件向父组件发送消息:
<!-- SearchBox.vue - 子组件 -->
<template>
<div class="search-box">
<input
v-model="keyword"
placeholder="输入搜索关键词"
@keyup.enter="handleSearch"
/>
<button @click="handleSearch">搜索</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const keyword = ref('')
// 声明组件可以触发的事件
const emit = defineEmits(['search', 'clear'])
function handleSearch() {
if (keyword.value.trim()) {
emit('search', keyword.value.trim())
}
}
function handleClear() {
keyword.value = ''
emit('clear')
}
</script>
<!-- 父组件 -->
<template>
<SearchBox @search="onSearch" @clear="onClear" />
<p v-if="result">搜索结果:{{ result }}</p>
</template>
<script setup>
import { ref } from 'vue'
import SearchBox from './components/SearchBox.vue'
const result = ref('')
function onSearch(keyword) {
result.value = `你搜索了 "${keyword}"`
}
function onClear() {
result.value = ''
}
</script>
7. 生命周期钩子
每个 Vue 组件从创建到销毁都会经历一系列阶段,Vue 提供了钩子函数让你在特定阶段执行代码。
7.1 生命周期流程
创建阶段: setup() → onBeforeMount → onMounted
更新阶段: onBeforeUpdate → onUpdated
销毁阶段: onBeforeUnmount → onUnmounted
7.2 常用钩子示例
<script setup>
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
ref
} from 'vue'
const data = ref(null)
const count = ref(0)
// 组件挂载前(此时还不能访问 DOM)
onBeforeMount(() => {
console.log('组件即将挂载')
})
// 组件挂载后(可以访问 DOM,适合发请求、操作 DOM)
onMounted(() => {
console.log('组件已挂载')
fetchData()
})
// 数据更新前
onBeforeUpdate(() => {
console.log('数据即将更新,旧值:', count.value)
})
// 数据更新后
onUpdated(() => {
console.log('数据已更新,新值:', count.value)
})
// 组件卸载前(清理定时器、取消订阅等)
onBeforeUnmount(() => {
console.log('组件即将卸载')
})
// 组件卸载后
onUnmounted(() => {
console.log('组件已卸载')
})
async function fetchData() {
// 模拟 API 请求
data.value = { message: '数据加载完成' }
}
</script>
7.3 最常用的两个钩子
| 钩子 | 用途 |
|---|---|
onMounted |
发起 API 请求、初始化第三方库、操作 DOM |
onBeforeUnmount |
清理定时器、移除事件监听、断开 WebSocket |
8. 列表渲染与条件渲染
8.1 v-for 列表渲染
<template>
<!-- 渲染数组 -->
<ul>
<li v-for="(item, index) in fruits" :key="item.id">
{{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
</li>
</ul>
<!-- 渲染对象 -->
<div v-for="(value, key) in userInfo" :key="key">
<strong>{{ key }}:</strong>{{ value }}
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const fruits = ref([
{ id: 1, name: '苹果', price: 5 },
{ id: 2, name: '香蕉', price: 3 },
{ id: 3, name: '橙子', price: 4 }
])
const userInfo = reactive({
name: '张三',
age: 28,
city: '北京'
})
</script>
重要:
v-for必须搭配:key,且 key 应该是唯一标识(如 id),不要用 index。
8.2 v-if / v-show 条件渲染
<template>
<!-- v-if:条件为 false 时,元素完全不渲染(从 DOM 中移除) -->
<p v-if="isLoggedIn">欢迎回来,{{ username }}!</p>
<p v-else-if="isLoading">加载中...</p>
<p v-else>请先登录</p>
<!-- v-show:通过 CSS display 控制显示隐藏,元素始终在 DOM 中 -->
<div v-show="showPanel">这个面板可以反复切换</div>
<button @click="showPanel = !showPanel">切换面板</button>
</template>
<script setup>
import { ref } from 'vue'
const isLoggedIn = ref(true)
const isLoading = ref(false)
const username = ref('张三')
const showPanel = ref(true)
</script>
选择建议:
v-if:切换频率低的场景(如权限控制)—— 有更高的切换开销v-show:切换频率高的场景(如 tab 面板)—— 有更高的初始渲染开销
9. 事件处理与表单绑定
9.1 事件处理 v-on
<template>
<!-- 简写 @ -->
<button @click="handleClick">点击我</button>
<button @click="handleClickWithArg('hello')">传参</button>
<button @click.prevent="handleClick">阻止默认行为</button>
<div @mousemove="handleMove">鼠标移入</div>
<input @keyup.enter="handleSubmit" />
</template>
<script setup>
function handleClick(event) {
console.log('按钮被点击', event.target)
}
function handleClickWithArg(msg) {
console.log(msg)
}
function handleMove(event) {
console.log(`鼠标位置:${event.clientX}, ${event.clientY}`)
}
function handleSubmit() {
console.log('按下了回车键')
}
</script>
9.2 表单绑定 v-model
v-model 实现表单元素与数据的双向绑定:
<template>
<form @submit.prevent="handleSubmit">
<!-- 文本输入 -->
<div>
<label>用户名:</label>
<input v-model="form.username" type="text" placeholder="请输入用户名" />
<span>你输入了:{{ form.username }}</span>
</div>
<!-- 密码 -->
<div>
<label>密码:</label>
<input v-model="form.password" type="password" />
</div>
<!-- 多行文本 -->
<div>
<label>简介:</label>
<textarea v-model="form.bio" rows="4"></textarea>
</div>
<!-- 复选框 -->
<div>
<label>
<input v-model="form.agree" type="checkbox" />
同意用户协议
</label>
</div>
<!-- 单选框 -->
<div>
<label><input v-model="form.gender" type="radio" value="male" /> 男</label>
<label><input v-model="form.gender" type="radio" value="female" /> 女</label>
</div>
<!-- 下拉选择 -->
<div>
<select v-model="form.city">
<option value="">请选择城市</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="shenzhen">深圳</option>
</select>
</div>
<button type="submit" :disabled="!form.agree">提交</button>
</form>
</template>
<script setup>
import { reactive } from 'vue'
const form = reactive({
username: '',
password: '',
bio: '',
agree: false,
gender: 'male',
city: ''
})
function handleSubmit() {
console.log('表单数据:', { ...form })
alert(`注册成功!用户名:${form.username}`)
}
</script>
10. 计算属性与侦听器
10.1 计算属性 computed
当一个值依赖其他响应式数据时,使用 computed 自动计算并缓存:
<template>
<div>
<input v-model="firstName" placeholder="姓" />
<input v-model="lastName" placeholder="名" />
<p>全名:{{ fullName }}</p>
<p>全名(大写):{{ fullNameUpperCase }}</p>
<hr />
<input v-model.number="price" type="number" placeholder="单价" />
<input v-model.number="quantity" type="number" placeholder="数量" />
<p>总价:¥{{ totalPrice }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// 计算属性:自动追踪依赖,依赖不变则返回缓存值
const fullName = computed(() => {
return firstName.value + lastName.value
})
const fullNameUpperCase = computed(() => {
return fullName.value.toUpperCase()
})
// 购物车场景
const price = ref(99)
const quantity = ref(2)
const totalPrice = computed(() => {
return (price.value * quantity.value).toFixed(2)
})
</script>
10.2 侦听器 watch 和 watchEffect
<script setup>
import { ref, watch, watchEffect } from 'vue'
const keyword = ref('')
const searchResults = ref([])
// watch:精确侦听特定数据
watch(keyword, (newVal, oldVal) => {
console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
if (newVal.length >= 2) {
// 模拟搜索
searchResults.value = [`结果1 for ${newVal}`, `结果2 for ${newVal}`]
} else {
searchResults.value = []
}
})
// watch 也可以侦听多个源
const firstName = ref('')
const lastName = ref('')
watch([firstName, lastName], ([newFirst, newLast]) => {
console.log(`姓名变为:${newFirst} ${newLast}`)
})
// watchEffect:自动追踪用到的响应式数据
const count = ref(0)
watchEffect(() => {
// 自动追踪 count 的变化
console.log(`count 的当前值是:${count.value}`)
})
</script>
watch vs watchEffect:
watch:需要明确指定侦听的源,可以获取新旧值watchEffect:自动追踪回调中用到的所有响应式依赖,代码更简洁
11. Vue Router:路由管理
Vue Router 是 Vue.js 的官方路由库,用于构建单页应用(SPA)。
11.1 基本配置
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// 路由懒加载:访问时才加载组件
component: () => import('../views/AboutView.vue')
},
{
path: '/user/:id', // 动态路由参数
name: 'user',
component: () => import('../views/UserView.vue')
},
{
// 404 页面
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('../views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
11.2 路由视图与导航
<!-- App.vue -->
<template>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<router-link :to="{ name: 'user', params: { id: 123 } }">
用户详情
</router-link>
</nav>
<!-- 路由出口:匹配到的组件渲染在这里 -->
<router-view />
</template>
11.3 获取路由参数
<!-- UserView.vue -->
<template>
<div>
<h2>用户详情</h2>
<p>用户ID:{{ userId }}</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute() // 获取当前路由信息
const router = useRouter() // 获取路由器实例
const userId = computed(() => route.params.id)
// 编程式导航
function goToHome() {
router.push('/')
}
function goToUser(id) {
router.push({ name: 'user', params: { id } })
}
</script>
11.4 导航守卫
// 全局前置守卫
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'login' }) // 未登录,跳转登录页
} else {
next() // 允许导航
}
})
// 路由配置中标记需要认证的页面
{
path: '/dashboard',
component: DashboardView,
meta: { requiresAuth: true }
}
12. Pinia:状态管理
Pinia 是 Vue 3 推荐的状态管理库,替代了 Vue 2 时代的 Vuex。它更简洁、支持 TypeScript、有更好的 DevTools 集成。
12.1 为什么需要状态管理?
当多个组件需要共享数据时(如用户登录信息、购物车),仅靠 Props/Emit 传递会非常繁琐。状态管理提供一个全局的数据仓库。
12.2 创建 Store
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// State:存储数据
const count = ref(0)
const name = ref('Vue 学习者')
// Getters:计算属性
const doubleCount = computed(() => count.value * 2)
// Actions:修改数据的方法(可以是同步或异步)
function increment() {
count.value++
}
function decrement() {
count.value--
}
async function fetchCount() {
// 模拟异步操作
const response = await new Promise(resolve =>
setTimeout(() => resolve({ count: 100 }), 1000)
)
count.value = response.count
}
function $reset() {
count.value = 0
name.value = 'Vue 学习者'
}
return { count, name, doubleCount, increment, decrement, fetchCount, $reset }
})
12.3 在组件中使用 Store
<template>
<div>
<h2>{{ store.name }} 的计数器</h2>
<p>当前值:{{ store.count }}</p>
<p>双倍值:{{ store.doubleCount }}</p>
<button @click="store.increment">+1</button>
<button @click="store.decrement">-1</button>
<button @click="store.fetchCount">从服务器获取</button>
<button @click="store.$reset">重置</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>
12.4 解构 Store
直接解构会丢失响应性,需要使用 storeToRefs:
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
// ✅ 正确:使用 storeToRefs 解构 state 和 getters
const { count, doubleCount } = storeToRefs(store)
// ✅ 正确:actions 可以直接解构
const { increment, decrement } = store
</script>
13. 实战项目:待办事项应用
下面我们将运用前面学到的知识,构建一个完整的待办事项(Todo)应用。
13.1 项目结构
src/
├── components/
│ ├── TodoInput.vue # 输入组件
│ ├── TodoItem.vue # 单条待办
│ └── TodoFilter.vue # 筛选组件
├── stores/
│ └── todo.js # Pinia Store
├── App.vue
└── main.js
13.2 状态管理 Store
// stores/todo.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useTodoStore = defineStore('todo', () => {
// State
const todos = ref([
{ id: 1, text: '学习 Vue.js 基础', done: true },
{ id: 2, text: '完成待办事项项目', done: false },
{ id: 3, text: '学习 Vue Router', done: false }
])
const filter = ref('all') // 'all' | 'active' | 'completed'
// Getters
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.done)
case 'completed':
return todos.value.filter(t => t.done)
default:
return todos.value
}
})
const activeCount = computed(() => {
return todos.value.filter(t => !t.done).length
})
const completedCount = computed(() => {
return todos.value.filter(t => t.done).length
})
const totalCount = computed(() => todos.value.length)
// Actions
let nextId = 4
function addTodo(text) {
if (!text.trim()) return
todos.value.push({
id: nextId++,
text: text.trim(),
done: false
})
}
function removeTodo(id) {
const index = todos.value.findIndex(t => t.id === id)
if (index > -1) {
todos.value.splice(index, 1)
}
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.done = !todo.done
}
}
function editTodo(id, newText) {
const todo = todos.value.find(t => t.id === id)
if (todo && newText.trim()) {
todo.text = newText.trim()
}
}
function clearCompleted() {
todos.value = todos.value.filter(t => !t.done)
}
function setFilter(newFilter) {
filter.value = newFilter
}
return {
todos, filter, filteredTodos, activeCount, completedCount, totalCount,
addTodo, removeTodo, toggleTodo, editTodo, clearCompleted, setFilter
}
})
13.3 输入组件 TodoInput.vue
<template>
<div class="todo-input">
<input
v-model="newTodo"
@keyup.enter="handleAdd"
placeholder="添加新的待办事项,按回车确认"
class="todo-input__field"
/>
<button @click="handleAdd" class="todo-input__btn" :disabled="!newTodo.trim()">
添加
</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
const store = useTodoStore()
const newTodo = ref('')
function handleAdd() {
store.addTodo(newTodo.value)
newTodo.value = ''
}
</script>
<style scoped>
.todo-input {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.todo-input__field {
flex: 1;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
outline: none;
transition: border-color 0.3s;
}
.todo-input__field:focus {
border-color: #42b883;
}
.todo-input__btn {
padding: 12px 24px;
background: #42b883;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.todo-input__btn:hover:not(:disabled) {
background: #36a072;
}
.todo-input__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
13.4 待办项组件 TodoItem.vue
<template>
<div class="todo-item" :class="{ 'todo-item--done': todo.done }">
<input
type="checkbox"
:checked="todo.done"
@change="store.toggleTodo(todo.id)"
class="todo-item__checkbox"
/>
<span v-if="!isEditing" class="todo-item__text" @dblclick="startEdit">
{{ todo.text }}
</span>
<input
v-else
v-model="editText"
@keyup.enter="finishEdit"
@keyup.escape="cancelEdit"
@blur="finishEdit"
class="todo-item__edit"
ref="editInput"
/>
<button @click="store.removeTodo(todo.id)" class="todo-item__delete">
✕
</button>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { useTodoStore } from '@/stores/todo'
const props = defineProps({
todo: {
type: Object,
required: true
}
})
const store = useTodoStore()
const isEditing = ref(false)
const editText = ref('')
const editInput = ref(null)
function startEdit() {
isEditing.value = true
editText.value = props.todo.text
nextTick(() => {
editInput.value?.focus()
})
}
function finishEdit() {
if (editText.value.trim()) {
store.editTodo(props.todo.id, editText.value)
}
isEditing.value = false
}
function cancelEdit() {
isEditing.value = false
}
</script>
<style scoped>
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.todo-item:hover {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
}
.todo-item--done .todo-item__text {
text-decoration: line-through;
color: #999;
}
.todo-item__checkbox {
width: 20px;
height: 20px;
cursor: pointer;
accent-color: #42b883;
}
.todo-item__text {
flex: 1;
font-size: 16px;
cursor: pointer;
}
.todo-item__edit {
flex: 1;
padding: 4px 8px;
font-size: 16px;
border: 1px solid #42b883;
border-radius: 4px;
outline: none;
}
.todo-item__delete {
background: none;
border: none;
color: #e74c3c;
font-size: 18px;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s;
}
.todo-item:hover .todo-item__delete {
opacity: 1;
}
</style>
13.5 筛选组件 TodoFilter.vue
<template>
<div class="todo-filter">
<div class="todo-filter__buttons">
<button
v-for="option in filterOptions"
:key="option.value"
:class="['todo-filter__btn', { active: store.filter === option.value }]"
@click="store.setFilter(option.value)"
>
{{ option.label }}
</button>
</div>
<div class="todo-filter__info">
<span>{{ store.activeCount }} 项未完成</span>
<button
v-if="store.completedCount > 0"
@click="store.clearCompleted"
class="todo-filter__clear"
>
清除已完成
</button>
</div>
</div>
</template>
<script setup>
import { useTodoStore } from '@/stores/todo'
const store = useTodoStore()
const filterOptions = [
{ label: '全部', value: 'all' },
{ label: '未完成', value: 'active' },
{ label: '已完成', value: 'completed' }
]
</script>
<style scoped>
.todo-filter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
margin-top: 16px;
border-top: 1px solid #eee;
}
.todo-filter__buttons {
display: flex;
gap: 8px;
}
.todo-filter__btn {
padding: 6px 16px;
border: 1px solid #ddd;
border-radius: 20px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.todo-filter__btn.active {
background: #42b883;
color: white;
border-color: #42b883;
}
.todo-filter__info {
display: flex;
align-items: center;
gap: 12px;
color: #666;
font-size: 14px;
}
.todo-filter__clear {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 14px;
}
</style>
13.6 主组件 App.vue
<template>
<div class="app">
<h1 class="app__title">📝 Vue 待办事项</h1>
<p class="app__subtitle">双击待办事项可以编辑</p>
<div class="app__container">
<TodoInput />
<div class="todo-list">
<TodoItem v-for="todo in store.filteredTodos" :key="todo.id" :todo="todo" />
<p v-if="store.filteredTodos.length === 0" class="todo-list__empty">
暂无待办事项 🎉
</p>
</div>
<TodoFilter />
</div>
<p class="app__stats">
共 {{ store.totalCount }} 项 · 已完成 {{ store.completedCount }} 项
</p>
</div>
</template>
<script setup>
import { useTodoStore } from '@/stores/todo'
import TodoInput from './components/TodoInput.vue'
import TodoItem from './components/TodoItem.vue'
import TodoFilter from './components/TodoFilter.vue'
const store = useTodoStore()
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
</style>
<style scoped>
.app {
max-width: 600px;
margin: 40px auto;
padding: 0 20px;
}
.app__title {
text-align: center;
font-size: 32px;
color: #42b883;
margin-bottom: 4px;
}
.app__subtitle {
text-align: center;
color: #999;
font-size: 14px;
margin-bottom: 24px;
}
.app__container {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.todo-list__empty {
text-align: center;
color: #999;
padding: 40px 0;
font-size: 16px;
}
.app__stats {
text-align: center;
color: #999;
font-size: 14px;
margin-top: 16px;
}
</style>
13.7 运行效果
启动项目后,你将拥有一个功能完整的待办事项应用:
- ✅ 添加待办事项(输入框 + 回车/点击按钮)
- ✅ 勾选完成/取消完成
- ✅ 双击编辑待办文字
- ✅ 删除待办事项
- ✅ 按状态筛选(全部/未完成/已完成)
- ✅ 清除所有已完成项
- ✅ 显示统计信息
14. 下一步学习建议
恭喜你完成了 Vue.js 入门教程!以下是继续深入学习的方向:
进阶主题
| 主题 | 说明 |
|---|---|
| TypeScript + Vue | 为项目添加类型安全 |
| 组合式函数(Composables) | 提取和复用有状态逻辑 |
| 自定义指令 | v-focus、v-permission 等 |
| 过渡动画 | <Transition> 和 <TransitionGroup> |
| 服务端渲染(SSR) | Nuxt.js 框架 |
| 单元测试 | Vitest + Vue Test Utils |
推荐学习资源
- Vue.js 官方文档 — 最权威的学习资料
- Vue Router 文档
- Pinia 文档
- Vue.js 新手训练营 — 官方交互式教程
学习路径建议
入门(你在这里)
↓
掌握 Composition API 深度用法
↓
学习 TypeScript 基础
↓
掌握 Vue 生态(Router + Pinia + 测试)
↓
学习 Nuxt.js(全栈框架)
↓
实战项目积累
提示:编程学习最重要的是动手实践。建议你跟随教程把代码敲一遍,然后尝试自己扩展功能——比如给待办事项添加优先级、截止日期、分类标签等。遇到问题时,善用官方文档和搜索引擎。祝你学习愉快!🚀