主题
组件通信与进阶用法(二)
本页对应 组件通信与进阶用法,共 6 段视频,约 1 小时 21 分。
学习目标
- 能完成组件拆分并将商品项抽离为独立组件,在入口文件中通过
Vue.component注册为全局组件,并在页面中多次复用或配合v-for渲染。 - 能解释并演示
scoped解决样式冲突的原理,正确为组件style添加scoped,验证样式仅作用于当前组件。 - 能说明组件
data必须写成函数的原因,并编写返回独立数据对象的data函数,验证多个组件实例的数据互不影响。 - 能使用
props完成父传子通信:父组件通过自定义属性传递数据,子组件声明props接收并在模板中使用,确保属性名一致。 - 能使用
$emit完成子传父通信:子组件触发自定义事件并携带数据,父组件监听事件并在方法中接收处理,同时能解释单向数据流原则。 - 能按“导入—注册—使用”和“父子关系—父传子—子传父”的流程排查组件错误,并在综合案例中完成列表渲染、添加、删除、底部统计及持久化存储功能。
知识点
先分清:data 和 props 都是数据来源
组件要渲染内容,就需要数据。data 和 props 的共同点,是都能给组件提供数据;但它们的数据来源不同,因此“能不能改”的规则也不同。
data的数据是组件自己的,谁定义谁负责,可以随便改。props的数据是外部传进来的,组件只是接收方,不能直接改,要遵循单向数据流。
怎么想这件事?先问一句:这份数据是谁的?如果是当前组件自己 data 里声明的,当前组件就有修改权;如果是父级通过 props 传下来的,源头在父级,子组件只能读、用、展示,不能绕过父级直接改。易错点是把 props 当成普通本地变量,看到按钮要加减就直接 count--、count++,这在 data 版本里没问题,在 props 版本里就会破坏数据来源。
区别:自己的数据随便改,外部数据不能直接改
本节笔记把区别写得很直接:
data的数据是自己的 → 随便改。prop的数据是外部的 → 不能直接改,要遵循单向数据流。
为什么需要这个区分?因为组件化以后,同一份数据可能被多个组件使用。如果每个子组件都能直接改外部传进来的数据,那么数据源头就乱了:到底是谁改的、以谁为准,很难追踪。转写稿里用“砍一刀”的例子说明过:子组件拿到父级传来的商品价格后,若直接在子组件里写类似 obj.price--,编辑器会标红/报错;即使强行运行看似能改,也不符合单向数据流原则。正确思路是:子组件要改数据,应通知父组件,由父组件改自己的 data,改完后数据再自动向下流动到子组件。
怎么做?在子组件里把 props 当作“只读参数”使用;需要修改时,把“要改什么”通知给数据源头。易错点是以为“单向数据流”就是数据永远不能变——不是。源头父级可以改,只是子组件不能直接改外部传入的数据。
单向数据流:父级更新,向下影响子组件
单向数据流的含义是:父级 props 的数据更新,会向下流动,影响子组件。这个数据流动是单向的。
为什么需要这样设计?因为数据源头清晰,父级是数据的拥有者,子组件是接收者和展示者。怎么想?把数据想成“从上游往下游流”:父级改了,子组件自动收到新值;子组件不能反过来直接改上游。怎么做?父级维护自己的 data,通过 props 传给子组件;子组件只负责接收和展示。转写稿调试时也强调:父组件 data 一改,子组件接收到的 props 会自动更新,页面随之变化;但子组件直接改 props 不是正确写法。
易错点有两个:一是子组件直接改 props;二是误以为父级改完还要手动通知子组件更新。实际上父级数据更新后,会按单向数据流向下流动,子组件自动受影响。
代码演示:BaseCount 的 data 版本
先看本节代码。App.vue 中引入并注册 BaseCount:
vue
<template>
<div class="app">
<BaseCount></BaseCount>
</div>
</template>
<script>
import BaseCount from './components/BaseCount.vue'
export default {
components:{
BaseCount
},
data(){
},
}
</script>
<style>
</style>BaseCount.vue 中当前启用的是 data 版本,所以 count 是组件自己的数据,按钮可以直接加减:
vue
<template>
<div class="base-count">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
</template>
<script>
export default {
// 1.自己的数据随便修改 (谁的数据 谁负责)
data () {
return {
count: 100,
}
},
// 2.外部传过来的数据 不能随便修改
//props: {
// count: {
// type: Number,
// },
//}
}
</script>
<style>
.base-count {
margin: 20px;
}
</style>
这段代码里,props 被注释掉了,说明当前演示的是“自己的数据随便改”。如果取消注释、改成由父级传 count,那么子组件里的 count--、count++ 就不能再直接修改 props 了。转写稿还补充了一个相关易错点:组件里的 data 要写成函数并返回对象,这样每次创建组件实例都会得到独立的数据对象,多个同名组件互不影响;不能写成普通对象共享一份引用数据。
口诀与检查清单
本节口诀是:谁的数据谁负责。
写代码前可以按这个顺序检查:
- 这份数据定义在哪个组件?定义方就是数据负责人。
- 子组件拿到的是
data还是props?data可改,props只读。 - 要改数据时,改的是源头还是接收方?应改源头,让数据按单向数据流向下更新。
- 父级传值和子组件
props接收名是否一致?名字对不上就接收不到。
易错点再强调一次:不要把 props 当本地 data 直接改;不要因为按钮能写 count++ 就忽略数据来源;不要认为单向数据流等于数据不能变化。抓住“谁的数据谁负责”,props 和 data 的边界就清楚了。
需求与拆分目标
本节是“综合案例-组件拆分”。笔记先列出完整需求:拆分基础组件、渲染待办任务、添加任务、删除任务、底部合计和清空功能、持久化存储。也就是说,组件拆分不是终点,而是为了把后续功能分别放进合适的位置,降低一个文件里模板、样式、逻辑互相纠缠的成本。
为什么需要:转写稿里用“小兔鲜”页面举例,新鲜好物区域里每个商品结构相似,如果全部写在一个页面中,结构长、样式多,复用和修改都困难。小黑记事本同理,头部、列表、底部职责不同,应先拆开。
怎么想:先看原页面结构,找“相对独立、职责清晰、可能复用”的区域。笔记给出三块:头部 TodoHeader、列表 TodoMain、底部 TodoFooter。
怎么做:在 components 目录下创建三个组件文件,把原结构中对应模板剪切过去,再在父组件中导入、注册、使用。
易错点:不要为了拆而拆;拆完后每个组件仍要保证只有一个根元素,否则模板会报错。

拆分基础组件
为什么需要:转写稿演示拆商品组件时,先把一个 <li> 商品结构从原列表中剪切出来,放入独立组件;原位置先注释,观察页面是否变空,再逐步替换。这个调试过程说明:拆分要小步验证,不能一次性大改。
怎么想:把“头部、列表、底部”分别看成三个盒子。列表组件内部未来要渲染待办项,底部组件未来要放合计和清空按钮,头部组件负责输入区域。先完成结构迁移,不急着写全部业务。
怎么做:创建 TodoHeader.vue、TodoMain.vue、TodoFooter.vue;把原模板中对应区域剪切到各组件 <template> 中;把与该区域相关的样式一起迁移。转写稿特别提醒:商品组件根元素是 <li>,原样式里若带父级选择器,迁到子组件后要删掉不存在的父级部分,否则样式不生效。
易错点:样式迁移后要检查选择器层级;组件文件名、注册名、标签名要对应;不要在子组件里直接修改父组件传入的数据。
注册、使用与调试
为什么需要:组件写好后必须建立父子关系,才能在页面中显示。转写稿练习了全局注册:在入口文件导入组件,再注册,页面中即可用标签或 v-for 使用。
怎么想:先导入,再注册,最后在模板中使用。转写稿反复强调这三步:“先导入,不导入怎么注册,不注册怎么使用”。
怎么做:局部注册时在父组件中 import 后写入 components;全局注册时在入口文件中注册。列表类结构可用 v-for 循环生成多个组件。
易错点:注册名和标签名不一致、单词拼错、忘记导入,都会导致组件不显示。转写稿还提到 data 必须写成函数并返回对象,保证每个组件实例维护独立数据;若写成对象,多个实例会共享引用数据,互相影响。
样式作用域与通信预留
为什么需要:转写稿用两个组件都给 p 标签写颜色,结果样式冲突,说明多个组件最终会组合到同一页面,普通样式可能互相覆盖。
怎么想:给组件样式加 scoped,让样式只作用于当前组件。其原理是 Vue 会给当前组件元素加自定义属性,并把选择器处理成属性选择器,从而避免影响其他组件。
怎么做:在组件 <style scoped> 中写样式。后续父子通信时,父组件通过自定义属性传数据,子组件用 props 接收;子组件要改数据时不能直接改 props,应通过 $emit 通知父组件,由父组件修改自己的数据,这符合单向数据流。
易错点:props 是外部数据,只读;事件名父子两端要一致;$emit 第一个参数是自定义事件名,后面才是传递的数据。完成拆分后,再按需求逐项实现渲染、添加、删除、合计清空和持久化。
一、先明确目标:列表渲染不是只写 v-for
为什么需要:综合案例里最容易出现的问题是“数据和组件各写各的”。如果数据散落在子组件里,父组件就无法统一管理,列表项也无法复用。所以笔记第一步先规定数据来源:数据放在公共父组件 App.vue 中。
怎么想:先建立父子关系,再谈传值。转写稿里反复强调:组件必须先“导入、注册、使用”,有了父子关系,父传子才有意义。这里 App.vue 是父组件,TodoMain 是子组件;App.vue 持有列表数据,TodoMain 负责展示。
怎么做:在 App.vue 的 data 中准备列表。注意转写稿中专门纠正过:脚手架项目里 data 必须写成函数,函数返回对象,保证每个组件实例维护独立数据。
vue
<!-- App.vue -->
<template>
<div>
<TodoMain :list="list" />
</div>
</template>
<script>
import TodoMain from './components/TodoMain.vue'
export default {
components: { TodoMain },
data() {
return {
list: [
{ id: 1, text: '学习组件注册' },
{ id: 2, text: '练习父传子' },
{ id: 3, text: '完成列表渲染' }
]
}
}
}
</script>易错点:data 写成对象会报错;TodoMain 忘记导入或注册,页面会提示组件未使用;组件名和文件名拼写要一致,转写稿里提到的 my-test、TodoMain 这类名字不要写错。
二、父传子:把列表交给 TodoMain
为什么需要:组件的数据是独立的,子组件不能直接访问父组件 data 里的 list。所以必须通过父传子把数据交过去。
怎么想:父组件通过自定义属性传值,子组件通过 props 接收。转写稿中的关键句是:父组件写自定义属性,子组件用 props 接收,属性名和接收名必须一致。比如父组件写 :list="list",子组件就要接收 list。
怎么做:在 TodoMain.vue 中声明 props。
vue
<!-- TodoMain.vue -->
<template>
<ul>
<li v-for="item in list" :key="item.id">
{{ item.text }}
</li>
</ul>
</template>
<script>
export default {
props: ['list']
}
</script>调试时先看父组件是否真的把数据传下来了:打开 Vue 调试工具,选中 TodoMain,查看 props 中是否出现 list。如果页面空白,按转写稿的顺序排查:有没有导入、有没有注册、有没有在模板中使用、props 名字是否和父组件属性名一致。
易错点:props 接收的是外部数据,不能在子组件里直接修改,这涉及单向数据流;父组件传对象或数组时,子组件拿到的是引用,仍应遵守“谁的数据谁负责”。
三、v-for:在 TodoMain 内循环渲染
为什么需要:列表项数量不是固定的,不能手写多个 <li>。用 v-for 可以根据数组长度自动生成结构。
怎么想:笔记写的是“通过父传子,将数据传递给 TodoMain;利用 v-for 进行渲染”。因此本案例把整个 list 传给 TodoMain,在 TodoMain 内部循环。转写稿里也演示过 v-for="item in list" 的写法,并提醒加 :key。
怎么做:上面代码中的 v-for="item in list" 就是核心;:key="item.id" 用来给每项稳定标识。若字段不同,把 item.text 换成实际字段即可。
易错点:v-for 要写在真正重复的元素上,比如 <li>;key 要唯一且稳定,优先用 id,不要随便用会变化的字段;插值表达式要写 {{ item.text }},不要漏花括号。转写稿中常被听错的 props、goodsList、item,实际代码里要按英文拼写写准。
四、综合调试:按顺序串起来
为什么需要:综合案例不是单个知识点,顺序一乱就会“数据有了但页面没有”。转写稿梳理过完整链路:先导入、注册、使用,建立父子关系;再写自定义属性传值;子组件用 props 接收;最后在模板中渲染。
怎么做:按清单检查:1)App.vue 的 data 是否返回对象;2)TodoMain 是否导入注册;3)父模板是否写 :list="list";4)子组件 props 是否接收 list;5)子模板是否写 v-for 和 :key。若样式互相影响,可给组件 <style scoped>,这是转写稿中讲的样式隔离办法,但不是列表渲染的必要条件。
易错点:不要跳过“导入注册使用”直接传值;props 名称和父组件属性名不一致会接收不到;v-for 忘记 :key 会有警告;子组件不要直接改 props,需要修改时应通知父组件处理。
先明确目标:添加功能要串起表单、事件、子传父
这一节要做的不是单独写一个输入框,而是把“收集任务名称 → 触发添加 → 子组件通知父组件 → 父组件更新列表”整条链路串起来。笔记里的四步就是主线:
- 收集表单数据
v-model - 监听事件(回车 + 点击都要进行添加)
- 子传父,将任务名称传递给父组件
App.vue - 父组件接受到数据后 进行添加 unshift(自己的数据自己负责)
为什么需要这样拆? 因为任务列表数据应该放在父组件 App.vue 里,子组件只负责输入和触发添加。转写稿里讲子传父时反复强调“谁的数据谁负责”:子组件通过 props 接收到的数据不能直接改,要通知父组件,让父组件改自己的数据。添加功能同理,子组件不要直接操作父组件的任务数组。
怎么想? 先画数据流:输入框用 v-model 拿到任务名称;用户按回车或点按钮时,子组件执行同一个添加逻辑;子组件用 $emit 把任务名称传出去;父组件在子组件标签上监听自定义事件,方法形参接收任务名称,再 unshift 到自己的数组中。
子组件:v-model 收集数据,回车和点击共用一个添加方法
为什么需要 v-model? 如果不写 v-model,就要手动监听 input 事件再取 event.target.value,代码更啰嗦。v-model 让输入框和组件内的数据保持同步,添加时直接读这个数据即可。
怎么做? 子组件里准备一个数据,例如 taskName,输入框绑定它;按钮点击和回车都调用 addTask。回车用 @keyup.enter,点击用 @click。注意按钮在表单里时要写 type="button",避免触发表单默认提交导致刷新。
vue
<template>
<div class="todo-header">
<input
type="text"
v-model="taskName"
placeholder="请输入任务名称"
@keyup.enter="addTask"
/>
<button type="button" @click="addTask">添加</button>
</div>
</template>
<script>
export default {
data() {
return {
taskName: ''
}
},
methods: {
addTask() {
const name = this.taskName.trim()
if (!name) return
this.$emit('add-task', name)
this.taskName = ''
}
}
}
</script>易错点: v-model 不要写成 :v-model;回车事件不是 @keyup.enter 写错成 @keyup.enter 以外的事件名;空字符串要拦截;添加成功后要清空输入框;$emit 的事件名要和父组件监听名一致。转写稿里讲子传父时特别提醒:事件名是自定义事件名,父组件监听时必须一致,否则父组件方法不会触发。
父组件:监听自定义事件,用 unshift 添加
为什么需要父组件添加? 因为列表数据在父组件。子组件只把“要添加的名称”交出去,父组件收到后改自己的数组,这才符合单向数据流。转写稿里“砍一刀”的例子也是先 $emit 传 ID,父组件方法接收 ID,再修改父组件自己的数据。
怎么做? 父组件引入、注册、使用子组件,并在标签上监听 add-task。方法里接收名称,直接 unshift 到任务数组。
vue
<template>
<div class="app">
<TodoHeader @add-task="handleAddTask" />
<ul>
<li v-for="item in taskList" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template>
<script>
import TodoHeader from './components/TodoHeader.vue'
export default {
components: { TodoHeader },
data() {
return {
taskList: [
{ id: 1, name: '学习 Vue' }
]
}
},
methods: {
handleAddTask(name) {
this.taskList.unshift({
id: Date.now(),
name
})
}
}
}
</script>易错点: 父组件监听名要和 $emit 第一个参数完全一致;方法形参就是 $emit 第二个参数;unshift 是加到数组前面,不是 push;id 要保证不重复,示例用 Date.now() 只是常见做法;不要子组件里直接 this.$parent.taskList.unshift(...),这违背“自己的数据自己负责”。
调试顺序:先确认通信,再写添加逻辑
转写稿里的调试思路可以照搬:先不要急着写完整业务,先在子组件 $emit,父组件方法里 console.log(name),确认点击和回车都能把任务名称传过来;确认后再写 unshift。如果没反应,按顺序查:子组件事件有没有触发、$emit 事件名和父组件监听名是否一致、父组件方法有没有写、v-model 是否绑对。这样比一上来盯着列表为什么不变更高效。
本节综合案例的目标是把“删除”这条数据流走通:删除按钮在子组件里,数据却在父组件 App.vue 里,所以不能子组件直接改父组件数据,而要按照“子传父”的语法把要删除的 id 交给父组件,再由父组件用 filter 删除。笔记里的“监听时间”应纠正为“监听删除的点击事件”;“子传复”应纠正为“子传父”。
整体思路:为什么删除要走子传父
为什么需要: 商品数据 goodsList 放在父组件 App.vue 的 data 中,子组件只是通过 props 拿到某一条商品数据。转写稿反复强调“谁的数据谁负责”,子组件通过 props 接收的是外部数据,不能直接改;删除要改的是父组件里的数组,所以必须通知父组件。
怎么想: 删除按钮写在子组件里,点击时子组件知道当前商品的 id;父组件掌握数组,负责真正删除。于是流程是:子组件点击删除 → 携带 id 触发自定义事件 → 父组件监听事件并接收 id → 父组件 filter 删除。
怎么做: 子组件用 $emit('delete', id);父组件在标签上写 @delete="delGoods",方法形参接收 id,再用 filter 生成新数组并赋值回 goodsList。
易错点: 不要子组件直接 this.obj 改数据;不要忘了传 id;父组件监听的事件名必须和 $emit 的第一个参数一致。
子组件:监听删除点击并携带 id
为什么需要: 子组件只负责展示一条商品,但删除动作需要知道“删哪一条”,所以点击事件里要把当前商品的 id 带出去。
怎么想: 删除按钮绑定点击事件,方法里不要直接删数组,而是调用 this.$emit。转写稿讲子传父核心语法时明确:$emit 第一个参数是自定义事件名,后面是传的数据;删除场景传 id 最合适。
怎么做:
vue
<template>
<div class="goods">
<h3>{{ obj.name }}</h3>
<p>单价:{{ obj.price }} 元</p>
<p>描述:{{ obj.info }}</p>
<button @click="del(obj.id)">删除</button>
</div>
</template>
<script>
export default {
props: ['obj'],
methods: {
del(id) {
this.$emit('delete', id)
}
}
}
</script>易错点: props 不要写成 probs;$emit 不要拼错;事件名 delete 要和父组件监听名一致;不要写成 this.$emit('delete') 却不传 id。
父组件:接收 id 并用 filter 删除
为什么需要: 真正的数据源在 App.vue,只有父组件能修改 goodsList。子组件把 id 传上来后,父组件才能按 id 找到并删除对应数据。
怎么想: 父组件用 v-for 渲染多个子组件,每个子组件通过 :obj="item" 拿到一条数据;监听 @delete="delGoods"。方法里先接收 id,再用 filter 保留“不等于这个 id”的项。注意 filter 返回新数组,必须赋值回 this.goodsList。
怎么做:
vue
<template>
<div id="app">
<my-test
v-for="item in goodsList"
:key="item.id"
:obj="item"
@delete="delGoods"
/>
</div>
</template>
<script>
import MyTest from './components/MyTest.vue'
export default {
components: { MyTest },
data() {
return {
goodsList: [
{ id: 1, name: '方便面', price: 2.5, info: '好吃不贵' },
{ id: 2, name: '火腿肠', price: 2, info: '价钱合理' },
{ id: 3, name: '卤鸡蛋', price: 1.5, info: '我的最爱' }
]
}
},
methods: {
delGoods(id) {
console.log(id)
this.goodsList = this.goodsList.filter(item => item.id !== id)
}
}
}
</script>易错点: this.goodsList.filter(...) 不赋值不会生效;item.id !== id 中变量名不要写混;data 必须写成函数并返回对象,保证组件实例数据独立;父组件方法名要和 @delete 后面一致。
调试过程与关键检查
为什么需要: 子传父第一次写容易卡在“事件有没有触发、父组件有没有收到 id”。转写稿里的调试办法是先输出接收到的数据,再写后续逻辑;删除功能也一样,先在 delGoods(id) 里 console.log(id),点删除按钮看控制台是否打印当前商品 id,确认通了再写 filter。
怎么想: 如果控制台没有 id,先查子组件 $emit 是否执行、事件名是否一致;如果 id 有了但页面没删,查 filter 是否赋值回 goodsList。
怎么做: 按“子组件 $emit → 父组件 @delete → 方法形参接收 → filter 赋值”的顺序逐段检查。
易错点: 事件名大小写或拼写不一致、props 名不一致、在子组件里直接改 props、忘记 return 数据对象,都会导致删除功能失败。核心原则仍是:自己的数据自己负责,子组件通知,父组件删除。
先理清组件关系:底部组件是父组件的子组件
本节综合案例要在商品列表页底部做一个“合计 + 清空”的功能,并把列表数据持久化到本地。动手前先明确组件关系:父组件持有 list 数据,底部组件负责展示合计和提供清空按钮。底部组件要成为父组件的子组件,仍然遵循转写稿里反复强调的三步:导入、注册、使用。只有建立了父子关系,才谈得上父传子和子传父。
为什么需要:合计逻辑和清空按钮放在底部组件里,结构更清晰,也方便复用;但数据源仍在父组件,底部组件不能直接改父组件的数据。
怎么想:父组件把 list 通过自定义属性传给底部组件;底部组件用 props 接收后计算合计。清空按钮在底部组件里,但清空动作要通知父组件完成。
怎么做:父组件中写 <Footer :list="list" @clear="clearList" />,底部组件中写 props: ['list']。
易错点:属性名和 props 名必须一致;不要试图在子组件里直接修改 props,这违反单向数据流。转写稿里“砍一刀”案例已经演示过:子组件拿到的是外部数据,不能直接改,只能 $emit 通知父组件。
底部合计:父传子展示合计
为什么需要:合计不是写死的,而是根据父组件传入的 list 动态计算。把计算逻辑放在底部组件,父组件只负责提供数据,职责更单一。
怎么想:底部组件收到 list 后,用 computed 遍历数组,累加价格或数量。父组件传的是数组引用,列表变化时子组件会自动重新计算。
怎么做:
js
// 底部组件
export default {
props: ['list'],
computed: {
total() {
return this.list.reduce((sum, item) => sum + item.price, 0)
}
}
}模板中展示 {{ total.toFixed(2) }}。父组件传值:<Footer :list="list" />。
易错点:computed 里要 return;list 初始值要是数组,避免 reduce 报错;金额展示可用 toFixed(2)。调试时先在子组件 console.log(this.list),确认父组件确实传过来了。转写稿里父传子调试也是先看子组件 props 是否拿到数据,再在页面展示。
清空功能:子传父通知父组件清空
为什么需要:数据在父组件,底部组件没有权限直接改 props。如果子组件直接写 this.list = [],既违反单向数据流,也会报错。正确做法是“谁的数据谁负责”。
怎么想:底部组件点击清空按钮时,用 $emit 触发一个自定义事件;父组件监听这个事件,在父组件方法里把 list 清空。父组件数据一变,子组件收到的 props 自动更新,合计也会变成 0。
怎么做:
js
// 底部组件
methods: {
handleClear() {
this.$emit('clear')
}
}html
<!-- 父组件 -->
<Footer :list="list" @clear="clearList" />js
// 父组件
methods: {
clearList() {
this.list = []
}
}易错点:$emit 的事件名要和父组件 @clear 一致;不要写成子组件直接调用父组件方法;事件名拼写要统一。调试时先在 handleClear 里 console.log('clear'),确认点击能触发,再看父组件 clearList 是否执行。
持久化存储:watch 监听数据变化写入本地
为什么需要:刷新页面后 list 会回到初始值,之前增删改的数据丢失。把数据存到 localStorage,刷新后还能恢复。
怎么想:用 watch 监听 list 的变化,每次变化就序列化写入本地;初始化时再从本地读回。清空后也要写入空数组,否则刷新又会恢复旧数据。
怎么做:
js
watch: {
list: {
handler(newVal) {
localStorage.setItem('list', JSON.stringify(newVal))
},
deep: true
}
}初始化读取:
js
data() {
return {
list: JSON.parse(localStorage.getItem('list')) || []
}
}易错点:对象/数组要 JSON.stringify;读取时要做空值判断,避免 JSON.parse(null) 得到 null;watch 要加 deep: true 才能监听数组内部变化;清空后也要触发持久化。
联调顺序与调试建议
建议按“父传子看合计 → 子传父看清空 → watch 看本地存储”的顺序联调。先确认底部组件能收到 list 并算出合计;再点清空,看父组件 list 是否变为空、合计是否归零;最后刷新页面,看 localStorage 是否能恢复数据。转写稿里父传子、子传父的调试思路同样适用:先打印数据,再改逻辑,不要一上来就写完整业务。
课件图示








代码示例
示例一:props 与 data、单向数据流、子传父(砍一刀)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>props & data / 单向数据流 / 子传父</title>
<script src="https://unpkg.com/vue@2/dist/vue.js"></script>
<style>
.goods {
width: 260px;
margin: 10px;
padding: 10px;
border: 1px solid #000;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="app">
<!-- 父组件:循环渲染商品,把每个 item 通过自定义属性 obj 传给子组件 -->
<goods
v-for="item in goodsList"
:key="item.id"
:obj="item"
@kan-yidao="kanPrice"
></goods>
</div>
<script>
// ===== 子组件:商品 =====
Vue.component('goods', {
template: `
<div class="goods">
<h2>{{ obj.name }}</h2>
<p>单价:{{ obj.price }} 元</p>
<p>{{ obj.info }}</p>
<button @click="kan">砍一刀</button>
<hr />
<!-- 自己的数据:随便改 -->
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
`,
// 1. 外部传进来的数据:props,不能直接改
props: {
obj: {
type: Object,
required: true
}
},
// 2. 自己的数据:data 必须是函数,保证每个组件实例独立一份
data () {
return {
count: 100
}
},
methods: {
kan () {
// ❌ 错误写法:this.obj.price-- (props 是外部的,不能直接改)
// ✅ 正确写法:子传父,通知父组件,让父组件去改它自己的数据
this.$emit('kan-yidao', this.obj.id)
}
}
})
// ===== 父组件:App =====
new Vue({
el: '#app',
data () {
return {
goodsList: [
{ id: 1, name: '方便面', price: 2.5, info: '好吃不贵,老少皆宜' },
{ id: 2, name: '火腿肠', price: 2, info: '泡面拌火腿' },
{ id: 3, name: '卤鸡蛋', price: 1.5, info: '我的最爱' }
]
}
},
methods: {
// 子组件 $emit 传过来的 id 会作为这个方法的形参
kanPrice (id) {
const item = this.goodsList.find(it => it.id === id)
if (item) {
item.price = +(item.price - 0.5).toFixed(2)
}
}
}
})
</script>
</body>
</html>关键行与易错点
data () { return { count: 100 } }:data必须是函数并返回对象。写成对象时,多个组件实例会共享同一份数据,Vue 也会直接报错。props: { obj: {...} }:props是外部数据,只能读不能改。口诀:谁的数据谁负责。- 父传子:父组件写自定义属性
:obj="item",子组件用props里的同名obj接收,两边名字必须完全一致,否则接收不到(注意 kebab-case 与 camelCase 的写法)。 - 子传父:子组件
this.$emit('kan-yidao', this.obj.id)触发事件 → 父组件标签上@kan-yidao="kanPrice"监听 → 事件名两边必须一致;$emit第二个参数之后是传给父组件的数据,会进入父组件方法的形参。 v-for一定要加:key,这里用唯一id。- 单向数据流:父组件改了
goodsList里的price,子组件展示会自动更新;反过来子组件改props会报警告,所以砍价要绕一圈交给父组件做。
示例二:综合案例——小黑记事本(拆分 / 列表 / 添加 / 删除 / 合计 / 清空 / 持久化)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>小黑记事本</title>
<script src="https://unpkg.com/vue@2/dist/vue.js"></script>
<style>
.todoapp { width: 420px; margin: 20px auto; font-family: sans-serif; }
.header { text-align: center; }
.new-todo { width: 260px; padding: 6px; }
.todo-list { list-style: none; padding: 0; }
.todo-list li {
display: flex;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid #ddd;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
</style>
</head>
<body>
<div id="app">
<section class="todoapp">
<!-- 子传父:添加 -->
<todo-header @add="addTodo"></todo-header>
<!-- 父传子:list ;子传父:删除 -->
<todo-main :list="list" @del="delTodo"></todo-main>
<!-- 父传子:list ;子传父:清空 -->
<todo-footer :list="list" @clear="clearTodo"></todo-footer>
</section>
</div>
<script>
// ===== 头部:收集任务名,子传父交给 App =====
Vue.component('todo-header', {
template: `
<header class="header">
<h1>小黑记事本</h1>
<input
class="new-todo"
placeholder="请输入任务"
v-model.trim="name"
@keyup.enter="add"
/>
<button @click="add">添加</button>
</header>
`,
data () {
return { name: '' }
},
methods: {
add () {
if (!this.name) return
this.$emit('add', this.name) // 子传父
this.name = '' // 自己的数据自己清空
}
}
})
// ===== 列表:父传子拿 list,v-for 渲染;删除走子传父 =====
Vue.component('todo-main', {
template: `
<section class="main">
<ul class="todo-list">
<li v-for="item in list" :key="item.id">
<span>{{ item.name }}</span>
<button @click="$emit('del', item.id)">删除</button>
</li>
</ul>
</section>
`,
props: {
list: {
type: Array,
default: () => []
}
}
})
// ===== 底部:父传子算合计;清空走子传父 =====
Vue.component('todo-footer', {
template: `
<footer class="footer">
<span>合计:{{ list.length }} 条</span>
<button @click="$emit('clear')">清空</button>
</footer>
`,
props: {
list: {
type: Array,
default: () => []
}
}
})
// ===== 父组件 App:所有数据都放在这里 =====
new Vue({
el: '#app',
data () {
return {
// 持久化:先从本地读,读不到用默认数据
list: JSON.parse(localStorage.getItem('todo-list')) || [
{ id: 1, name: '吃饭' },
{ id: 2, name: '睡觉' },
{ id: 3, name: '打豆豆' }
]
}
},
methods: {
addTodo (name) {
// 新增用 unshift,放到最前面
this.list.unshift({ id: Date.now(), name })
},
delTodo (id) {
// 删除用 filter,返回新数组
this.list = this.list.filter(item => item.id !== id)
},
clearTodo () {
this.list = []
}
},
watch: {
// 持久化:数据一变就写回本地
list: {
deep: true,
handler (val) {
localStorage.setItem('todo-list', JSON.stringify(val))
}
}
}
})
</script>
</body>
</html>关键行与易错点
- 数据统一放在父组件
App的data里,子组件通过props接收,不要在每个子组件里各存一份,否则数据不同步。 - 添加:
v-model.trim收集表单值,回车@keyup.enter和按钮点击都要能添加;子组件$emit('add', this.name),父组件unshift新增。 - 删除:子组件
$emit('del', item.id)带上 id,父组件用filter生成新数组;filter返回新数组,所以要this.list = this.list.filter(...)重新赋值。 - 合计:直接把
list传给todo-footer,用list.length展示,不需要额外传一个数字。 - 清空:子组件只负责通知
$emit('clear'),真正清空由父组件做(谁的数据谁负责)。 - 持久化:
watch监听list,必须加deep: true,否则只改数组里某个对象的属性不会触发;写入用JSON.stringify,读取用JSON.parse。 - 易错点:
$emit的事件名与父组件@事件名必须一致;v-for必须加:key;props的default若是数组/对象,必须写成函数() => []。
示例三:样式冲突与 scoped 原理
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>样式冲突 & scoped</title>
<script src="https://unpkg.com/vue@2/dist/vue.js"></script>
<style>
/* 组件 A 的样式 */
p { color: red; }
/* 组件 B 的样式 */
p { color: blue; }
</style>
</head>
<body>
<div id="app">
<h2>APP 组件</h2>
<p>我是 APP 组件里的 p</p>
<hr />
<my-test></my-test>
</div>
<script>
Vue.component('my-test', {
template: `
<div>
<h3>my-test 组件</h3>
<p>我是 my-test 组件里的 p</p>
</div>
`
})
new Vue({ el: '#app' })
</script>
</body>
</html>关键行与易错点
- 两个组件的模板最终都会拼到同一个页面里,两个
p选择器也都在同一个样式表里,后写的color: blue覆盖了前面的红色,所以两个p都变蓝——这就是样式冲突。 - 解决原理:Vue 会给当前组件的每个元素加一个自定义属性(形如
data-v-xxxxxx),同时把 CSS 选择器改写成属性选择器p[data-v-xxxxxx],这样p样式就只会命中本组件的元素,不会影响别的组件。 - 实际开发不用手写属性,只需要在单文件组件里写
<style scoped>,Vue 会自动完成上面那套处理:
vue
<!-- BaseCount.vue -->
<template>
<div class="base-count">
<button @click="count--">-</button>
<span>{{ count }}</span>
<button @click="count++">+</button>
</div>
</template>
<script>
export default {
data () {
return { count: 100 }
}
}
</script>
<style scoped>
.base-count { margin: 20px; }
</style>- 易错点:
scoped只对当前组件的模板元素生效;不加scoped的公共样式仍会全局生效,容易互相污染。面试常问“scoped 的原理”,答案就是给元素加自定义属性 + CSS 属性选择器。
常见错误
- 现象:组件页面不显示或提示未知组件 → 原因: 全局注册位置/名称写错,或局部注册后模板标签名与注册名不一致 → 修法: 全局注册放
main.js,注册名与模板标签逐字一致;局部注册检查components与模板。 - 现象:两个组件样式互相覆盖 → 原因:
style默认全局,未加scoped,标签选择器过宽 → 修法: 组件样式加scoped,避免无约束的p {}、div {}等选择器。 - 现象:组件 data 报错或多实例数据联动 → 原因:
data写成对象,多个实例共享同一引用 → 修法: 统一写data() { return { ... } }。 - 现象:父传子子组件拿不到值 → 原因: 父模板属性名与子组件
props名不一致,或动态值漏写冒号 → 修法: 属性名与props名保持一致,传变量/表达式用:propName="value"。 - 现象:子组件修改 props 出现警告/报错 → 原因: 直接改外部数据,违反单向数据流 → 修法: 子组件
$emit通知父组件,由父组件修改源数据。 - 现象:子传父父组件方法不触发 → 原因:
$emit事件名与父组件监听名不一致,或父方法未写在methods→ 修法: 事件名复制保持一致,父组件监听绑定到methods中的方法。 - 现象:列表渲染错乱或更新异常 → 原因:
v-for缺唯一key,或传参传错层级 → 修法: 使用唯一id作为:key,循环中传当前item。 - 现象:持久化不生效或刷新丢失 → 原因:
watch未深度监听,或初始化未读取本地数据 → 修法: 监听列表变化并deep: true,初始化时从localStorage读取。
练习
- 将
BaseCount改为:父组件持有count,子组件通过props接收,点击加减时子组件$emit,父组件修改数据,验证单向数据流。 - 拆分
TodoHeader、TodoMain、TodoFooter,完成列表渲染、添加、删除、底部合计、清空和localStorage持久化。 - 抽离商品卡片组件并全局注册,在“新鲜好物”和“热门品牌”中复用;父传子传
item,子传父传id实现“砍一刀”改价。 - 故意制造两个组件
p标签样式冲突,再用scoped修复,并观察生成的自定义属性选择器。 - 同一组件渲染三次,分别加减各自计数,验证
data函数保证实例数据独立。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 案例-小兔仙组件拆分-注册全局组件 | 08:25 |
| 2 | scoped解决样式冲突 | 17:01 |
| 3 | data必须是函数 | 06:44 |
| 4 | 组件通信-父传子 | 21:49 |
| 5 | 梳理前面的知识点(画图) | 07:41 |
| 6 | 组件通信-子传父 | 20:01 |
小结
data是组件自己的数据,props是外部传入的数据,修改源头要找父组件。- 父子通信的前提是先建立父子关系:导入、注册、使用缺一不可。
- 父传子靠自定义属性 +
props接收,名称必须严格对应。 - 子传父靠
$emit触发自定义事件,父组件监听并处理。 - 组件样式默认全局,
scoped是解决样式冲突的常规手段。 - 组件
data必须用函数返回新对象,保证每个实例独立。 - 综合案例的主线是:父组件持有列表,子组件负责展示与通知,父组件负责增删清空,监听变化做持久化。
- 排错优先查三件事:组件关系是否建立、命名是否一致、数据流向是否逆改。