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

相关推荐
mldong5 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu10 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss