Vue.js前端开发入门教程

教程简介

零基础Vue.js前端开发完整教程,涵盖模板语法、组件化开发、响应式数据、Props/Emit、生命周期、路由、Pinia状态管理等核心技能,配有完整待办事项应用实战项目,适合前端开发初学者。

Vue.js 前端开发入门教程

本教程面向零基础学习者,从零开始掌握 Vue.js 前端开发的核心知识与实战技能。


目录

  1. 什么是 Vue.js
  2. 环境搭建与安装
  3. 模板语法
  4. 响应式数据
  5. 组件化开发
  6. Props 与 Emit:组件间通信
  7. 生命周期钩子
  8. 列表渲染与条件渲染
  9. 事件处理与表单绑定
  10. 计算属性与侦听器
  11. Vue Router:路由管理
  12. Pinia:状态管理
  13. 实战项目:待办事项应用
  14. 下一步学习建议

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 侦听器 watchwatchEffect

<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-focusv-permission
过渡动画 <Transition><TransitionGroup>
服务端渲染(SSR) Nuxt.js 框架
单元测试 Vitest + Vue Test Utils

推荐学习资源

学习路径建议

入门(你在这里)
  ↓
掌握 Composition API 深度用法
  ↓
学习 TypeScript 基础
  ↓
掌握 Vue 生态(Router + Pinia + 测试)
  ↓
学习 Nuxt.js(全栈框架)
  ↓
实战项目积累

提示:编程学习最重要的是动手实践。建议你跟随教程把代码敲一遍,然后尝试自己扩展功能——比如给待办事项添加优先级、截止日期、分类标签等。遇到问题时,善用官方文档和搜索引擎。祝你学习愉快!🚀

内容声明

本文内容为AI技术学习教程,仅供学习参考。如涉及技术问题,欢迎通过 xurj005@163.com 与我们交流。

目录