Vue3组件样式隔离与组件通信实战:从 scoped、props 校验到记事本应用
- 前言
- [1. scoped:让组件样式拥有边界](#1. scoped:让组件样式拥有边界)
-
- [1.1 为什么需要样式隔离](#1.1 为什么需要样式隔离)
- [1.2 scoped 的使用方式](#1.2 scoped 的使用方式)
- [1.3 scoped 的编译原理](#1.3 scoped 的编译原理)
- [1.4 scoped 的边界与常见误区](#1.4 scoped 的边界与常见误区)
- [2. 组件通信:先确定关系,再选择方案](#2. 组件通信:先确定关系,再选择方案)
-
- [2.1 组件通信解决什么问题](#2.1 组件通信解决什么问题)
- [3. 父传子:使用 props 传递数据](#3. 父传子:使用 props 传递数据)
-
- [3.1 整体流程](#3.1 整体流程)
- [3.2 defineProps 的作用与基本写法](#3.2 defineProps 的作用与基本写法)
- [3.3 商品卡片案例:从静态页面到可复用组件](#3.3 商品卡片案例:从静态页面到可复用组件)
- [3.4 配合 v-for 给每个组件独立传值](#3.4 配合 v-for 给每个组件独立传值)
- [4. 子传父:使用自定义事件通知父组件](#4. 子传父:使用自定义事件通知父组件)
-
- [4.1 为什么子组件不能直接修改 props](#4.1 为什么子组件不能直接修改 props)
- [4.2 defineEmits 的作用与参数](#4.2 defineEmits 的作用与参数)
- [4.3 砍价案例:完整的事件链路](#4.3 砍价案例:完整的事件链路)
- [5. props 校验:给组件输入建立契约](#5. props 校验:给组件输入建立契约)
-
- [5.1 为什么需要 props 校验](#5.1 为什么需要 props 校验)
- [5.2 完整声明方式](#5.2 完整声明方式)
- [5.3 default 与 required 的注意事项](#5.3 default 与 required 的注意事项)
- [5.4 props 与 ref、reactive 的区别](#5.4 props 与 ref、reactive 的区别)
- [6. 记事本组件:把通信知识串成完整应用](#6. 记事本组件:把通信知识串成完整应用)
-
- [6.1 项目要完成什么:先认识三个页面板块](#6.1 项目要完成什么:先认识三个页面板块)
- [6.2 第一步:创建项目并搭好静态组件骨架](#6.2 第一步:创建项目并搭好静态组件骨架)
- [6.3 第二步:在 App.vue 准备数据并完成列表渲染](#6.3 第二步:在 App.vue 准备数据并完成列表渲染)
- [6.4 第三步:TodoHeader 收集输入并添加任务](#6.4 第三步:TodoHeader 收集输入并添加任务)
- [6.5 第四步:完成状态与删除功能](#6.5 第四步:完成状态与删除功能)
- [6.6 第五步:底部统计、批量清理与本地持久化](#6.6 第五步:底部统计、批量清理与本地持久化)
- [6.7 第六步:整合 App.vue 并复盘完整执行流程](#6.7 第六步:整合 App.vue 并复盘完整执行流程)
- [7. 非父子组件通信](#7. 非父子组件通信)
-
- [7.1 祖先传后代:provide 与 inject](#7.1 祖先传后代:provide 与 inject)
- [7.2 任意两个组件:使用 mitt 事件总线](#7.2 任意两个组件:使用 mitt 事件总线)
- 总结
前言
Vue 的组件化开发把页面拆成一个个职责清晰的模块,但拆分之后也会带来两个必须正面解决的问题:样式如何只影响当前组件 ,以及组件之间如何安全地传递数据和触发行为。如果样式没有边界,组件会互相污染;如果数据流没有约束,组件就会变成互相修改状态的"黑盒"。
本文从 scoped 的编译原理开始,逐步讲清楚父子组件通信、props 校验、非父子组件通信,并通过一个可扩展的 Todo 记事本完成列表展示、添加、删除、清除已完成和本地持久化。每个实战模块都先看整体流程,再拆开解释 API、参数和执行过程,最后给出可以组合起来的代码。
1. scoped:让组件样式拥有边界
1.1 为什么需要样式隔离
在单文件组件中,<style> 默认会被打包为普通 CSS。假设 MyLeft.vue 中写了下面的代码:
html
<template>
<div class="my-left">
<h3>每天起床第一句</h3>
</div>
</template>
<style>
h3 {
color: red;
}
</style>
此时 h3 是一个全局选择器。只要页面中出现任何 h3,它都有可能变成红色,包括另一个完全不相关的 MyRight.vue。组件虽然完成了代码拆分,但 CSS 仍然没有完成边界拆分。
核心定义: 默认
<style>作用于全局;<style scoped>通过编译期生成唯一属性选择器,让样式只匹配当前组件的元素。
1.2 scoped 的使用方式
只需要在 style 标签上增加 scoped:
vue
<style scoped>
h3 {
color: red;
}
</style>
scoped 也可以和预处理器一起使用:
vue
<style scoped lang="scss">
.item {
padding: 20px;
&:hover {
transform: translateY(-4px);
}
}
</style>
这里的 lang="scss" 只表示使用 Sass 语法,必须确保项目已经安装并配置对应预处理器;scoped 负责的是选择器隔离,两者解决的是不同问题。
1.3 scoped 的编译原理
Vue 编译器会为当前组件生成一个类似 data-v-abc123 的唯一标识,并完成两件事:
data-v-abc123中的abc123是 Vue 编译器生成的哈希标识,用来表示当前组件的样式作用域。编译时,Vue 会把这个标识添加到组件模板的元素上,同时把 CSS 选择器转换为带有[data-v-abc123]的属性选择器。这样,样式只有在元素属于当前组件时才会匹配。这里的哈希只承担区分组件作用域的任务,不代表加密,也不用于安全校验。
- 给当前组件模板中的元素添加
data-v-abc123属性。 - 给组件样式中的选择器追加
[data-v-abc123]。
源码:
css
h3 {
color: red;
}
编译后可以近似理解为:
html
<h3 data-v-abc123>每天起床第一句</h3>
css
h3[data-v-abc123] {
color: red;
}
最终浏览器需要同时满足 h3 和 [data-v-abc123] 两个条件,所以其他组件中的 h3 不会被命中。注意,scoped 是编译期的属性选择器策略,不是运行时创建了独立 CSS 沙箱。
1.4 scoped 的边界与常见误区
scoped 能隔离当前组件写出的选择器,但并不意味着所有情况都自动隔离:
:global(.class-name)可以显式穿透到全局,适合少量全局工具类,不要滥用。- 子组件的根节点可能同时带有父组件和子组件的标识,这是为了方便父组件调整子组件根节点布局,但父组件不能随意控制子组件内部所有元素。
- 第三方组件内部 DOM 通常需要使用库提供的主题 API,或谨慎使用
:deep();深度选择器过多会重新带来样式耦合。 scoped不能替代合理的类名设计。组件内部仍应优先使用.todo-list、.todo-item这类有语义的类名。
| 写法 | 作用范围 | 适合场景 |
|---|---|---|
<style> |
全局 | reset、主题变量、全局工具类 |
<style scoped> |
当前组件 | 组件自身布局和视觉样式 |
:global() |
显式全局 | 少量公共样式 |
:deep() |
影响后代或第三方组件内部 | 必要的库样式覆盖 |
2. 组件通信:先确定关系,再选择方案
2.1 组件通信解决什么问题
组件实例的数据默认是独立的。父组件不能直接读取子组件的局部变量,兄弟组件也不应直接互相修改状态。组件通信的本质是:在保持组件边界的前提下,让一个组件把数据或事件传递给另一个组件。
先判断组件关系,再选择通信方式:
| 组件关系 | 推荐方案 | 数据流方向 |
|---|---|---|
| 父子 | props + 自定义事件 |
父传子、子通知父 |
| 祖先与后代 | provide + inject |
跨层级向下共享 |
| 任意两个组件 | mitt 事件总线或状态管理 |
发布与订阅 |
父子通信遵循"数据向下,事件向上"的单向数据流。父组件拥有状态,子组件接收状态并展示;子组件想改变状态时发出事件,由父组件决定是否修改。
3. 父传子:使用 props 传递数据
3.1 整体流程
父组件准备数据,子组件用 defineProps() 声明接收项,父组件在子组件标签上通过自定义属性传值:
text
App.vue
└─ :img-url、:title、:price
↓
MyGoods.vue 的 props
↓
模板展示数据
3.2 defineProps 的作用与基本写法
在 <script setup> 中,defineProps 是 Vue 提供的编译器宏,不需要手动导入。最简单的写法是传入属性名数组:
html
<script setup>
defineProps(['imgUrl', 'title', 'price'])
</script>
数组写法适合快速演示,但缺少类型、必填和默认值约束。实际项目更推荐对象写法:
html
<script setup>
const props = defineProps({
title: String,
price: Number,
imgUrl: String
})
</script>
defineProps 的返回值是一个只读的 props 对象。模板中可以直接使用 title,脚本中通常通过 props.title 访问。它的核心参数和结果如下:
| 内容 | 含义 |
|---|---|
| 参数 | 数组或对象形式的 props 声明 |
| 返回值 | 当前组件接收到的只读 props 对象 |
| 使用场景 | 父组件向子组件传递展示数据或配置 |
| 常见错误 | 子组件直接给 props.title 赋值、属性名大小写不一致 |
3.3 商品卡片案例:从静态页面到可复用组件
父组件持有商品数据,使用动态绑定 : 将 JavaScript 表达式的结果传给子组件:
html
<!-- App.vue -->
<script setup>
import MyGoods from './components/MyGoods.vue'
const goodsObj = {
id: '3998274',
name: '德国百年工艺高端水晶玻璃红酒杯2支装',
price: 139,
picture: 'https://example.com/wine-glass.jpg'
}
</script>
<template>
<div class="list">
<MyGoods
:img-url="goodsObj.picture"
:title="goodsObj.name"
:price="goodsObj.price"
/>
</div>
</template>
子组件只负责展示,不负责决定商品从哪里来:
html
<!-- components/MyGoods.vue -->
<script setup>
const props = defineProps({
imgUrl: String,
title: String,
price: Number
})
</script>
<template>
<article class="item">
<img :src="props.imgUrl" :alt="props.title" />
<p class="name">{{ props.title }}</p>
<p class="price">¥{{ props.price }}</p>
</article>
</template>
<style scoped>
.item {
width: 240px;
padding: 20px 30px;
transition: transform 0.3s, box-shadow 0.3s;
}
.item:hover {
box-shadow: 0 0 5px rgb(0 0 0 / 20%);
transform: translateY(-4px);
}
img {
width: 100%;
}
</style>
执行时,Vue 先创建 MyGoods 实例,再把父组件传入的值挂到该实例的 props 上;当父组件的 goodsObj.price 发生响应式变化时,子组件会重新渲染,但子组件不能反过来改写这个输入。
3.4 配合 v-for 给每个组件独立传值
组件标签和 li 一样可以被 v-for 遍历。关键点是:每次循环都要传入当前 item 的字段,并设置稳定的 key。
html
<script setup>
import MyGoods from './components/MyGoods.vue'
const goodsList = [
{ id: '4001172', name: '手摇咖啡磨豆机', price: 289, picture: '/images/coffee.jpg' },
{ id: '4001594', name: '黑陶功夫茶组', price: 288, picture: '/images/tea.jpg' },
{ id: '4001009', name: '竹制干泡茶盘', price: 109, picture: '/images/tray.jpg' }
]
</script>
<template>
<div class="list">
<MyGoods
v-for="item in goodsList"
:key="item.id"
:img-url="item.picture"
:title="item.name"
:price="item.price"
/>
</div>
</template>
key 不只是为了消除警告,它帮助 Vue 判断列表项身份,避免更新时错误复用 DOM。不要在数据有唯一 ID 时用循环下标作为 key,否则插入、删除或排序后可能出现输入框状态错位。
4. 子传父:使用自定义事件通知父组件
4.1 为什么子组件不能直接修改 props
props 是父组件传入的只读输入,子组件直接修改它会破坏单向数据流:父组件拥有一份状态,子组件又偷偷维护另一套修改逻辑,最终很难判断状态到底由谁负责。
正确流程是:子组件只发出"发生了什么",父组件决定"怎么改数据"。
4.2 defineEmits 的作用与参数
defineEmits() 用于声明或获取当前组件向外触发事件的函数:
html
<script setup>
const emit = defineEmits(['cut-price'])
const onCut = () => {
emit('cut-price', 3)
}
</script>
emit 的第一个参数是事件名,后面的参数是传给父组件的载荷。父组件在子组件标签上使用 @事件名 监听:
html
<MyGoods @cut-price="handleCutPrice" />
| API | 作用 | 参数 | 返回值 |
|---|---|---|---|
defineEmits() |
声明组件可触发的事件 | 事件名数组或对象 | emit 函数 |
emit(name, ...args) |
触发自定义事件 | 事件名、任意载荷 | 通常无业务返回值 |
@event="handler" |
父组件监听事件 | 事件处理函数 | 由 Vue 调用处理函数 |
4.3 砍价案例:完整的事件链路
父组件保存可变的商品列表,并把当前商品下标传给子组件:
html
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import MyGoods from './components/MyGoods.vue'
const goodsList = ref([
{ id: '1', name: '手摇咖啡磨豆机', price: 289, picture: '/images/coffee.jpg' },
{ id: '2', name: '黑陶功夫茶组', price: 288, picture: '/images/tea.jpg' }
])
const subPrice = (index, amount) => {
goodsList.value[index].price -= amount
}
</script>
<template>
<div class="list">
<MyGoods
v-for="(item, index) in goodsList"
:key="item.id"
:img-url="item.picture"
:title="item.name"
:price="item.price"
:index="index"
@cut-price="subPrice"
/>
</div>
</template>
子组件触发事件,但不直接碰 goodsList:
html
<!-- components/MyGoods.vue -->
<script setup>
const props = defineProps({
imgUrl: String,
title: String,
price: Number,
index: Number
})
const emit = defineEmits(['cut-price'])
const onCut = () => {
const amount = 3
emit('cut-price', props.index, amount)
}
</script>
<template>
<article class="item">
<img :src="props.imgUrl" :alt="props.title" />
<p>{{ props.title }}</p>
<p>¥{{ props.price }}</p>
<button type="button" @click="onCut">砍一刀</button>
</article>
</template>
点击按钮后的执行顺序是:
- 子组件执行
onCut()。 emit('cut-price', index, amount)发出事件和参数。- 父组件匹配到
@cut-price="subPrice",调用subPrice(index, amount)。 - 父组件修改自己的
goodsList,响应式系统通知相关子组件更新价格。
如果子组件需要传递复杂对象,建议传递稳定的业务 ID,而不是依赖数组下标;父组件再通过 ID 查找目标项,列表排序后会更加安全。
5. props 校验:给组件输入建立契约
5.1 为什么需要 props 校验
可复用组件不能假设调用者永远传入正确数据。例如对话框透明度应该是 0 到 1 之间的数字,商品价格应该是数字而不是字符串。props 校验可以在开发阶段尽早发现问题,让组件的输入契约变得明确。
5.2 完整声明方式
html
<script setup>
const props = defineProps({
opacity: {
type: Number,
required: true,
validator(value) {
return value >= 0 && value <= 1
}
},
title: {
type: String,
default: '提示'
},
buttons: {
type: Array,
default: () => []
}
})
</script>
<template>
<div :style="{ backgroundColor: `rgb(0 0 0 / ${props.opacity})` }">
{{ props.title }}
</div>
</template>
各配置项的含义如下:
| 配置项 | 作用 | 使用要点 |
|---|---|---|
type |
声明期望类型 | String、Number、Boolean、Object、Array 等构造函数 |
required |
是否必须传入 | 缺失时开发环境发出警告 |
default |
缺省值 | 基础类型直接写值,数组和对象使用工厂函数 |
validator |
自定义校验 | 返回 true 表示通过,false 表示不通过 |
defineProps 的返回值仍然是只读对象,校验只负责提示开发错误,不会自动把错误输入修正成正确输入。
5.3 default 与 required 的注意事项
required: true 和 default 表达的是相反的语义:前者要求调用方必须传值,后者允许调用方不传值。因此通常不要同时配置。复杂类型的默认值必须使用函数返回新对象:
javascript
const props = defineProps({
config: {
type: Object,
default: () => ({})
},
tags: {
type: Array,
default: () => []
}
})
如果直接写 default: [],多个组件实例可能共享同一份数组引用,某个实例的修改会影响其他实例。工厂函数保证每次创建组件时获得独立默认值。
5.4 props 与 ref、reactive 的区别
三者都可以在模板中提供数据,但所有权不同:
| 数据来源 | 归属者 | 是否可在当前组件直接修改 | 典型用途 |
|---|---|---|---|
props |
父组件 | 否,只读 | 外部输入、展示配置 |
ref |
当前组件 | 是,通过 .value |
基础类型或替换整个值 |
reactive |
当前组件 | 是,直接改属性 | 对象、数组的本地状态 |
对于嵌套对象,Vue 可能允许通过 props 访问到的对象内部属性发生变化,但这会让数据责任变得模糊,实践中仍建议由父组件统一修改,并通过事件通知父组件。
6. 记事本组件:把通信知识串成完整应用
6.1 项目要完成什么:先认识三个页面板块
这个记事本并不只是一个数组的增删练习,它要把前面学过的 props、自定义事件、v-for、v-model、ref 和 watch 串成一条完整的数据流。最终页面分为三个板块,每个板块都对应一个独立组件。
- 头部输入区
TodoHeader:负责收集新任务名称,用户按下回车后发出add-todo事件。它只关心"用户输入了什么",不直接操作任务数组。 - 中间列表区
TodoMain:负责接收并展示任务列表,处理完成状态和删除按钮。它通过todoListprops 获取数据,通过del-todo事件通知父组件删除任务。 - 底部操作区
TodoFooter:负责显示任务数量,并在用户点击按钮时发出clear-todo事件,请求父组件清除所有已完成任务。 - 根组件
App.vue:不是一个单纯的展示板块,而是整个应用的状态中心。任务数组由它创建,添加、删除、清除和持久化也由它统一管理。
项目核心原则: 数据尽量集中在共同父组件
App.vue中,父组件通过 props 向下传数据,子组件通过自定义事件向上表达操作意图。
组件之间的关系可以表示为:
text
todoList
┌─────────────────────────┐
↓ ↓
TodoHeader TodoMain TodoFooter
│ │ │
add-todo del-todo clear-todo
│ │ │
└───────────┴──────→ App.vue ←──────┘
│
└── 修改 todoList 并写入 localStorage
组件接口先统一规划好,后面的代码就不会越写越乱:
| 组件 | 接收的 props | 发出的事件 | 是否直接维护任务数组 |
|---|---|---|---|
TodoHeader |
无 | add-todo,携带任务名称 |
否 |
TodoMain |
todoList |
del-todo,携带任务下标 |
否 |
TodoFooter |
todoList |
clear-todo,不携带参数 |
否 |
App.vue |
无 | 监听三个子组件事件 | 是 |
每一条任务使用一个对象表示:
javascript
{
id: 321,
name: '学习 Vue 组件通信',
finished: false
}
三个字段各有明确职责:
| 字段 | 类型 | 作用 |
|---|---|---|
id |
Number 或 String |
唯一标识任务,也是 v-for 的 key |
name |
String |
保存并展示任务名称 |
finished |
Boolean |
表示任务是否完成,控制复选框和删除线样式 |
6.2 第一步:创建项目并搭好静态组件骨架
这一板块的目标是先把项目目录、组件关系和静态页面跑起来。暂时不急着处理数据和事件,先确认组件可以被正确导入、页面区域可以正确组合。这样后面出现问题时,才能判断是目录路径错误,还是业务逻辑错误。
使用 Vite 创建 Vue 3 项目:
bash
npm create vite@latest vue-todo -- --template vue
cd vue-todo
npm install
npm run dev
把示例文件整理为下面的结构:
text
vue-todo
├── index.html
├── package.json
├── src
│ ├── App.vue
│ ├── main.js
│ ├── assets
│ │ └── style.css
│ └── components
│ ├── TodoHeader.vue
│ ├── TodoMain.vue
│ └── TodoFooter.vue
└── vite.config.js
TodoHeader.vue 先提供标题和输入框:
vue
<template>
<header class="header">
<h1>我的记事本</h1>
<input
class="new-todo"
placeholder="接下来要完成什么?"
autofocus
/>
</header>
</template>
TodoMain.vue 先提供一条静态任务,确认列表结构和样式类名没有问题:
vue
<template>
<section class="main">
<ul class="todo-list">
<li>
<div class="view">
<input class="toggle" type="checkbox" />
<label>学习 Vue 组件通信</label>
<button class="destroy" type="button">删除</button>
</div>
</li>
</ul>
</section>
</template>
TodoFooter.vue 先提供数量和操作按钮:
vue
<template>
<footer class="footer">
<span class="todo-count"><strong>0</strong> item left</span>
<button class="clear-completed" type="button">清除已完成</button>
</footer>
</template>
最后在 App.vue 中完成导入和组装:
vue
<script setup>
import TodoHeader from './components/TodoHeader.vue'
import TodoMain from './components/TodoMain.vue'
import TodoFooter from './components/TodoFooter.vue'
import './assets/style.css'
</script>
<template>
<section class="todoapp">
<TodoHeader />
<TodoMain />
<TodoFooter />
</section>
</template>
这里需要注意三个基础点:
import TodoHeader from './components/TodoHeader.vue'导入的是组件对象,导入后才能在模板中写<TodoHeader />。import './assets/style.css'只负责加载全局样式,不会产生一个可使用的 JavaScript 变量。- 路径中的目录名必须完全一致,例如
assets不能误写成asssts,否则 Vite 会提示找不到模块。
静态骨架能正常显示后,再进入数据驱动阶段。此时三个子组件只有页面结构,还没有形成真正的组件通信。
6.3 第二步:在 App.vue 准备数据并完成列表渲染
这一板块解决两个问题:任务数组应该放在哪里,以及 TodoMain 怎样拿到数组并渲染出来。 三个子组件都可能使用任务数据,因此数据应该放在它们共同的父组件 App.vue 中。
先在父组件创建响应式任务列表:
vue
<script setup>
import { ref } from 'vue'
const todoList = ref([
{ id: 321, name: '学习 Vue 组件通信', finished: false },
{ id: 127, name: '打王者', finished: true },
{ id: 666, name: '跑步一小时', finished: false }
])
</script>
ref(initialValue) 接收一个初始值,返回一个响应式引用对象。这里传入的是数组,因此真正的数据保存在 todoList.value 中:
javascript
console.log(todoList.value)
脚本中修改数据要访问 .value:
javascript
todoList.value.push(newTodo)
模板会自动解包 ref,所以传给子组件时直接写 todoList:
vue
<TodoMain :todo-list="todoList" />
这行代码包含两部分:
text
:todo-list="todoList"
↑ ↑
子组件 prop 名 父组件中的响应式数组
模板中的 todo-list 使用短横线命名,子组件脚本中会对应为驼峰形式的 todoList。接下来在 TodoMain.vue 中声明接收:
vue
<script setup>
const props = defineProps({
todoList: {
type: Array,
default: () => []
}
})
</script>
defineProps() 的作用是建立子组件的输入契约:
| 配置 | 当前值 | 含义 |
|---|---|---|
| prop 名称 | todoList |
父组件需要通过该名称传入任务列表 |
type |
Array |
期望收到数组 |
default |
() => [] |
父组件未传值时使用独立空数组 |
| 返回值 | props |
在脚本和模板中读取 props 的只读对象 |
拿到数组后,用 v-for 渲染任务:
vue
<template>
<section class="main">
<ul class="todo-list">
<li
v-for="(todo, index) in props.todoList"
:key="todo.id"
:class="{ completed: todo.finished }"
>
<div class="view">
<input
v-model="todo.finished"
class="toggle"
type="checkbox"
/>
<label>{{ todo.name }}</label>
<button class="destroy" type="button">删除</button>
</div>
</li>
</ul>
</section>
</template>
这段模板需要逐项理解:
v-for="(todo, index) in props.todoList"遍历数组,todo是当前任务对象,index是当前下标。:key="todo.id"使用任务 ID 标记列表项身份,帮助 Vue 正确复用和更新 DOM。:class="{ completed: todo.finished }"表示finished为true时添加completed类。{``{ todo.name }}使用插值表达式显示任务名称。v-model="todo.finished"把复选框选中状态与finished建立双向同步。
配合下面的样式,就能在任务完成后显示删除线:
css
.todo-list li.completed label {
color: #949494;
text-decoration: line-through;
}
列表首次渲染的执行过程如下:
text
App.vue 创建 todoList
↓
:todo-list="todoList" 父传子
↓
TodoMain 的 defineProps 接收
↓
v-for 遍历每一个任务对象
↓
根据 name 和 finished 渲染文本、复选框与样式
需要特别说明,props 本身是只读的,不能写 props.todoList = []。示例中的 v-model="todo.finished" 修改的是数组内部对象属性,在这个入门案例中可以运行,但它会让子组件参与修改父组件对象。更严格的单向数据流可以把勾选操作也改为 emit('toggle-todo', todo.id),再由父组件修改 finished。
6.4 第三步:TodoHeader 收集输入并添加任务
头部组件的职责是收集任务名称并报告"用户要添加任务"这件事 。任务数组属于 App.vue,所以 TodoHeader 不能自己执行 todoList.unshift(),而应该把输入内容通过自定义事件交给父组件。
先在子组件创建输入框状态:
javascript
import { ref } from 'vue'
const title = ref('')
再使用 v-model.trim 绑定输入框:
vue
<input v-model.trim="title" />
这里同时完成了两件事:
v-model让输入框内容和title.value保持同步。.trim修饰符自动移除输入内容两端的空格,避免把纯空格当成有效任务。
接着声明并触发 add-todo 事件:
javascript
const emit = defineEmits(['add-todo'])
const onEnter = () => {
if (!title.value) {
window.alert('任务名称不能为空')
return
}
emit('add-todo', title.value)
title.value = ''
}
defineEmits(['add-todo']) 声明当前组件允许发出 add-todo 事件,并返回 emit 函数。调用:
javascript
emit('add-todo', title.value)
一共传入两个参数:
| 参数位置 | 当前值 | 作用 |
|---|---|---|
| 第一个参数 | 'add-todo' |
要触发的自定义事件名 |
| 第二个参数 | title.value |
传给父组件的任务名称 |
完整的 TodoHeader.vue 如下:
vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add-todo'])
const title = ref('')
const onEnter = () => {
if (!title.value) {
window.alert('任务名称不能为空')
return
}
emit('add-todo', title.value)
title.value = ''
}
</script>
<template>
<header class="header">
<h1>我的记事本</h1>
<input
v-model.trim="title"
class="new-todo"
placeholder="接下来要完成什么?"
aria-label="新任务名称"
autofocus
@keyup.enter="onEnter"
/>
</header>
</template>
@keyup.enter="onEnter" 表示只有用户按下回车键时才执行 onEnter。autofocus 会尝试在页面加载后自动聚焦输入框,aria-label 则为辅助技术提供输入框说明。
父组件监听事件并真正修改数组:
javascript
const addTodo = (name) => {
todoList.value.unshift({
id: crypto.randomUUID?.() ?? String(Date.now()),
name,
finished: false
})
}
vue
<TodoHeader @add-todo="addTodo" />
父组件的监听关系可以直接对照:
text
子组件:emit('add-todo', title.value)
↓ ↓
父组件:@add-todo="addTodo" addTodo(name)
unshift() 把新任务插入数组头部,因此新任务会显示在最上方。crypto.randomUUID() 优先生成唯一字符串;如果当前环境不支持,就使用 Date.now() 的时间戳字符串作为备用 ID。新增任务默认设置 finished: false,所以复选框不会被选中。
整个添加流程是:
text
用户输入任务名称
↓
v-model.trim 更新 title.value
↓
用户按下 Enter
↓
onEnter 完成非空校验
↓
emit 发出 add-todo,并携带任务名称
↓
App.vue 的 addTodo 执行 unshift
↓
todoList 变化,TodoMain 自动重新渲染
6.5 第四步:完成状态与删除功能
列表区不仅展示数据,还要处理两个操作:勾选任务完成状态 和删除指定任务 。勾选操作改变任务对象中的 finished,删除操作则需要移除数组中的整条数据。
完成状态由下面这行代码负责:
vue
<input v-model="todo.finished" class="toggle" type="checkbox" />
对复选框来说,v-model 会读取和写入布尔值:
text
复选框选中 → todo.finished = true
复选框取消选中 → todo.finished = false
与此同时,列表项使用动态 class:
vue
<li :class="{ completed: todo.finished }">
所以同一个 finished 同时控制复选框和完成样式,数据不会出现两套状态。
删除操作不能由子组件直接执行,因为任务数组的所有者是父组件。先在 TodoMain.vue 中声明事件:
javascript
const emit = defineEmits(['del-todo'])
遍历列表时已经拿到了 index:
vue
<li v-for="(todo, index) in props.todoList" :key="todo.id">
因此点击删除按钮时,可以把当前下标传给父组件:
vue
<button
class="destroy"
type="button"
@click="emit('del-todo', index)"
>
删除
</button>
整合后的 TodoMain.vue 为:
vue
<script setup>
const props = defineProps({
todoList: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['del-todo'])
</script>
<template>
<section class="main">
<ul class="todo-list">
<li
v-for="(todo, index) in props.todoList"
:key="todo.id"
:class="{ completed: todo.finished }"
>
<div class="view">
<input
v-model="todo.finished"
class="toggle"
type="checkbox"
:aria-label="`标记"${todo.name}"的完成状态`"
/>
<label>{{ todo.name }}</label>
<button
class="destroy"
type="button"
:aria-label="`删除任务"${todo.name}"`"
@click="emit('del-todo', index)"
></button>
</div>
</li>
</ul>
</section>
</template>
父组件监听 del-todo:
vue
<TodoMain
:todo-list="todoList"
@del-todo="delTodo"
/>
并通过 splice() 删除指定下标的一项:
javascript
const delTodo = (index) => {
if (window.confirm('确认删除这条任务吗?')) {
todoList.value.splice(index, 1)
}
}
splice(index, 1) 的两个参数分别表示:
| 参数 | 当前值 | 含义 |
|---|---|---|
| 起始位置 | index |
从当前任务所在位置开始 |
| 删除数量 | 1 |
只删除一条数据 |
删除流程如下:
text
用户点击某一条任务的删除按钮
↓
TodoMain 触发 del-todo,并携带 index
↓
App.vue 调用 delTodo(index)
↓
用户确认删除
↓
splice(index, 1) 修改 todoList
↓
Vue 移除对应列表 DOM
下标方案适合当前没有排序、筛选的练习项目。如果以后加入筛选视图,页面下标可能不再等于原数组下标,更稳妥的做法是传递 todo.id,父组件再使用 findIndex() 找到任务。
6.6 第五步:底部统计、批量清理与本地持久化
底部组件要完成两件事:展示当前任务总数 ,以及发出清除已完成任务的请求 。它需要读取列表,因此由父组件继续通过 todoList props 传值。
完整的 TodoFooter.vue 如下:
vue
<script setup>
const props = defineProps({
todoList: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['clear-todo'])
</script>
<template>
<footer class="footer">
<span class="todo-count">
<strong>{{ props.todoList.length }}</strong>
item left
</span>
<button
class="clear-completed"
type="button"
@click="emit('clear-todo')"
>
清除已完成
</button>
</footer>
</template>
props.todoList.length 会随着数组长度变化自动更新。按钮触发的 clear-todo 没有携带参数,因为父组件已经拥有完整数组,不需要子组件再把数组传回去。
父组件这样连接事件:
vue
<TodoFooter
:todo-list="todoList"
@clear-todo="clearTodo"
/>
清理函数使用 filter() 创建新数组:
javascript
const clearTodo = () => {
todoList.value = todoList.value.filter((todo) => !todo.finished)
}
filter() 会逐个执行判断:
text
todo.finished === false → !false 为 true → 保留
todo.finished === true → !true 为 false → 过滤
因此最终只保留未完成任务。这里必须重新赋值给 todoList.value,因为 filter() 不会修改原数组,而是返回一个新数组。
到这里,页面功能已经能正常使用,但刷新浏览器后数据会重新变成默认值。为了解决这个问题,需要使用 localStorage 保存数据。
先定义固定键名和默认数据:
javascript
const TODO_LIST_KEY = 'todo-list-key'
const defaultList = [
{ id: 321, name: '学习 Vue 组件通信', finished: false },
{ id: 127, name: '打王者', finished: true },
{ id: 666, name: '跑步一小时', finished: false }
]
读取数据时需要完成"读取字符串---解析 JSON---失败兜底"三个步骤:
javascript
const readTodoList = () => {
try {
const raw = localStorage.getItem(TODO_LIST_KEY)
return raw ? JSON.parse(raw) : defaultList
} catch {
return defaultList
}
}
const todoList = ref(readTodoList())
localStorage.getItem() 找不到数据时返回 null;找到数据时返回字符串。JSON.parse() 把字符串还原为数组。如果本地数据被手动改坏,JSON.parse() 会抛出异常,所以使用 try...catch 回退到默认列表,避免整个页面白屏。
数据变化后,再使用 watch() 自动写入:
javascript
watch(
todoList,
(newList) => {
localStorage.setItem(TODO_LIST_KEY, JSON.stringify(newList))
},
{ deep: true }
)
watch() 的三个参数在这里分别承担以下职责:
| 参数位置 | 当前内容 | 作用 |
|---|---|---|
| 第一个参数 | todoList |
要监听的响应式数据源 |
| 第二个参数 | (newList) => {} |
数据变化后执行的回调 |
| 第三个参数 | { deep: true } |
深度监听数组内部对象属性 |
必须使用 deep: true,因为勾选任务时改变的是:
javascript
todoList.value[index].finished
如果只关心数组整体是否被替换,普通监听就够了;但本项目还要捕获对象内部的 finished 变化,所以需要深度监听。
localStorage 只能保存字符串,因此写入前必须执行:
javascript
JSON.stringify(newList)
完整持久化流程为:
text
首次进入页面
↓
localStorage 有数据吗?
├─ 有:JSON.parse 后作为 todoList 初始值
└─ 无:使用 defaultList
任务发生添加、删除、清理或完成状态变化
↓
watch 深度监听到变化
↓
JSON.stringify 转为字符串
↓
localStorage.setItem 保存
↓
下次刷新时重新读取
6.7 第六步:整合 App.vue 并复盘完整执行流程
前面每个板块已经分别完成,现在把数据源、三个操作函数、持久化监听和三个子组件统一放进 App.vue。父组件最终承担四项职责:初始化任务、接收子组件事件、修改任务数组、保存任务数据。
完整的 App.vue 如下:
vue
<script setup>
import { ref, watch } from 'vue'
import TodoHeader from './components/TodoHeader.vue'
import TodoMain from './components/TodoMain.vue'
import TodoFooter from './components/TodoFooter.vue'
import './assets/style.css'
const TODO_LIST_KEY = 'todo-list-key'
const defaultList = [
{ id: 321, name: '学习 Vue 组件通信', finished: false },
{ id: 127, name: '打王者', finished: true },
{ id: 666, name: '跑步一小时', finished: false }
]
const readTodoList = () => {
try {
const raw = localStorage.getItem(TODO_LIST_KEY)
return raw ? JSON.parse(raw) : defaultList
} catch {
return defaultList
}
}
const todoList = ref(readTodoList())
const addTodo = (name) => {
todoList.value.unshift({
id: crypto.randomUUID?.() ?? String(Date.now()),
name,
finished: false
})
}
const delTodo = (index) => {
if (window.confirm('确认删除这条任务吗?')) {
todoList.value.splice(index, 1)
}
}
const clearTodo = () => {
todoList.value = todoList.value.filter((todo) => !todo.finished)
}
watch(
todoList,
(newList) => {
localStorage.setItem(TODO_LIST_KEY, JSON.stringify(newList))
},
{ deep: true }
)
</script>
<template>
<section class="todoapp">
<TodoHeader @add-todo="addTodo" />
<TodoMain
:todo-list="todoList"
@del-todo="delTodo"
/>
<TodoFooter
:todo-list="todoList"
@clear-todo="clearTodo"
/>
</section>
</template>
模板中的三行组件代码就是整个项目的数据接口:
| 组件连接 | 数据或事件方向 | 实际作用 |
|---|---|---|
@add-todo="addTodo" |
TodoHeader → App |
把输入的任务名称交给父组件 |
:todo-list="todoList" |
App → TodoMain |
给列表区提供任务数组 |
@del-todo="delTodo" |
TodoMain → App |
把待删除任务的下标交给父组件 |
:todo-list="todoList" |
App → TodoFooter |
给底部提供任务数组以统计数量 |
@clear-todo="clearTodo" |
TodoFooter → App |
请求父组件清除已完成任务 |
项目运行后的完整行为可以用一张表复盘:
| 用户操作或阶段 | 首先执行的位置 | 数据处理 | 最终结果 |
|---|---|---|---|
| 页面首次打开 | App.vue |
读取 localStorage,没有则使用默认数据 |
三个组件获得初始状态 |
| 输入任务并回车 | TodoHeader |
触发 add-todo,父组件执行 unshift |
新任务出现在列表顶部 |
| 勾选任务 | TodoMain |
更新当前任务的 finished |
复选框、删除线和本地存储同步变化 |
| 删除任务 | TodoMain |
触发 del-todo,父组件执行 splice |
指定任务被移除 |
| 清除已完成 | TodoFooter |
触发 clear-todo,父组件执行 filter |
所有已完成任务被过滤 |
| 任意数据变化 | App.vue 中的 watch |
序列化任务数组并写入本地 | 刷新页面后状态仍然存在 |
这一项目真正需要掌握的不是某一个数组方法,而是组件之间如何围绕同一份状态协作:
App.vue负责保存业务状态,避免三个子组件各维护一份互不一致的数据。TodoMain和TodoFooter使用 props 读取父组件状态,形成从父到子的单向数据入口。TodoHeader、TodoMain和TodoFooter使用自定义事件表达操作意图,父组件监听事件后修改自己的数据。todoList是响应式数据,只要它发生变化,依赖它的列表、数量和样式都会自动更新。watch不参与页面展示,它负责观察状态变化并执行持久化这一副作用。
阶段性总结: 先拆组件职责,再把共享状态提升到父组件;父组件用 props 传数据,子组件用 emit 传事件;最后由响应式系统刷新视图,由
watch + localStorage保存结果。这就是这个记事本项目从组件拆分到完整运行的主线。
7. 非父子组件通信
7.1 祖先传后代:provide 与 inject
当组件层级很深时,如果仍然逐层使用 props,数据需要经过许多"中转组件",这就是 prop drilling。provide 和 inject 允许祖先组件直接向后代提供依赖:
vue
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
任意后代组件都可以注入:
vue
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light')
</script>
<template>
<div :class="theme">当前主题:{{ theme }}</div>
</template>
provide(key, value) 的 key 是依赖名称,value 可以是普通值、对象或响应式引用;inject(key, defaultValue) 根据同一个 key 获取值,第二个参数是找不到提供者时的默认值。若希望后代修改数据,建议提供操作函数,而不是把可写状态直接暴露出去。
javascript
provide('todo-service', {
todoList,
addTodo
})
7.2 任意两个组件:使用 mitt 事件总线
两个组件没有共同的父子链路,又不值得引入完整状态管理时,可以使用 mitt 建立轻量事件总线:
bash
npm install mitt
创建一个公共模块:
javascript
// src/utils/bus.js
import mitt from 'mitt'
export const bus = mitt()
发送方调用 emit:
vue
<!-- MyLeft.vue -->
<script setup>
import { bus } from '../utils/bus'
const sendMessage = () => {
bus.emit('message', '你好,织女!')
}
</script>
<template>
<button type="button" @click="sendMessage">发送消息</button>
</template>
接收方调用 on,并在组件卸载时取消监听,避免重复注册和内存泄漏:
vue
<!-- MyRight.vue -->
<script setup>
import { onBeforeUnmount, ref } from 'vue'
import { bus } from '../utils/bus'
const message = ref('暂未收到消息')
const receiveMessage = (value) => {
message.value = value
}
bus.on('message', receiveMessage)
onBeforeUnmount(() => {
bus.off('message', receiveMessage)
})
</script>
<template>
<p>{{ message }}</p>
</template>
事件总线适合短链路通知,例如弹窗、刷新提示、跨区域 UI 事件;不适合承载整个应用的核心状态,因为事件名和载荷分散在各个文件中,规模变大后追踪困难。需要可预测状态、调试时间旅行或复杂派生数据时,应考虑 Pinia 等状态管理方案。
| 方案 | 优点 | 风险或边界 |
|---|---|---|
props + emit |
数据流清晰、类型契约明确 | 深层级传递会产生中转 |
provide + inject |
跨层级方便 | 依赖关系不够显式,滥用会降低可维护性 |
mitt |
轻量、任意组件可通信 | 事件流分散,必须手动解绑 |
| 状态管理 | 状态集中、调试能力强 | 需要额外约定和库,简单场景可能过重 |
总结
组件化开发的关键不是把文件拆得越多越好,而是让每个组件拥有清晰的边界和责任。scoped 通过唯一属性选择器隔离样式;props 负责父组件向下提供只读输入;defineEmits 负责子组件向上报告事件;provide/inject 解决跨层级依赖;mitt 则适合轻量的任意组件通知。记事本案例把这些机制串成一条完整链路:状态集中在父组件,子组件通过 props 获取数据,通过事件表达意图,父组件统一修改并借助 watch 持久化。掌握这套单向数据流后,组件更容易复用、调试和扩展。