第四章:Vue — 现代前端开发框架
4.1 为什么要学 Vue
学完前三章,你已经能用 HTML 搭骨架、CSS 画皮肤、JavaScript 写逻辑了。但是你有没有发现,用原生 JS 操作页面其实挺痛苦的?
比如一个最简单的需求:页面上显示一个数字,点按钮让它 +1。
用原生 JS,你得这么写:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生 JS 计数器</title>
</head>
<body>
<p id="num">0</p>
<button id="btn">+1</button>
<script>
let count = 0
const numEl = document.querySelector('#num')
const btnEl = document.querySelector('#btn')
btnEl.addEventListener('click', () => {
count++
numEl.textContent = count
})
</script>
</body>
</html>
流程是这样的:先定义数据 → 手动获取 DOM 元素 → 监听事件 → 手动把数据写回 DOM。每改一个数据,你就得手动找到对应的元素去更新它。页面一复杂,代码就乱成一锅粥。
同样的功能,用 Vue 写是这样的:
<template>
<p>{{ count }}</p>
<button @click="count++">+1</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
就这么几行。 你只管定义数据、修改数据,页面自动跟着变。不用 querySelector,不用 textContent,不用手动同步任何东西。
这就是 Vue 的核心思想——数据驱动:
你只管改数据,Vue 自动帮你更新页面。
Vue 是目前国内最流行的前端框架之一,大量公司在用(尤其是国内团队),社区生态非常成熟,中文文档也很友好。学会 Vue,你就能像搭积木一样快速构建复杂的网页应用。
4.2 环境搭建
之前写 HTML/CSS/JS,新建个文件双击就能跑。但 Vue 项目稍微不一样——它需要一个开发环境来帮你编译 .vue 文件、自动刷新页面等等。别怕,跟着走就行。
第一步:安装 Node.js
去 Node.js 官网 下载 LTS(长期支持版),一路”下一步”安装就好。
一句话理解 Node.js:它让你能在电脑上直接跑 JavaScript(不用浏览器)。安装 Node.js 后会自动附带 npm——一个帮你下载和管理第三方库的”应用商店”。
安装完后,打开终端(Windows 用 PowerShell,Mac 用终端),验证一下:
node -v
npm -v
能看到版本号就说明安装成功了。
第二步:创建 Vue 项目
在终端里,进入你想放项目的文件夹,然后执行:
npm create vue@latest my-app
执行后会问你一堆选项,全部选 No(按回车就是 No) 就好,我们要一个最干净的项目:
✔ Add TypeScript? … No
✔ Add JSX Support? … No
✔ Add Vue Router? … No
✔ Add Pinia? … No
✔ Add Vitest? … No
✔ Add an End-to-End Testing Solution? … No
✔ Add ESLint? … No
✔ Add Prettier? … No
然后依次执行:
cd my-app
npm install
npm run dev
cd my-app— 进入项目目录npm install— 安装项目依赖(第一次要等几十秒)npm run dev— 启动开发服务器
终端会显示类似这样的信息:
VITE v6.x.x ready in 500 ms
➜ Local: http://localhost:5173/
打开浏览器,访问 http://localhost:5173/,看到 Vue 的欢迎页面,就说明成功了!
第三步:认识项目目录结构
项目里文件挺多,但你现阶段只需要关注这几个:
my-app/
├── index.html ← HTML 入口,整个应用挂载到这里的 <div id="app">
├── package.json ← 项目信息和依赖列表(类似"配料表")
├── src/ ← 源代码目录,你写的代码都在这里
│ ├── main.js ← JS 入口文件,负责创建 Vue 应用并挂载到页面
│ ├── App.vue ← 根组件,整个应用的"总壳子"
│ └── components/ ← 组件目录,存放你写的各种组件
└── ...其他文件(暂时不用管)
打开 src/main.js,你会看到这样的代码:
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
翻译成大白话就是:从 Vue 库里导入创建应用的函数,用 App.vue 这个根组件创建应用,挂载到 index.html 里那个 id="app" 的 div 上。 这个文件基本不用改。
第四步:推荐安装 Vue DevTools
在 Chrome 或 Edge 的扩展商店搜索 Vue.js devtools,安装后按 F12 打开开发者工具,会多一个 “Vue” 面板,可以直观地看到组件结构和数据状态,调试起来非常方便。
4.3 Vue 单文件组件(.vue 文件)
Vue 有一个很棒的设计:把一个组件的 HTML、JS、CSS 写在同一个 .vue 文件里,叫做单文件组件(Single File Component,简称 SFC)。
一个 .vue 文件由三个区块组成:
<template>
<!-- HTML 结构写在这里 -->
<div class="greeting">
<h1>{{ message }}</h1>
<p>当前时间:{{ time }}</p>
</div>
</template>
<script setup>
// JavaScript 逻辑写在这里
import { ref } from 'vue'
const message = ref('你好,Vue!')
const time = ref(new Date().toLocaleTimeString())
</script>
<style scoped>
/* CSS 样式写在这里 */
.greeting {
text-align: center;
padding: 40px;
}
h1 {
color: #42b883;
}
</style>
| 区块 | 作用 | 对应前三章的什么 |
|---|---|---|
<template> | 写 HTML 结构 | .html 文件里的 <body> 内容 |
<script setup> | 写 JS 逻辑 | .js 文件 |
<style scoped> | 写 CSS 样式 | .css 文件 |
scoped 是什么意思? 加了 scoped 后,这个 <style> 里写的样式只在当前组件内生效,不会影响其他组件。再也不用担心样式冲突了。
动手试试
现在打开 src/App.vue,把里面的内容全部删掉,换成下面这段:
<template>
<div class="app">
<h1>我的第一个 Vue 页面</h1>
<p>如果你能看到这个页面,说明环境搭建成功了!</p>
</div>
</template>
<script setup>
// 暂时没有逻辑
</script>
<style scoped>
.app {
text-align: center;
padding: 60px 20px;
font-family: sans-serif;
}
h1 {
color: #42b883;
}
</style>
保存文件后,你会发现浏览器自动刷新了,页面显示了你写的内容。这就是 Vue 开发环境自带的热更新(Hot Module Replacement)——改完代码保存,页面即时更新,不需要手动刷新浏览器。
4.4 响应式数据(Vue 的核心魔法)
前面说了,Vue 的核心思想是数据驱动。那 Vue 怎么知道你改了数据?靠的就是响应式系统。
什么是”响应式”?
普通的 JS 变量改了就改了,页面不会有任何反应。但用 Vue 的 ref() 或 reactive() 包裹过的数据,一旦被修改,Vue 就会自动把最新的值更新到页面上。
ref() — 适合基本类型
用于字符串、数字、布尔值这些简单数据:
import { ref } from 'vue'
const count = ref(0) // 数字
const name = ref('小明') // 字符串
const isDone = ref(false) // 布尔值
修改数据时,在 JS 代码里要通过 .value 来访问和修改:
count.value++ // 0 → 1,页面自动更新
name.value = '小红' // 页面自动显示"小红"
isDone.value = true
但在 <template> 里用的时候,不需要写 .value,Vue 会自动帮你”解包”:
<template>
<p>{{ count }}</p> <!-- 直接用,不用写 count.value -->
<p>{{ name }}</p>
</template>
划重点:
ref在<script>里要写.value,在<template>里不用。这是初学者最容易搞混的地方,多写几次就记住了。
reactive() — 适合对象和数组
当你要管理一组相关的数据时,用 reactive():
import { reactive } from 'vue'
const user = reactive({
name: '小明',
age: 20,
hobbies: ['打游戏', '看动漫']
})
// 直接修改属性,不需要 .value
user.age = 21
user.hobbies.push('写代码')
reactive() 的好处是直接操作属性,不需要 .value,用起来更自然。
完整示例:计数器
下面是一个完整的计数器组件,把 App.vue 改成这样试试:
<template>
<div class="counter">
<h1>计数器</h1>
<p class="num">{{ count }}</p>
<button @click="count++">+1</button>
<button @click="count--">-1</button>
<button @click="count = 0">归零</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style scoped>
.counter {
text-align: center;
padding: 40px;
font-family: sans-serif;
}
.num {
font-size: 48px;
font-weight: bold;
color: #42b883;
margin: 20px 0;
}
button {
margin: 0 8px;
padding: 8px 20px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #42b883;
color: white;
cursor: pointer;
}
button:hover {
background: #359e6e;
}
</style>
对比一下原生 JS 的写法,你需要 querySelector + addEventListener + textContent = ...,Vue 只需要一个 ref 加一个 @click。
实际建议
- 简单值(数字、字符串、布尔值)→ 用
ref() - 复杂对象(有多个属性的对象、数组)→ 用
reactive() - 拿不准的时候,用
ref()就不会错,它什么类型都能包
4.5 模板语法
Vue 的 <template> 里可以使用一些特殊语法,让 HTML 和数据”连接”起来。下面一个一个来。
文本插值 {{ }}
用双大括号把数据”插”到 HTML 里:
<template>
<h1>{{ title }}</h1>
<p>1 + 1 = {{ 1 + 1 }}</p>
<p>{{ message.toUpperCase() }}</p>
</template>
<script setup>
import { ref } from 'vue'
const title = ref('Vue 模板语法')
const message = ref('hello vue')
</script>
{{ }} 里面可以写数据名、表达式、调用方法,但不能写语句(比如 if、for)。
属性绑定 :属性名
HTML 属性的值要动态变化时,用 : 前缀(这是 v-bind: 的缩写):
<template>
<img :src="imageUrl" :alt="imageAlt" />
<a :href="link">点击跳转</a>
<!-- 动态 class -->
<p :class="isActive ? 'active' : ''">动态样式</p>
<!-- 动态 style -->
<p :style="{ color: textColor, fontSize: size + 'px' }">彩色文字</p>
</template>
<script setup>
import { ref } from 'vue'
const imageUrl = ref('https://vuejs.org/images/logo.png')
const imageAlt = ref('Vue Logo')
const link = ref('https://cn.vuejs.org')
const isActive = ref(true)
const textColor = ref('#42b883')
const size = ref(24)
</script>
<style scoped>
.active {
font-weight: bold;
color: #42b883;
}
</style>
记住:静态属性直接写,动态属性加 :。
事件绑定 @事件名
用 @ 前缀监听用户操作(这是 v-on: 的缩写):
<template>
<button @click="handleClick">点我</button>
<button @click="count++">快捷写法:直接写表达式</button>
<input @input="handleInput" />
<input @keyup.enter="handleEnter" placeholder="按回车触发" />
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function handleClick() {
alert('你点击了按钮!')
}
function handleInput(event) {
console.log('输入内容:', event.target.value)
}
function handleEnter() {
alert('你按了回车!')
}
</script>
常用事件:
| 事件 | 触发时机 |
|---|---|
@click | 点击 |
@input | 输入内容变化 |
@keyup.enter | 按下回车键 |
@submit.prevent | 表单提交(阻止默认刷新) |
@mouseover | 鼠标移入 |
注意 @keyup.enter 里的 .enter 是 Vue 提供的事件修饰符,非常方便。@submit.prevent 里的 .prevent 等价于 event.preventDefault()。
双向绑定 v-model
这是 Vue 最方便的特性之一。v-model 可以让输入框的值和数据自动双向同步——你改数据,输入框跟着变;你在输入框打字,数据也跟着变。
用原生 JS 实现同样的效果:
<input id="input" />
<p id="output"></p>
<script>
const input = document.querySelector('#input')
const output = document.querySelector('#output')
input.addEventListener('input', (e) => {
output.textContent = e.target.value
})
</script>
用 Vue:
<template>
<input v-model="name" placeholder="输入你的名字" />
<p>{{ name }}</p>
</template>
<script setup>
import { ref } from 'vue'
const name = ref('')
</script>
一行 v-model 顶原生 JS 好几行代码。 输入框里打字,<p> 标签的内容实时同步更新。
综合示例:问候语表单
把下面的代码写进 App.vue,感受一下各种模板语法配合使用的效果:
<template>
<div class="form-demo">
<h2>问候语生成器</h2>
<label>你的名字:</label>
<input v-model="name" placeholder="请输入名字" />
<label>选择心情:</label>
<select v-model="mood">
<option value="开心">开心 😊</option>
<option value="一般">一般 😐</option>
<option value="难过">难过 😢</option>
</select>
<div class="result" v-if="name">
<p>
你好,<strong>{{ name }}</strong>!
今天你的心情是<strong>{{ mood }}</strong>。
</p>
<p :style="{ color: moodColor }">
{{ greetingText }}
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const name = ref('')
const mood = ref('开心')
const moodColor = computed(() => {
if (mood.value === '开心') return '#42b883'
if (mood.value === '一般') return '#e6a23c'
return '#f56c6c'
})
const greetingText = computed(() => {
if (mood.value === '开心') return '太棒了,继续保持好心情!'
if (mood.value === '一般') return '没关系,明天会更好!'
return '抱抱你,有什么我能帮忙的吗?'
})
</script>
<style scoped>
.form-demo {
max-width: 400px;
margin: 40px auto;
padding: 30px;
font-family: sans-serif;
border: 1px solid #e0e0e0;
border-radius: 12px;
}
label {
display: block;
margin-top: 16px;
margin-bottom: 4px;
font-weight: bold;
}
input, select {
width: 100%;
padding: 8px 12px;
font-size: 15px;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
}
.result {
margin-top: 24px;
padding: 16px;
background: #f9f9f9;
border-radius: 8px;
}
</style>
这个示例用到了 v-model(双向绑定输入框和下拉框)、{{ }}(文本插值)、:style(动态样式)、v-if(有名字才显示结果)、computed(计算属性,后面会详细讲)。
4.6 条件渲染与列表渲染
v-if / v-else-if / v-else — 条件渲染
根据数据决定要不要显示某个元素:
<template>
<div class="login-demo">
<div v-if="isLogin">
<p>欢迎回来,{{ username }}!</p>
<button @click="isLogin = false">退出登录</button>
</div>
<div v-else>
<p>你还没有登录</p>
<button @click="login">点击登录</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const isLogin = ref(false)
const username = ref('小明')
function login() {
isLogin.value = true
}
</script>
v-if 是”真的把元素从页面上删掉/添加”。条件为 false 时,这个元素在 DOM 里根本不存在。
你也可以写多个条件分支:
<template>
<p v-if="score >= 90">优秀 🏆</p>
<p v-else-if="score >= 60">及格 ✅</p>
<p v-else>不及格 ❌</p>
</template>
<script setup>
import { ref } from 'vue'
const score = ref(85)
</script>
v-show — 另一种条件显示
<p v-show="isVisible">我可以被隐藏</p>
v-show 和 v-if 的效果看起来一样,但原理不同:
v-if是真删除元素,v-show是用 CSS 的display: none隐藏元素。
选择建议:频繁切换显示/隐藏用 v-show(性能更好),否则用 v-if。
v-for — 列表渲染
当你有一个数组,要把里面每个元素渲染成一个列表项,就用 v-for:
<template>
<h3>水果列表</h3>
<ul>
<li v-for="fruit in fruits" :key="fruit">
{{ fruit }}
</li>
</ul>
</template>
<script setup>
import { ref } from 'vue'
const fruits = ref(['苹果', '香蕉', '橘子', '葡萄'])
</script>
如果需要索引:
<li v-for="(fruit, index) in fruits" :key="index">
{{ index + 1 }}. {{ fruit }}
</li>
为什么必须加
:key? Vue 在更新列表时,需要通过key来识别”哪个元素是哪个”。如果不加key,Vue 没法高效地更新列表,可能会出现奇怪的 bug。一般用元素的id(如果有的话)或者index作为 key。
综合示例:可添加/删除的列表
<template>
<div class="list-demo">
<h2>我的购物清单</h2>
<div class="input-row">
<input
v-model="newItem"
@keyup.enter="addItem"
placeholder="输入物品名称,按回车添加"
/>
<button @click="addItem">添加</button>
</div>
<p v-if="items.length === 0" class="empty">清单是空的,快添加点什么吧~</p>
<ul v-else>
<li v-for="(item, index) in items" :key="index">
<span>{{ item }}</span>
<button class="delete-btn" @click="removeItem(index)">删除</button>
</li>
</ul>
<p class="total">共 {{ items.length }} 项</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const newItem = ref('')
const items = ref(['牛奶', '面包', '鸡蛋'])
function addItem() {
const value = newItem.value.trim()
if (value) {
items.value.push(value)
newItem.value = ''
}
}
function removeItem(index) {
items.value.splice(index, 1)
}
</script>
<style scoped>
.list-demo {
max-width: 420px;
margin: 40px auto;
padding: 30px;
font-family: sans-serif;
border: 1px solid #e0e0e0;
border-radius: 12px;
}
.input-row {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.input-row input {
flex: 1;
padding: 8px 12px;
font-size: 15px;
border: 1px solid #ccc;
border-radius: 6px;
}
.input-row button {
padding: 8px 16px;
font-size: 15px;
border: none;
border-radius: 6px;
background: #42b883;
color: white;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 8px;
background: #f5f5f5;
border-radius: 6px;
}
.delete-btn {
padding: 4px 12px;
font-size: 13px;
border: none;
border-radius: 4px;
background: #f56c6c;
color: white;
cursor: pointer;
}
.empty {
color: #999;
text-align: center;
}
.total {
text-align: right;
color: #666;
font-size: 14px;
}
</style>
这个例子综合了 v-model、@click、@keyup.enter、v-if/v-else、v-for,是目前学到的所有东西的一个小汇总。仔细看看代码,确保每一行你都能理解。
4.7 组件化开发
到目前为止,我们所有代码都写在 App.vue 一个文件里。但实际项目中,一个页面可能有导航栏、侧边栏、内容区、表单、列表……全塞在一个文件里,几百上千行,根本没法维护。
Vue 的解决方案是组件化——把页面拆成一个个独立的”零件”,每个零件是一个 .vue 文件,各管各的逻辑和样式,然后像搭积木一样拼在一起。
App.vue(根组件)
├── Header.vue(顶部导航)
├── TodoList.vue(待办列表)
│ └── TodoItem.vue(单个待办项)
└── Footer.vue(底部信息)
创建一个组件
在 src/components/ 目录下新建一个文件 MyButton.vue:
<template>
<button class="my-btn">
{{ text }}
</button>
</template>
<script setup>
defineProps({
text: String
})
</script>
<style scoped>
.my-btn {
padding: 8px 20px;
font-size: 15px;
border: none;
border-radius: 6px;
background: #42b883;
color: white;
cursor: pointer;
}
.my-btn:hover {
background: #359e6e;
}
</style>
使用组件
在 App.vue 里 import 它,然后直接在 <template> 中当标签用:
<template>
<div>
<h1>组件示例</h1>
<MyButton text="点赞" />
<MyButton text="收藏" />
<MyButton text="分享" />
</div>
</template>
<script setup>
import MyButton from './components/MyButton.vue'
</script>
一个组件写一次,到处用。text 是我们传给组件的数据——这就是 Props。
Props — 父组件向子组件传数据
当你需要让组件显示不同的内容时,就通过 Props 从外面传数据进去。
子组件用 defineProps() 声明自己接收哪些数据:
<!-- src/components/UserCard.vue -->
<template>
<div class="user-card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
<p>简介:{{ bio }}</p>
</div>
</template>
<script setup>
defineProps({
name: String,
age: Number,
bio: String
})
</script>
<style scoped>
.user-card {
padding: 16px;
margin: 12px 0;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
</style>
父组件在使用时传值:
<!-- App.vue -->
<template>
<div>
<h2>用户列表</h2>
<UserCard name="小明" :age="20" bio="前端爱好者" />
<UserCard name="小红" :age="19" bio="设计达人" />
</div>
</template>
<script setup>
import UserCard from './components/UserCard.vue'
</script>
注意:字符串直接写,数字、布尔值等非字符串类型要加 :(:age="20" 传的是数字 20,age="20" 传的是字符串 “20”)。
Emit — 子组件向父组件发消息
Props 是”父传子”,那”子传父”呢?比如子组件里有个删除按钮,点击后需要通知父组件删除数据。这时候用 Emit。
子组件用 defineEmits() 声明自己会发出哪些事件:
<!-- src/components/TodoItem.vue -->
<template>
<div class="todo-item">
<span :class="{ done: isDone }">{{ text }}</span>
<div>
<button @click="emit('toggle')">
{{ isDone ? '撤销' : '完成' }}
</button>
<button class="del" @click="emit('delete')">删除</button>
</div>
</div>
</template>
<script setup>
defineProps({
text: String,
isDone: Boolean
})
const emit = defineEmits(['toggle', 'delete'])
</script>
<style scoped>
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin-bottom: 8px;
background: #f5f5f5;
border-radius: 6px;
}
.done {
text-decoration: line-through;
color: #999;
}
button {
margin-left: 6px;
padding: 4px 12px;
font-size: 13px;
border: none;
border-radius: 4px;
background: #42b883;
color: white;
cursor: pointer;
}
.del {
background: #f56c6c;
}
</style>
父组件用 @事件名 监听子组件发出的事件:
<!-- App.vue -->
<template>
<div class="app">
<h2>待办事项</h2>
<TodoItem
v-for="(todo, index) in todos"
:key="todo.id"
:text="todo.text"
:is-done="todo.done"
@toggle="todo.done = !todo.done"
@delete="todos.splice(index, 1)"
/>
</div>
</template>
<script setup>
import { reactive } from 'vue'
import TodoItem from './components/TodoItem.vue'
const todos = reactive([
{ id: 1, text: '学习 HTML', done: true },
{ id: 2, text: '学习 CSS', done: true },
{ id: 3, text: '学习 JavaScript', done: true },
{ id: 4, text: '学习 Vue', done: false },
])
</script>
<style scoped>
.app {
max-width: 450px;
margin: 40px auto;
padding: 30px;
font-family: sans-serif;
}
</style>
数据的流动方向很清晰:父组件通过 Props 把数据传给子组件,子组件通过 Emit 把事件通知传回父组件。这是 Vue 组件通信最基本也是最常用的模式。
4.8 生命周期与侧边效果
什么是生命周期?
一个组件从创建 → 挂载到页面 → 更新 → 销毁,会经历一系列阶段,这就是”生命周期”。Vue 提供了一些”钩子函数”,让你在特定阶段执行代码。
作为入门,你只需要记住一个钩子:onMounted。
onMounted — 组件加载完成后执行
onMounted 里的代码会在组件渲染到页面之后执行。最常见的用途是页面加载时请求数据:
<template>
<div>
<p v-if="loading">加载中...</p>
<div v-else>
<h3>{{ userData.name }}</h3>
<p>{{ userData.email }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const userData = ref({})
const loading = ref(true)
onMounted(async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users/1')
userData.value = await res.json()
loading.value = false
})
</script>
这段代码的意思是:组件加载完成后,发起网络请求获取用户数据,拿到数据后更新页面。
computed — 计算属性
有些数据不是直接定义的,而是根据其他数据计算得来的。比如你有一个商品列表,想实时显示总价。这时候用 computed:
<template>
<ul>
<li v-for="item in cart" :key="item.name">
{{ item.name }} — ¥{{ item.price }}
</li>
</ul>
<p><strong>总价:¥{{ totalPrice }}</strong></p>
</template>
<script setup>
import { reactive, computed } from 'vue'
const cart = reactive([
{ name: '苹果', price: 5 },
{ name: '牛奶', price: 12 },
{ name: '面包', price: 8 },
])
const totalPrice = computed(() => {
return cart.reduce((sum, item) => sum + item.price, 0)
})
</script>
computed 的好处:当 cart 数据变化时,totalPrice 会自动重新计算,你不需要手动调用任何函数。而且 Vue 内部有缓存——如果依赖的数据没变,computed 不会重复计算,性能很好。
watch — 监听数据变化
有时候你需要在某个数据变化时执行一些操作(比如数据变了就保存到本地存储),可以用 watch:
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
watch(keyword, (newVal, oldVal) => {
console.log(`搜索词从"${oldVal}"变成了"${newVal}"`)
})
</script>
watch 在入门阶段用得不多,知道它的存在就好。大部分场景 computed 就够用了。
4.9 实战练习:待办事项应用
学了这么多,现在把所有知识串起来,做一个完整的待办事项(Todo)应用。
功能需求:
- 输入框 + 添加按钮(
v-model+@click) - 待办列表渲染(
v-for+ 组件化) - 点击完成/删除(事件 + 数据操作)
- 统计未完成数量(
computed)
我们把它拆成两个组件:App.vue(主页面)和 TodoItem.vue(单个待办项)。
TodoItem.vue — 单个待办项组件
在 src/components/ 下新建 TodoItem.vue:
<template>
<div class="todo-item" :class="{ 'is-done': done }">
<span class="text" @click="emit('toggle')">
{{ done ? '✅' : '⬜' }} {{ text }}
</span>
<button class="del-btn" @click="emit('delete')">✕</button>
</div>
</template>
<script setup>
defineProps({
text: String,
done: Boolean
})
const emit = defineEmits(['toggle', 'delete'])
</script>
<style scoped>
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
transition: all 0.2s;
}
.todo-item:hover {
border-color: #42b883;
}
.is-done {
opacity: 0.5;
}
.text {
cursor: pointer;
font-size: 15px;
user-select: none;
}
.is-done .text {
text-decoration: line-through;
color: #999;
}
.del-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: #ccc;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.del-btn:hover {
background: #fee;
color: #f56c6c;
}
</style>
App.vue — 主页面
<template>
<div class="todo-app">
<h1>📝 待办事项</h1>
<div class="input-row">
<input
v-model="newTodo"
@keyup.enter="addTodo"
placeholder="今天要做什么?"
/>
<button @click="addTodo">添加</button>
</div>
<div class="stats">
<span>共 {{ todos.length }} 项,未完成 {{ remainCount }} 项</span>
<button v-if="doneCount > 0" class="clear-btn" @click="clearDone">
清除已完成
</button>
</div>
<div v-if="todos.length === 0" class="empty">
还没有待办事项,添加一个吧!
</div>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:text="todo.text"
:done="todo.done"
@toggle="toggleTodo(todo.id)"
@delete="deleteTodo(todo.id)"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import TodoItem from './components/TodoItem.vue'
const newTodo = ref('')
const nextId = ref(4)
const todos = ref([
{ id: 1, text: '学习 HTML ✔', done: true },
{ id: 2, text: '学习 CSS ✔', done: true },
{ id: 3, text: '学习 Vue', done: false },
])
const remainCount = computed(() => {
return todos.value.filter(t => !t.done).length
})
const doneCount = computed(() => {
return todos.value.filter(t => t.done).length
})
function addTodo() {
const text = newTodo.value.trim()
if (!text) return
todos.value.push({ id: nextId.value++, text, done: false })
newTodo.value = ''
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
function deleteTodo(id) {
todos.value = todos.value.filter(t => t.id !== id)
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
</script>
<style scoped>
.todo-app {
max-width: 480px;
margin: 40px auto;
padding: 30px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
h1 {
text-align: center;
font-size: 28px;
margin-bottom: 24px;
}
.input-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.input-row input {
flex: 1;
padding: 10px 14px;
font-size: 15px;
border: 2px solid #e0e0e0;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus {
border-color: #42b883;
}
.input-row button {
padding: 10px 20px;
font-size: 15px;
border: none;
border-radius: 8px;
background: #42b883;
color: white;
cursor: pointer;
transition: background 0.2s;
}
.input-row button:hover {
background: #359e6e;
}
.stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
font-size: 14px;
color: #888;
}
.clear-btn {
padding: 4px 12px;
font-size: 13px;
border: none;
border-radius: 4px;
background: #f0f0f0;
color: #666;
cursor: pointer;
}
.clear-btn:hover {
background: #e0e0e0;
}
.empty {
text-align: center;
padding: 40px 0;
color: #ccc;
font-size: 15px;
}
</style>
回顾与对比
同样的”待办事项”功能,如果用原生 JS 写,你需要:
- 手动
querySelector获取输入框、按钮、列表容器 - 手动
addEventListener绑定点击事件 - 手动
createElement+appendChild往列表里添加元素 - 手动遍历 DOM 元素来计算未完成数量
- 手动删除 DOM 节点
- 数据和 DOM 到处都是,改一个功能牵一发动全身
用 Vue 写:
- 数据定义在
ref/reactive里,清清楚楚 - 页面和数据自动同步,不用手动操作 DOM
v-for一行搞定列表渲染v-model一行搞定输入框绑定computed自动计算未完成数量- 组件化拆分,各管各的,好维护好复用
数据驱动 + 组件化,这就是 Vue 带给你的超能力。
本章总结
一张表回顾所有核心知识点:
| 知识点 | 语法 | 一句话解释 |
|---|---|---|
| 响应式数据 | ref() / reactive() | 数据变了,页面自动更新 |
| 文本插值 | {{ }} | 在 HTML 里显示数据 |
| 属性绑定 | :属性名 | 动态设置 HTML 属性 |
| 事件绑定 | @事件名 | 监听用户操作 |
| 双向绑定 | v-model | 输入框和数据自动同步 |
| 条件渲染 | v-if / v-show | 控制元素显示隐藏 |
| 列表渲染 | v-for | 循环渲染列表 |
| 计算属性 | computed() | 根据已有数据自动计算新数据 |
| 生命周期 | onMounted() | 组件加载完成后执行 |
| 组件 Props | defineProps() | 父组件向子组件传数据 |
| 组件 Emit | defineEmits() | 子组件向父组件发消息 |
下一章我们就要用这些知识,搭建一个真正的 AI 对话应用了。组件化开发、Props 和 Emit 会是重中之重——到时候你会把页面拆成导航栏、聊天区域、消息气泡、输入框等组件,各管各的,像搭积木一样拼出一个完整的应用。
学习建议:本章代码一定要亲手跑一遍。看代码和写代码的效果天差地别。遇到不明白的地方,改改数据看看页面怎么变化,比看十遍教程都管用。