【从零构建多智能体 02】Vue3.5 基础入门:从 HTML 示例看懂响应式、列表渲染与组件通信
系列总名称:从零构建多智能体:Harness & Hermes 项目实战系列
项目方向:Echo English AI:AI 英语陪练学习站
本文是系列第 2 篇。上一篇我们完成了 Python 基础和多格式文件解析器;这一篇暂时不进入 Vite 工程搭建,而是先用几个可直接运行的 HTML 示例,把 Vue3.5 的核心语法讲清楚。后面真正写移动端英语学习网站时,这些语法都会用到。
一、为什么第二篇先讲 Vue 基础?
我们最终要做的是一个完整项目:
text
Echo English AI:AI 英语陪练学习站
后续它会包含:
- 今日英语学习任务;
- AI 口语陪练;
- 单词复习;
- 阅读训练;
- 写作批改;
- 学习复盘;
- 登录鉴权;
- 后端接口;
- AI 多智能体协作。
但是如果一上来就创建 Vite 工程、拆组件、写路由、接接口,新手很容易卡在一堆概念里:
ref是什么?v-model为什么能同步输入框?v-for是怎么循环数组的?:key为什么必须写?@click、@change是什么意思?props和emit是怎么让组件通信的?computed和普通函数有什么区别?
所以第二篇先做基础教学。我们先通过独立 HTML 文件看懂 Vue3.5 的核心写法,再进入真正的项目工程。
二、这几个 HTML 示例分别讲什么?
vue3.5 案例 文件夹里有 4 个 HTML 示例。它们是递进关系:
| 文件 | 主要内容 | 你应该重点看什么 |
|---|---|---|
index.html |
Vue HelloWorld | createApp、ref、插值语法、点击事件 |
todolist.html |
待办基础版 | v-model、@change、@keyup.enter、v-for、v-if |
todolist_advanced.html |
组件通信版 | 子组件、props、emit、父子通信 |
todolist_complete.html |
完整功能版 | computed、筛选、编辑、生命周期 |
虽然示例用的是 TodoList,但它背后的知识点和我们后续要做的英语学习网站是一致的。
例如:
| TodoList 示例 | 后续 Echo English AI 中的对应功能 |
|---|---|
| 添加待办 | 添加今日学习任务 |
| 循环渲染待办列表 | 渲染单词列表、练习列表、聊天消息 |
| 删除待办 | 删除生词、删除学习记录 |
| 切换完成状态 | 标记单词已掌握、任务已完成 |
| 筛选已完成/未完成 | 筛选已学/未学/需复习 |
| 编辑待办 | 编辑学习计划、修改作文内容 |
所以不要把它只看成待办清单。它其实是在训练你理解 Vue 的数据驱动思想。
三、如何运行这些 HTML 示例?
这些示例不需要安装 Vite,也不需要打包。
你可以直接双击 HTML 文件,用浏览器打开。
也可以在编辑器里打开文件,然后用浏览器预览。
它们之所以能直接运行,是因为页面中通过 CDN 引入了 Vue:
html
<script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js"></script>
这行代码的作用是:从网络加载 Vue3.5 的浏览器版本。
加载成功后,页面里会出现一个全局变量:
javascript
Vue
后面代码里写:
javascript
const { createApp, ref } = Vue;
就是从这个 Vue 对象里取出要用的 API。
四、先看最小 Vue 程序:index.html
一个最小 Vue 页面通常有 4 个部分:
text
HTML 挂载点 -> 引入 Vue -> 创建应用 -> 挂载应用
你可以先新建一个文件:
text
index.html
然后把下面完整代码复制进去,双击用浏览器打开。
html
<!DOCTYPE html> <!-- 声明这是 HTML5 文档,浏览器会按现代标准解析页面 -->
<html lang="zh-CN"> <!-- lang 表示页面语言,这里是中文页面 -->
<head> <!-- head 里放页面配置,不直接展示在页面主体中 -->
<meta charset="UTF-8"> <!-- 设置字符编码为 UTF-8,避免中文乱码 -->
<title>Vue 3.5 HelloWorld</title> <!-- 浏览器标签页标题 -->
<script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js"></script> <!-- 通过 CDN 引入 Vue3.5,加载后会得到全局变量 Vue -->
</head> <!-- head 结束 -->
<body> <!-- body 里放页面真正显示的内容 -->
<div id="app"> <!-- Vue 的挂载点,后面 mount("#app") 会接管这个区域 -->
<h1>{{ msg }}</h1> <!-- 插值语法,把 Vue 数据 msg 显示到页面上 -->
<button @click="changeMsg">点击修改文本</button> <!-- @click 是 Vue 点击事件绑定,点击后执行 changeMsg 函数 -->
</div> <!-- Vue 管理区域结束 -->
<script> <!-- 编写 JavaScript 逻辑 -->
const { createApp, ref } = Vue; // 从 Vue 对象里取出 createApp 和 ref 两个 API
createApp({ // 创建一个 Vue 应用
setup() { // setup 是组合式 API 的入口,响应式数据和方法通常写在这里
const msg = ref("Hello Vue 3.5!"); // 创建响应式数据 msg,初始值是 Hello Vue 3.5!
const changeMsg = () => { // 定义一个函数,点击按钮时会执行它
msg.value = "数据变了,页面会自动更新!"; // 修改 ref 数据时,JavaScript 里要写 .value
}; // changeMsg 函数结束
return { msg, changeMsg }; // 把 msg 和 changeMsg 暴露给模板使用
} // setup 结束
}).mount("#app"); // 把 Vue 应用挂载到 id="app" 的元素上
</script> <!-- JavaScript 结束 -->
</body> <!-- body 结束 -->
</html> <!-- HTML 文档结束 -->
1. 挂载点是什么?
示例里有这样一段:
html
<div id="app">
<h1>{{ msg }}</h1>
<button @click="changeMsg">点击修改文本</button>
</div>
这里的:
html
<div id="app">
就是 Vue 的挂载点。
你可以理解成:
text
Vue 只管理 id="app" 这个区域里面的内容。
后面这句:
javascript
}).mount("#app");
就是告诉 Vue:
text
请接管页面中 id 为 app 的这个区域。
2. {``{ msg }} 是什么?
html
<h1>{{ msg }}</h1>
这是 Vue 的插值语法。
意思是:
text
把 JavaScript 里的 msg 显示到页面这里。
如果 msg 的值是:
javascript
"Hello Vue 3.5!"
页面上就会显示:
text
Hello Vue 3.5!
如果后面 msg 变了,页面也会自动变。
这就是 Vue 的核心特点:
text
数据变了,页面自动更新。
3. ref 是什么?
示例里有:
javascript
const msg = ref("Hello Vue 3.5!");
ref 用来创建响应式数据。
普通变量是这样:
javascript
let msg = "Hello";
它只是普通 JavaScript 变量。它改了,Vue 不一定知道。
Vue 响应式变量是这样:
javascript
const msg = ref("Hello");
ref 包起来以后,Vue 就能追踪它。
当 msg 改变时,模板里的:
html
{{ msg }}
也会自动更新。
4. 为什么 JS 里要写 .value?
示例中修改数据时是这样:
javascript
msg.value = "数据驱动视图,太简单了!";
为什么不是:
javascript
msg = "新的内容";
因为 ref() 返回的不是原始字符串,而是一个包装对象。
可以粗略理解为:
javascript
const msg = {
value: "Hello Vue 3.5!"
}
所以在 JavaScript 里要通过:
javascript
msg.value
读取或修改它的真实值。
但是在 HTML 模板中,Vue 会自动帮你拆开,所以模板里直接写:
html
{{ msg }}
不用写:
html
{{ msg.value }}
简单记:
text
JavaScript 里用 msg.value
模板里直接用 msg
5. @click="changeMsg" 是什么?
html
<button @click="changeMsg">点击修改文本</button>
@click 是 Vue 的事件绑定语法。
它等价于:
html
v-on:click="changeMsg"
意思是:
text
当用户点击这个按钮时,执行 changeMsg 函数。
函数定义在 setup() 里面:
javascript
const changeMsg = () => {
msg.value = "数据驱动视图,太简单了!";
};
执行流程是:
text
用户点击按钮
-> Vue 触发 changeMsg
-> changeMsg 修改 msg.value
-> msg 是响应式数据
-> 页面中的 {{ msg }} 自动更新
五、setup() 和 return 到底做什么?
示例里有:
javascript
createApp({
setup() {
const msg = ref("Hello Vue 3.5!");
const changeMsg = () => {
msg.value = "数据驱动视图,太简单了!";
};
return { msg, changeMsg };
}
}).mount("#app");
这里最容易困惑的是:
javascript
return { msg, changeMsg };
它的意思是:
text
把 msg 和 changeMsg 暴露给 HTML 模板使用。
如果你在 setup() 里定义了:
javascript
const msg = ref("Hello");
但是没有 return:
javascript
return { msg };
那么模板里写:
html
{{ msg }}
就找不到它。
所以记住:
text
模板要用谁,setup 就要 return 谁。
这个规则也能解释你问的 todos 是怎么找到的。后面会讲。
六、基础版 TodoList:todolist.html
这个文件开始出现更多 Vue 常用语法:
v-model@change@keyup.enter@clickv-for:keyv-if
我们一个一个讲。
你可以新建文件:
text
todolist.html
复制下面完整代码运行。
html
<!DOCTYPE html> <!-- 声明 HTML5 文档 -->
<html lang="zh-CN"> <!-- 设置页面语言为中文 -->
<head> <!-- 页面配置区域开始 -->
<meta charset="UTF-8"> <!-- 设置 UTF-8 编码,避免中文乱码 -->
<title>Vue 3.5 TodoList 基础版</title> <!-- 浏览器标签页标题 -->
<script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js"></script> <!-- 引入 Vue3.5 浏览器版 -->
<style> /* 页面样式开始 */
.box { max-width: 600px; margin: 2rem auto; padding: 0 1rem; font-family: sans-serif; } /* 页面容器居中,并限制宽度 */
.add-todo { display: flex; gap: 0.5rem; margin-bottom: 1rem; } /* 输入框和按钮横向排列 */
input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; } /* 输入框占满剩余宽度 */
button { padding: 0.5rem 1rem; background: #42b983; color: #fff; border: none; cursor: pointer; border-radius: 4px; } /* 按钮基础样式 */
.todo-list { list-style: none; padding: 0; } /* 去掉 ul 默认圆点和内边距 */
.todo-item { padding: 0.5rem; border-bottom: 1px solid #f1f1f1; } /* 每一条待办的样式 */
.empty { color: #999; text-align: center; padding: 1rem; } /* 空状态提示样式 */
</style> <!-- 页面样式结束 -->
</head> <!-- 页面配置区域结束 -->
<body> <!-- 页面主体开始 -->
<div id="app" class="box"> <!-- Vue 挂载点,也是页面外层容器 -->
<h2>今日学习任务(基础版)</h2> <!-- 页面标题,这里改成更贴近英语学习项目的表达 -->
<div class="add-todo"> <!-- 输入区域容器 -->
<input
v-model="inputVal"
@change="changeVal"
@keyup.enter="addTodo"
placeholder="输入任务,例如:背 20 个单词"
> <!-- v-model 绑定 inputVal;@change 内容变化后执行;@keyup.enter 按回车执行 addTodo -->
<button @click="addTodo">添加</button> <!-- 点击按钮时执行 addTodo 函数 -->
</div> <!-- 输入区域结束 -->
<ul class="todo-list"> <!-- 待办列表容器 -->
<li class="todo-item" v-for="(todo, index) in todos" :key="index"> <!-- v-for 遍历 todos 数组;todo 是当前项;index 是下标;:key 给每项唯一标识 -->
{{ index + 1 }}. {{ todo }} <!-- 显示序号和待办文本;index 从 0 开始,所以这里加 1 -->
</li> <!-- 单条待办结束 -->
</ul> <!-- 待办列表结束 -->
<div class="empty" v-if="todos.length === 0"> <!-- v-if 条件渲染;只有 todos 为空数组时才显示 -->
暂无学习任务,先添加一个吧。 <!-- 空状态提示文案 -->
</div> <!-- 空状态结束 -->
</div> <!-- Vue 管理区域结束 -->
<script> <!-- JavaScript 逻辑开始 -->
const { createApp, ref } = Vue; // 从 Vue 全局对象中取出 createApp 和 ref
createApp({ // 创建 Vue 应用
setup() { // 组合式 API 入口
const inputVal = ref(""); // 输入框内容,初始为空字符串
const todos = ref(["背 20 个单词", "完成 5 分钟口语跟读"]); // 待办数组,使用 ref 后页面会响应变化
const changeVal = () => { // 输入框 change 事件触发时执行
console.log("当前输入内容:", inputVal.value); // 在控制台打印输入框当前值,JS 中访问 ref 要写 .value
}; // changeVal 结束
const addTodo = () => { // 添加任务函数
const text = inputVal.value.trim(); // 取出输入内容并去掉首尾空格
if (text) { // 如果 text 不是空字符串,才允许添加
todos.value.push(text); // 往响应式数组里追加一条数据
inputVal.value = ""; // 清空输入框;因为 v-model 绑定了 inputVal,页面输入框也会自动清空
} // if 结束
}; // addTodo 结束
return { inputVal, todos, changeVal, addTodo }; // 暴露给模板使用;模板里的 inputVal、todos、changeVal、addTodo 都来自这里
} // setup 结束
}).mount("#app"); // 挂载到 id="app" 的 DOM 节点
</script> <!-- JavaScript 逻辑结束 -->
</body> <!-- 页面主体结束 -->
</html> <!-- HTML 文档结束 -->
七、v-model="inputVal" 是什么?
示例里输入框大概是这样:
html
<input
v-model="inputVal"
@change="changeVal"
placeholder="输入内容"
@keyup.enter="addTodo"
>
先看:
html
v-model="inputVal"
v-model 是 Vue 的双向绑定。
它做了两件事:
- 把
inputVal的值显示到输入框里; - 用户在输入框里输入内容时,自动更新
inputVal。
也就是说:
text
inputVal -> 输入框
输入框 -> inputVal
两边会同步。
对应的 JavaScript 数据是:
javascript
const inputVal = ref("");
输入框一开始是空的,因为 inputVal.value 是空字符串。
当你输入:
text
hello
Vue 会自动把:
javascript
inputVal.value
更新成:
javascript
"hello"
你不需要手动写:
javascript
document.querySelector("input").value
这就是 Vue 比原生 DOM 操作省事的地方。
八、@change="changeVal" 是什么意思?
html
@change="changeVal"
意思是:
text
当输入框触发 change 事件时,执行 changeVal 函数。
change 事件不是每输入一个字就触发。通常是:
- 输入框内容发生变化;
- 输入框失去焦点;
- 或者用户确认输入后;
才触发。
示例里的函数类似:
javascript
const changeVal = () => {
console.log(inputVal.value);
};
作用是把当前输入框内容打印出来。
你可以这样理解:
text
v-model 负责同步输入框内容
@change 负责在内容变更后执行某个动作
如果希望每输入一个字符都触发,可以用:
html
@input="changeVal"
区别是:
| 写法 | 触发时机 |
|---|---|
@input |
每次输入时触发 |
@change |
输入完成并确认变化后触发 |
@keyup.enter |
按下回车键时触发 |
九、@keyup.enter="addTodo" 是什么?
html
@keyup.enter="addTodo"
拆开看:
text
keyup:键盘按键松开事件
enter:只监听回车键
addTodo:要执行的函数
所以它的意思是:
text
用户在输入框中按下并松开回车键时,执行 addTodo。
这就是为什么输入待办后按回车也能添加。
这在后续英语学习网站里很常用:
- 输入一句英文,按回车发送给 AI;
- 输入单词,按回车加入生词本;
- 输入学习任务,按回车添加到今日计划。
十、@click="addTodo" 是什么?
按钮里有:
html
<button @click="addTodo">添加</button>
意思是:
text
用户点击按钮时,执行 addTodo 函数。
所以基础版 TodoList 有两种添加方式:
text
按回车 -> addTodo
点按钮 -> addTodo
它们执行的是同一个函数。
这就是前端开发里常见的复用:
text
不同交互入口,复用同一段业务逻辑。
十一、addTodo 函数到底做了什么?
示例里的添加逻辑可以理解成:
javascript
const addTodo = () => {
const text = inputVal.value.trim();
if (text) {
todos.value.push(text);
inputVal.value = "";
}
};
逐行解释。
javascript
const text = inputVal.value.trim();
取出输入框内容,并去掉首尾空格。
比如用户输入:
text
learn vue
trim() 后变成:
text
learn vue
javascript
if (text) {
判断内容是否为空。
如果用户什么都没输入,就不添加。
javascript
todos.value.push(text);
把新内容追加到 todos 数组里。
注意:todos 是通过 ref 创建的:
javascript
const todos = ref(["学习 v-for 循环", "掌握 v-model 双向绑定"]);
所以在 JavaScript 里访问数组时要写:
javascript
todos.value
添加元素就是:
javascript
todos.value.push(text)
javascript
inputVal.value = "";
添加完成后,把输入框清空。
因为输入框用了:
html
v-model="inputVal"
所以 inputVal.value 清空后,页面里的输入框也会自动清空。
十二、重点解释:v-for="(todo, index) in todos" 怎么找到 todos?
你问的这句:
html
<li class="todo-item" v-for="(todo, index) in todos" :key="index">
{{ index + 1 }}. {{ todo }}
</li>
这是 Vue 的列表渲染。
它的意思是:
text
遍历 todos 数组。
每次循环取出一个元素,叫 todo。
同时拿到当前下标,叫 index。
然后为每个元素生成一个 li。
1. todos 从哪里来?
在 JavaScript 里定义了:
javascript
const todos = ref(["学习 v-for 循环", "掌握 v-model 双向绑定"]);
然后在 setup() 最后返回了:
javascript
return {
inputVal,
todos,
addTodo
};
只要 return 出去了,模板就能使用:
html
todos
所以完整关系是:
text
setup 里定义 todos
-> return { todos }
-> 模板可以访问 todos
-> v-for 遍历 todos
-> 页面生成 li 列表
这就是 Vue 的特性。
Vue 的模板不是普通 HTML,它能访问组件暴露出来的数据和方法。
2. 为什么模板里写 todos,不是 todos.value?
因为在模板中,Vue 会自动解包 ref。
JavaScript 里:
javascript
todos.value
模板里:
html
todos
这和前面 msg 的规则一样。
简单记:
text
JS 里访问 ref:xxx.value
模板里访问 ref:xxx
3. (todo, index) 是什么意思?
html
v-for="(todo, index) in todos"
这里的 todo 和 index 是临时变量。
假设:
javascript
todos = ["A", "B", "C"]
循环过程是:
| 第几次 | todo | index |
|---|---|---|
| 第 1 次 | "A" |
0 |
| 第 2 次 | "B" |
1 |
| 第 3 次 | "C" |
2 |
所以:
html
{{ index + 1 }}. {{ todo }}
会显示:
text
1. A
2. B
3. C
4. :key="index" 是什么?
html
:key="index"
完整写法是:
html
v-bind:key="index"
冒号 : 是 v-bind 的简写。
它的作用是给每一项一个唯一标识,帮助 Vue 判断列表中每一行是谁。
为什么需要它?
因为列表会变化:
- 新增一项;
- 删除一项;
- 排序;
- 筛选;
- 编辑;
Vue 需要知道哪些 DOM 节点可以复用,哪些需要重新创建。
基础示例用 index 可以跑通,但真实项目更推荐用稳定的 id:
html
<li v-for="item in tasks" :key="item.id">
{{ item.title }}
</li>
因为 index 会随着删除、排序而变化。
后续 Echo English AI 中,单词、学习任务、聊天消息都应该有自己的 id:
javascript
{ id: "word-001", text: "confidence" }
然后写:
html
<li v-for="word in words" :key="word.id">
{{ word.text }}
</li>
十三、v-if="todos.length === 0" 是什么?
基础版里有空状态提示:
html
<div v-if="todos.length === 0">
暂无待办事项
</div>
v-if 是条件渲染。
意思是:
text
只有当 todos.length === 0 为 true 时,才显示这个 div。
todos.length 表示数组长度。
如果:
javascript
todos = []
那么:
javascript
todos.length === 0
为 true,页面显示空状态。
如果数组里有内容,空状态就不显示。
后续英语学习网站里也会大量用到:
html
<div v-if="words.length === 0">暂无生词</div>
<div v-if="messages.length === 0">还没有对话记录</div>
<div v-if="tasks.length === 0">今日任务已清空</div>
十四、进阶版:为什么要拆组件?
todolist_advanced.html 里开始出现子组件。
它把每一条待办项抽成了:
javascript
const TodoItem = {
template: `...`,
props: { ... },
emits: [...]
};
为什么要拆?
因为列表项开始变复杂了:
- 要显示文本;
- 要显示完成状态;
- 点击文本可以切换状态;
- 点击按钮可以删除;
- 不同状态有不同样式;
如果全部写在父组件里,代码会越来越乱。
拆成子组件以后,父组件负责数据,子组件负责单条 UI。
可以理解成:
text
父组件:管理整个 todos 数组
子组件:负责展示其中一条 todo
你可以新建文件:
text
todolist_advanced.html
复制下面完整代码运行。
html
<!DOCTYPE html> <!-- 声明 HTML5 文档 -->
<html lang="zh-CN"> <!-- 设置页面语言为中文 -->
<head> <!-- 页面配置开始 -->
<meta charset="UTF-8"> <!-- 设置字符编码 -->
<title>Vue 3.5 TodoList 进阶版</title> <!-- 浏览器标签标题 -->
<script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js"></script> <!-- 引入 Vue3.5 -->
<style> /* 样式开始 */
.box { max-width: 640px; margin: 2rem auto; padding: 0 1rem; font-family: sans-serif; } /* 页面容器样式 */
.add-todo { display: flex; gap: 0.5rem; margin-bottom: 1rem; } /* 输入区横向布局 */
input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; } /* 输入框样式 */
button { padding: 0.45rem 0.8rem; border: none; cursor: pointer; border-radius: 4px; } /* 按钮通用样式 */
.add-btn { background: #42b983; color: #fff; } /* 添加按钮样式 */
.todo-list { display: flex; flex-direction: column; gap: 0.5rem; } /* 列表纵向排列 */
.todo-item { display: flex; justify-content: space-between; align-items: center; padding: 0.8rem; border: 1px solid #eee; border-radius: 4px; } /* 单条任务布局 */
.todo-item.done { background: #fafafa; } /* 完成状态背景 */
.todo-item.done .todo-text { color: #999; text-decoration: line-through; } /* 完成状态文字加删除线 */
.todo-text { cursor: pointer; flex: 1; } /* 任务文字可点击并占满剩余空间 */
.delete-btn { background: #ef4444; color: #fff; } /* 删除按钮样式 */
.empty { color: #999; text-align: center; margin-top: 1rem; } /* 空状态样式 */
</style> <!-- 样式结束 -->
</head> <!-- 页面配置结束 -->
<body> <!-- 页面主体开始 -->
<div id="app" class="box"> <!-- Vue 挂载点 -->
<h2>今日学习任务(组件通信版)</h2> <!-- 页面标题 -->
<div class="add-todo"> <!-- 添加区域 -->
<input v-model="inputVal" @keyup.enter="addTodo" placeholder="输入学习任务,回车添加"> <!-- v-model 绑定输入内容,回车触发 addTodo -->
<button class="add-btn" @click="addTodo">添加</button> <!-- 点击按钮触发 addTodo -->
</div> <!-- 添加区域结束 -->
<div class="todo-list"> <!-- 列表区域 -->
<todo-item
v-for="(item, index) in todos"
:key="item.id"
:todo="item"
:is-done="item.isDone"
@delete="deleteTodo(index)"
@toggle="toggleDone(index)"
></todo-item> <!-- 使用子组件;父组件通过 props 传数据;子组件通过 emit 通知父组件 -->
</div> <!-- 列表区域结束 -->
<div class="empty" v-if="todos.length === 0"> <!-- 没有任务时显示 -->
暂无任务。 <!-- 空状态文案 -->
</div> <!-- 空状态结束 -->
</div> <!-- Vue 管理区域结束 -->
<script> <!-- JavaScript 开始 -->
const { createApp, ref } = Vue; // 解构 Vue API
const TodoItem = { // 定义子组件 TodoItem
template: `
<div class="todo-item" :class="{ done: isDone }">
<span class="todo-text" @click="$emit('toggle')" title="点击切换完成状态">
{{ todo.text }}
</span>
<button class="delete-btn" @click="$emit('delete')">删除</button>
</div>
`, // 子组件模板;:class 根据 isDone 动态添加 done;点击文字 emit toggle;点击按钮 emit delete
props: { // props 用来接收父组件传过来的数据
todo: { type: Object, required: true }, // todo 必须是对象,里面包含任务文本等信息
isDone: { type: Boolean, default: false } // isDone 是布尔值,默认 false
}, // props 结束
emits: ["delete", "toggle"] // 声明这个子组件会向外触发 delete 和 toggle 两个事件
}; // 子组件定义结束
createApp({ // 创建父组件应用
components: { TodoItem }, // 注册子组件,注册后模板里才能使用 <todo-item>
setup() { // 父组件逻辑入口
const inputVal = ref(""); // 输入框内容
const todos = ref([ // 父组件维护完整任务数组
{ id: 1, text: "背 20 个单词", isDone: true }, // 第 1 条任务,已完成
{ id: 2, text: "完成 5 分钟口语跟读", isDone: false } // 第 2 条任务,未完成
]); // todos 初始化结束
const addTodo = () => { // 添加任务
const text = inputVal.value.trim(); // 取出输入框内容并去掉空格
if (text) { // 内容不为空才添加
todos.value.push({ id: Date.now(), text, isDone: false }); // 新任务追加到数组,id 用时间戳临时生成
inputVal.value = ""; // 清空输入框
} // if 结束
}; // addTodo 结束
const deleteTodo = (index) => { // 删除任务,index 来自 v-for 当前下标
todos.value.splice(index, 1); // 从数组中删除指定下标的 1 个元素
}; // deleteTodo 结束
const toggleDone = (index) => { // 切换完成状态
todos.value[index].isDone = !todos.value[index].isDone; // true 变 false,false 变 true
}; // toggleDone 结束
return { inputVal, todos, addTodo, deleteTodo, toggleDone }; // 暴露父组件数据和方法给模板
} // setup 结束
}).mount("#app"); // 挂载应用
</script> <!-- JavaScript 结束 -->
</body> <!-- body 结束 -->
</html> <!-- HTML 文档结束 -->
十五、props:父组件怎么把数据传给子组件?
注意 :这是父组件在"调用/使用"子组件TodoItem,并监听子组件发出来的事件。
即 定义 TodoItem 的地方是子组件;使用 <todo-item> 的地方是父组件。
进阶版里父组件这样使用子组件:
html
<todo-item
v-for="(item, index) in todos"
:key="index"
:todo="item"
:is-done="item.isDone"
@delete="deleteTodo(index)"
@toggle="toggleDone(index)"
></todo-item>
先看:
html
:todo="item"
意思是:
text
把当前循环到的 item 传给子组件,子组件里名字叫 todo。
再看:
html
:is-done="item.isDone"
意思是:
text
把 item.isDone 传给子组件,子组件里名字叫 isDone。
子组件里要声明自己接收哪些数据:
javascript
props: {
todo: { type: Object, required: true },
isDone: { type: Boolean, default: false }
}
这就是 Vue 的父传子:
text
父组件通过属性传
子组件通过 props 接
后续 Echo English AI 中也一样:
html
<word-card :word="word"></word-card>
<message-bubble :message="message"></message-bubble>
<lesson-card :lesson="lesson"></lesson-card>
十六、emit:子组件怎么通知父组件?
子组件里有:
html
<span @click="$emit('toggle')">
{{ todo.text }}
</span>
<button @click="$emit('delete')">删除</button>
$emit('toggle') 的意思是:
text
子组件向父组件发出一个 toggle 事件。
$emit('delete') 的意思是:
text
子组件向父组件发出一个 delete 事件。
父组件监听:
html
@delete="deleteTodo(index)"
@toggle="toggleDone(index)"
完整流程:
text
用户点击子组件里的删除按钮
-> 子组件 emit delete
-> 父组件监听到 delete
-> 父组件执行 deleteTodo(index)
-> 父组件修改 todos 数组
-> 页面自动更新
为什么子组件不自己删除?
因为真正的数据在父组件:
javascript
const todos = ref([...])
子组件只是拿到其中一条数据用于展示。
谁拥有数据,谁负责修改数据。这是很重要的组件设计原则。
十七、:class="{ done: isDone }" 是什么?
进阶版里有动态 class:
html
<div class="todo-item" :class="{ done: isDone }">
这句意思是:
text
这个 div 永远有 todo-item 类。
如果 isDone 为 true,再额外加上 done 类。
如果 isDone 是 false:
html
<div class="todo-item">
如果 isDone 是 true:
html
<div class="todo-item done">
这样 CSS 就能根据状态改变样式:
css
.todo-item.done span {
color: #999;
text-decoration: line-through;
}
后续英语学习网站里可以这样用:
html
<div class="word-card" :class="{ mastered: word.isMastered }">
意思是:
text
如果单词已掌握,就显示掌握状态样式。
十八、完整版:computed 是什么?
todolist_complete.html 里出现了:
javascript
const filteredTodos = computed(() => {
switch (filter.value) {
case "done":
return todos.value.filter(t => t.isDone);
case "undone":
return todos.value.filter(t => !t.isDone);
default:
return todos.value;
}
});
computed 叫计算属性。
它适合处理:
text
由已有数据推导出来的新数据。
你可以新建文件:
text
todolist_complete.html
复制下面完整代码运行。
html
<!DOCTYPE html> <!-- 声明 HTML5 文档 -->
<html lang="zh-CN"> <!-- 设置中文页面 -->
<head> <!-- 页面配置开始 -->
<meta charset="UTF-8"> <!-- 设置编码,避免中文乱码 -->
<title>Vue 3.5 TodoList 完整版</title> <!-- 浏览器标签标题 -->
<script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js"></script> <!-- 引入 Vue3.5 CDN -->
<style> /* 样式开始 */
.box { max-width: 700px; margin: 2rem auto; padding: 0 1rem; font-family: sans-serif; } /* 页面容器居中 */
.add-todo { display: flex; gap: 0.5rem; margin-bottom: 1rem; } /* 添加区域横向布局 */
input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; } /* 输入框样式 */
button { padding: 0.45rem 0.8rem; border: none; cursor: pointer; border-radius: 4px; } /* 通用按钮样式 */
.add-btn { background: #42b983; color: #fff; } /* 添加按钮 */
.filter-btns { display: flex; gap: 0.5rem; margin: 1rem 0; } /* 筛选按钮容器 */
.filter-btn { background: #fff; color: #333; border: 1px solid #ddd; } /* 筛选按钮默认样式 */
.filter-btn.active { background: #42b983; color: #fff; border-color: #42b983; } /* 当前筛选按钮高亮 */
.stats { color: #666; margin-bottom: 1rem; font-size: 0.9rem; } /* 统计信息样式 */
.todo-list { display: flex; flex-direction: column; gap: 0.5rem; } /* 列表纵向排列 */
.todo-item { display: flex; justify-content: space-between; align-items: center; padding: 0.8rem; border: 1px solid #eee; border-radius: 4px; } /* 单条任务样式 */
.todo-item.done .todo-text { color: #999; text-decoration: line-through; } /* 完成任务显示删除线 */
.todo-text { cursor: pointer; flex: 1; } /* 文本可点击,并占满剩余宽度 */
.edit-input { margin-right: 0.5rem; } /* 编辑输入框右侧留空 */
.edit-btn { background: #3498db; color: #fff; margin-right: 0.3rem; } /* 编辑按钮样式 */
.delete-btn { background: #ef4444; color: #fff; } /* 删除按钮样式 */
.empty-tip { color: #999; text-align: center; padding: 1.5rem; border: 1px dashed #ddd; border-radius: 4px; } /* 空状态样式 */
</style> <!-- 样式结束 -->
</head> <!-- 页面配置结束 -->
<body> <!-- 页面主体开始 -->
<div id="app" class="box"> <!-- Vue 挂载点 -->
<h2>今日英语学习任务(完整版)</h2> <!-- 页面标题 -->
<div class="add-todo"> <!-- 添加任务区域 -->
<input v-model="inputVal" @keyup.enter="addTodo" placeholder="输入任务,例如:朗读 3 个英文句子"> <!-- 输入框双向绑定 inputVal,回车添加 -->
<button class="add-btn" @click="addTodo">添加</button> <!-- 点击添加任务 -->
</div> <!-- 添加区域结束 -->
<div class="filter-btns"> <!-- 筛选按钮区域 -->
<button class="filter-btn" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button> <!-- 点击后显示全部任务 -->
<button class="filter-btn" :class="{ active: filter === 'done' }" @click="filter = 'done'">已完成</button> <!-- 点击后只显示已完成 -->
<button class="filter-btn" :class="{ active: filter === 'undone' }" @click="filter = 'undone'">未完成</button> <!-- 点击后只显示未完成 -->
</div> <!-- 筛选按钮区域结束 -->
<div class="stats"> <!-- 统计信息区域 -->
总数:{{ totalCount }} | 已完成:{{ doneCount }} | 未完成:{{ undoneCount }} <!-- 这些值都来自 computed 计算属性 -->
</div> <!-- 统计信息结束 -->
<div class="todo-list" v-if="filteredTodos.length > 0"> <!-- 如果筛选后列表有内容,就显示列表 -->
<todo-item
v-for="todo in filteredTodos"
:key="todo.id"
:todo="todo"
@delete="deleteTodo(todo)"
@toggle="toggleDone(todo)"
@save-edit="saveEdit(todo)"
></todo-item> <!-- 遍历 computed 结果 filteredTodos,并把每条任务交给子组件 -->
</div> <!-- 任务列表结束 -->
<div class="empty-tip" v-else> <!-- 如果 filteredTodos 为空,就显示空状态 -->
当前筛选条件下没有任务。 <!-- 空状态文案 -->
</div> <!-- 空状态结束 -->
</div> <!-- Vue 管理区域结束 -->
<script> <!-- JavaScript 开始 -->
const { createApp, ref, computed, onMounted, onUnmounted } = Vue; // 引入 Vue API:响应式、计算属性、生命周期
const TodoItem = { // 定义子组件 TodoItem
template: `
<div class="todo-item" :class="{ done: todo.isDone }">
<input
v-if="todo.isEditing"
class="edit-input"
v-model="todo.text"
@blur="$emit('save-edit')"
@keyup.enter="$emit('save-edit')"
>
<span v-else class="todo-text" @click="$emit('toggle')">
{{ todo.text }}
</span>
<div>
<button class="edit-btn" @click="todo.isEditing = true">编辑</button>
<button class="delete-btn" @click="$emit('delete')">删除</button>
</div>
</div>
`, // 子组件模板:编辑状态显示 input;非编辑状态显示 span;按钮触发编辑和删除
props: { // 接收父组件传入的数据
todo: { type: Object, required: true } // todo 是当前任务对象,必须传
}, // props 结束
emits: ["delete", "toggle", "save-edit"] // 声明子组件会触发的事件
}; // 子组件结束
createApp({ // 创建 Vue 应用
components: { TodoItem }, // 注册 TodoItem 子组件
setup() { // 组合式 API 入口
const inputVal = ref(""); // 输入框内容
const filter = ref("all"); // 当前筛选条件:all、done、undone
const todos = ref([ // 原始任务数组
{ id: 1, text: "背 20 个英语单词", isDone: false, isEditing: false }, // 任务 1
{ id: 2, text: "完成 5 分钟口语跟读", isDone: true, isEditing: false } // 任务 2
]); // todos 初始化结束
onMounted(() => { // 组件挂载完成后执行
console.log("页面已加载,可以在这里请求后端接口"); // 实际项目中这里常用于请求初始化数据
setTimeout(() => { // 模拟异步加载一条任务
todos.value.push({ id: 3, text: "模拟加载:复习昨天的生词", isDone: false, isEditing: false }); // 1 秒后添加模拟任务
}, 1000); // 延迟 1000 毫秒
}); // onMounted 结束
onUnmounted(() => { // 组件销毁时执行
console.log("页面销毁,可以在这里清理定时器或监听事件"); // 实际项目中用于清理资源
}); // onUnmounted 结束
const filteredTodos = computed(() => { // 根据 todos 和 filter 计算筛选后的列表
switch (filter.value) { // 判断当前筛选条件
case "done": // 如果筛选已完成
return todos.value.filter(todo => todo.isDone); // 返回 isDone 为 true 的任务
case "undone": // 如果筛选未完成
return todos.value.filter(todo => !todo.isDone); // 返回 isDone 为 false 的任务
default: // 默认情况,也就是 all
return todos.value; // 返回全部任务
} // switch 结束
}); // filteredTodos 结束
const totalCount = computed(() => todos.value.length); // 总任务数
const doneCount = computed(() => todos.value.filter(todo => todo.isDone).length); // 已完成任务数
const undoneCount = computed(() => totalCount.value - doneCount.value); // 未完成任务数
const addTodo = () => { // 添加任务
const text = inputVal.value.trim(); // 读取输入框并去掉空格
if (text) { // 内容不为空才添加
todos.value.push({ id: Date.now(), text, isDone: false, isEditing: false }); // 追加新任务
inputVal.value = ""; // 清空输入框
} // if 结束
}; // addTodo 结束
const deleteTodo = (todoItem) => { // 删除任务,参数是当前任务对象
const index = todos.value.indexOf(todoItem); // 在原始数组中找到任务位置
if (index > -1) { // 如果找到了
todos.value.splice(index, 1); // 删除这一条任务
} // if 结束
}; // deleteTodo 结束
const toggleDone = (todoItem) => { // 切换完成状态
todoItem.isDone = !todoItem.isDone; // true 变 false,false 变 true
}; // toggleDone 结束
const saveEdit = (todoItem) => { // 保存编辑
if (!todoItem.text.trim()) { // 如果编辑后内容为空
alert("内容不能为空!"); // 提示用户
return; // 直接结束函数
} // if 结束
todoItem.isEditing = false; // 退出编辑模式
}; // saveEdit 结束
return { // 暴露数据和方法给模板
inputVal, // 输入框内容
filter, // 当前筛选条件
todos, // 原始任务数组
filteredTodos, // 筛选后的任务数组
totalCount, // 总数
doneCount, // 已完成数量
undoneCount, // 未完成数量
addTodo, // 添加任务方法
deleteTodo, // 删除任务方法
toggleDone, // 切换完成状态方法
saveEdit // 保存编辑方法
}; // return 结束
} // setup 结束
}).mount("#app"); // 挂载应用
</script> <!-- JavaScript 结束 -->
</body> <!-- 页面主体结束 -->
</html> <!-- HTML 文档结束 -->
这里已有数据是:
javascript
todos
filter
推导数据是:
javascript
filteredTodos
如果 filter 是 "all",显示全部。
如果 filter 是 "done",只显示已完成。
如果 filter 是 "undone",只显示未完成。
computed 和普通函数有什么区别?
普通函数每次调用都会执行。
computed 会根据依赖缓存结果。
只要依赖不变,Vue 可以直接复用上一次的计算结果。
简单记:
text
需要在模板中反复使用的派生数据,优先考虑 computed。
后续英语学习网站里会有很多 computed:
javascript
const masteredWords = computed(() => words.value.filter(w => w.status === "mastered"));
const todayTasks = computed(() => tasks.value.filter(t => t.date === today));
const unreadMessages = computed(() => messages.value.filter(m => !m.read));
十九、v-for 遍历 computed 结果
完整版里不是直接遍历 todos,而是遍历:
html
v-for="(todo, index) in filteredTodos"
这表示:
text
页面展示的是筛选后的列表,而不是原始列表。
数据流程是:
text
todos 原始数据
-> filter 当前筛选条件
-> computed 计算 filteredTodos
-> v-for 渲染 filteredTodos
如果你点"已完成",filter.value 变成 "done"。
filteredTodos 自动重新计算。
页面列表自动更新。
你不需要手动操作 DOM。
二十、生命周期:onMounted 是什么?
完整版里有:
javascript
onMounted(() => {
console.log("组件已挂载!");
});
onMounted 是生命周期钩子。
意思是:
text
当组件已经显示到页面上以后,执行这里的代码。
常见用途:
- 页面打开后请求后端接口;
- 初始化图表;
- 加载本地缓存;
- 开启定时器;
例如后续 Echo English AI 首页打开后,可以在 onMounted 里请求今日学习任务:
javascript
onMounted(async () => {
const result = await fetchTodayPlan();
todayTasks.value = result;
});
还有:
javascript
onUnmounted(() => {
console.log("组件即将销毁!");
});
onUnmounted 表示组件销毁时执行,常用于清理定时器、取消监听事件。
二十一、编辑功能怎么理解?
完整版里有编辑模式:
html
<input
v-if="todo.isEditing"
v-model="todo.text"
@blur="$emit('save-edit')"
@keyup.enter="$emit('save-edit')"
>
<span v-else @click="$emit('toggle')">
{{ todo.text }}
</span>
这里用了:
html
v-if="todo.isEditing"
如果当前任务处于编辑状态,就显示输入框。
否则:
html
v-else
显示普通文本。
v-model="todo.text" 表示编辑输入框和 todo.text 双向绑定。
当输入框失去焦点:
html
@blur="$emit('save-edit')"
或者按下回车:
html
@keyup.enter="$emit('save-edit')"
就通知父组件保存编辑。
后续英语写作批改功能里也会用到类似逻辑:
text
展示作文内容
-> 点击编辑
-> 切换成输入框
-> 修改内容
-> 失焦或回车保存
二十二、这些语法和 Echo English AI 怎么连接?
这一篇暂时不写 Vite 项目,但这些基础会直接进入后续项目。
| Vue 语法 | 示例中用途 | Echo English AI 中用途 |
|---|---|---|
ref |
保存输入框、列表、筛选状态 | 保存当前 Tab、聊天输入、任务列表 |
v-model |
输入待办 | 输入英文句子、作文、单词 |
@click |
点击按钮添加/删除 | 发送消息、开始练习、收藏单词 |
@change |
输入变化后打印 | 切换学习模式、选择难度 |
@keyup.enter |
回车添加 | 回车发送英文句子 |
v-for |
渲染待办列表 | 渲染单词、消息、练习、学习任务 |
:key |
标识列表项 | 用 id 标识单词和消息 |
v-if |
空状态、编辑模式 | 无数据提示、加载中、登录状态 |
props |
父传子 | 把单词、消息、任务传给卡片组件 |
emit |
子通知父 | 子组件通知父组件收藏、删除、提交 |
computed |
筛选列表、统计数量 | 已掌握单词数、今日完成率 |
onMounted |
初始化数据 | 页面加载后请求学习计划 |
所以这一篇是项目铺垫,不是孤立教程。
二十三、小白常见问题
1. 为什么模板里能直接用 todos?
因为 todos 在 setup() 里被 return 出去了。
javascript
return { todos };
模板只能访问被暴露出来的数据和方法。
2. 为什么 JS 里要写 todos.value?
因为 todos 是 ref 创建的响应式对象。
javascript
const todos = ref([]);
在 JavaScript 里要通过:
javascript
todos.value
访问真实数组。
模板里 Vue 会自动解包,所以写:
html
todos
即可。
3. @click、@change 前面的 @ 是 Vue 特有的吗?
是的。
@click 是:
html
v-on:click
的简写。
这是 Vue 模板语法,不是普通 HTML 原生写法。
4. :key 前面的冒号是什么意思?
冒号 : 是:
html
v-bind:
的简写。
html
:key="index"
等价于:
html
v-bind:key="index"
意思是把 JavaScript 表达式 index 的值绑定给 key。
5. 为什么真实项目不推荐用 index 当 key?
因为删除或排序后,index 会变。
真实项目应该用稳定的唯一 id:
html
<li v-for="item in list" :key="item.id">
6. 为什么子组件不能直接改父组件数组?
不是绝对不能,但不推荐。
更清晰的方式是:
text
父组件管理数据
子组件负责展示
子组件通过 emit 通知父组件修改
这样数据流更清楚,项目大了也更容易维护。
二十四、练习任务
练习 1:把 TodoList 改成"今日英语学习任务"
把初始数据改成:
javascript
const todos = ref(["背 20 个单词", "完成 5 分钟口语跟读"]);
页面标题改成:
text
今日英语学习任务
练习 2:给任务加分类
把字符串数组改成对象数组:
javascript
const tasks = ref([
{ id: 1, title: "背 20 个单词", type: "vocabulary", isDone: false },
{ id: 2, title: "完成口语跟读", type: "speaking", isDone: false },
]);
然后用:
html
v-for="task in tasks"
渲染任务。
练习 3:实现"只看未完成"
新增:
javascript
const filter = ref("all");
再用 computed 计算筛选后的任务。
练习 4:思考后续组件拆分
如果做 Echo English AI,下面这些可以拆成组件:
TaskItemWordCardChatMessagePracticePanelBottomTabBar
先不用写代码,只要能说出每个组件负责什么。
二十五、总结
这一篇我们没有进入 Vite 工程搭建,而是先把 Vue3.5 的基础语法讲透。
你需要重点掌握:
createApp().mount("#app")负责创建并挂载 Vue 应用。ref创建响应式数据。- JavaScript 里访问
ref要写.value。 - 模板里访问
ref不用写.value。 return { ... }决定模板能访问哪些数据和方法。v-model负责表单双向绑定。@click、@change、@keyup.enter是事件绑定。v-for用来循环渲染列表。:key帮助 Vue 稳定识别列表项。v-if用来做条件渲染。props负责父传子。emit负责子通知父。computed适合处理筛选、统计这类派生数据。onMounted适合做页面初始化。