【从零构建多智能体 02】Vue3.5 基础入门:从 HTML 示例看懂响应式、列表渲染与组件通信

【从零构建多智能体 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 是什么意思?
  • propsemit 是怎么让组件通信的?
  • computed 和普通函数有什么区别?

所以第二篇先做基础教学。我们先通过独立 HTML 文件看懂 Vue3.5 的核心写法,再进入真正的项目工程。

二、这几个 HTML 示例分别讲什么?

vue3.5 案例 文件夹里有 4 个 HTML 示例。它们是递进关系:

文件 主要内容 你应该重点看什么
index.html Vue HelloWorld createAppref、插值语法、点击事件
todolist.html 待办基础版 v-model@change@keyup.enterv-forv-if
todolist_advanced.html 组件通信版 子组件、propsemit、父子通信
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
  • @click
  • v-for
  • :key
  • v-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 的双向绑定。

它做了两件事:

  1. inputVal 的值显示到输入框里;
  2. 用户在输入框里输入内容时,自动更新 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"

这里的 todoindex 是临时变量。

假设:

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 类。

如果 isDonefalse

html 复制代码
<div class="todo-item">

如果 isDonetrue

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

因为 todossetup() 里被 return 出去了。

javascript 复制代码
return { todos };

模板只能访问被暴露出来的数据和方法。

2. 为什么 JS 里要写 todos.value

因为 todosref 创建的响应式对象。

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,下面这些可以拆成组件:

  • TaskItem
  • WordCard
  • ChatMessage
  • PracticePanel
  • BottomTabBar

先不用写代码,只要能说出每个组件负责什么。

二十五、总结

这一篇我们没有进入 Vite 工程搭建,而是先把 Vue3.5 的基础语法讲透。

你需要重点掌握:

  1. createApp().mount("#app") 负责创建并挂载 Vue 应用。
  2. ref 创建响应式数据。
  3. JavaScript 里访问 ref 要写 .value
  4. 模板里访问 ref 不用写 .value
  5. return { ... } 决定模板能访问哪些数据和方法。
  6. v-model 负责表单双向绑定。
  7. @click@change@keyup.enter 是事件绑定。
  8. v-for 用来循环渲染列表。
  9. :key 帮助 Vue 稳定识别列表项。
  10. v-if 用来做条件渲染。
  11. props 负责父传子。
  12. emit 负责子通知父。
  13. computed 适合处理筛选、统计这类派生数据。
  14. onMounted 适合做页面初始化。