Vue3组件样式隔离与组件通信实战:从 scoped、props 校验到记事本应用

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 校验

可复用组件不能假设调用者永远传入正确数据。例如对话框透明度应该是 01 之间的数字,商品价格应该是数字而不是字符串。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 声明期望类型 StringNumberBooleanObjectArray 等构造函数
required 是否必须传入 缺失时开发环境发出警告
default 缺省值 基础类型直接写值,数组和对象使用工厂函数
validator 自定义校验 返回 true 表示通过,false 表示不通过

defineProps 的返回值仍然是只读对象,校验只负责提示开发错误,不会自动把错误输入修正成正确输入。

5.3 default 与 required 的注意事项

required: truedefault 表达的是相反的语义:前者要求调用方必须传值,后者允许调用方不传值。因此通常不要同时配置。复杂类型的默认值必须使用函数返回新对象:

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-forv-modelrefwatch 串成一条完整的数据流。最终页面分为三个板块,每个板块都对应一个独立组件。

  • 头部输入区 TodoHeader :负责收集新任务名称,用户按下回车后发出 add-todo 事件。它只关心"用户输入了什么",不直接操作任务数组。
  • 中间列表区 TodoMain :负责接收并展示任务列表,处理完成状态和删除按钮。它通过 todoList props 获取数据,通过 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 NumberString 唯一标识任务,也是 v-forkey
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 }" 表示 finishedtrue 时添加 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" 表示只有用户按下回车键时才执行 onEnterautofocus 会尝试在页面加载后自动聚焦输入框,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 负责保存业务状态,避免三个子组件各维护一份互不一致的数据。
  • TodoMainTodoFooter 使用 props 读取父组件状态,形成从父到子的单向数据入口。
  • TodoHeaderTodoMainTodoFooter 使用自定义事件表达操作意图,父组件监听事件后修改自己的数据。
  • todoList 是响应式数据,只要它发生变化,依赖它的列表、数量和样式都会自动更新。
  • watch 不参与页面展示,它负责观察状态变化并执行持久化这一副作用。

阶段性总结: 先拆组件职责,再把共享状态提升到父组件;父组件用 props 传数据,子组件用 emit 传事件;最后由响应式系统刷新视图,由 watch + localStorage 保存结果。这就是这个记事本项目从组件拆分到完整运行的主线。

7. 非父子组件通信

7.1 祖先传后代:provide 与 inject

当组件层级很深时,如果仍然逐层使用 props,数据需要经过许多"中转组件",这就是 prop drilling。provideinject 允许祖先组件直接向后代提供依赖:

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 持久化。掌握这套单向数据流后,组件更容易复用、调试和扩展。

相关推荐
LaughingZhu1 小时前
Product Hunt 每日热榜 | 2026-07-28
前端·神经网络·react.js·搜索引擎·前端框架
陈随易8 小时前
在 VSCode 里,把项目一键部署到服务器
前端·后端·程序员
Highcharts.js8 小时前
教程:基于 React + Highcharts 构建一个单页应用程序、按需数据拉取与图表渲染
开发语言·前端·数据结构·react.js·前端框架·highcharts·页面应用
To_OC9 小时前
我被 useState 坑了两次之后,终于把它的脾气摸透了
前端·javascript·react.js
鱼樱前端9 小时前
我用 Claude Code 后,编码效率翻 3 倍。但更值钱的是别的。
前端·后端·ai编程
_lucas9 小时前
给知识库网站接入AI问答
前端·ai编程·全栈
前端糕手10 小时前
前端面试题大全:JavaScript + Vue3 + React + TypeScript + 工程化 + 性能优化
前端
我不叫武11 小时前
一个用 Rust 写的离线编码查询桌面工具
前端
Web4Browser12 小时前
指纹浏览器 API 自动化怎么接:启动 Profile、获取 CDP 端点并连接自动化框架
前端·网络·typescript·自动化