Skip to content

组件通信与进阶用法(二) ​

本页对应 组件通信与进阶用法,共 6 段视频,约 1 小时 21 分。

学习目标 ​

  1. 能完成组件拆分并将商品项抽离为独立组件,在入口文件中通过 Vue.component 注册为全局组件,并在页面中多次复用或配合 v-for 渲染。
  2. 能解释并演示 scoped 解决样式冲突的原理,正确为组件 style 添加 scoped,验证样式仅作用于当前组件。
  3. 能说明组件 data 必须写成函数的原因,并编写返回独立数据对象的 data 函数,验证多个组件实例的数据互不影响。
  4. 能使用 props 完成父传子通信:父组件通过自定义属性传递数据,子组件声明 props 接收并在模板中使用,确保属性名一致。
  5. 能使用 $emit 完成子传父通信:子组件触发自定义事件并携带数据,父组件监听事件并在方法中接收处理,同时能解释单向数据流原则。
  6. 能按“导入—注册—使用”和“父子关系—父传子—子传父”的流程排查组件错误,并在综合案例中完成列表渲染、添加、删除、底部统计及持久化存储功能。

知识点 ​

先分清: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>

BaseCount 组件运行效果

这段代码里,props 被注释掉了,说明当前演示的是“自己的数据随便改”。如果取消注释、改成由父级传 count,那么子组件里的 count--、count++ 就不能再直接修改 props 了。转写稿还补充了一个相关易错点:组件里的 data 要写成函数并返回对象,这样每次创建组件实例都会得到独立的数据对象,多个同名组件互不影响;不能写成普通对象共享一份引用数据。

口诀与检查清单 ​

本节口诀是:谁的数据谁负责。

写代码前可以按这个顺序检查:

  1. 这份数据定义在哪个组件?定义方就是数据负责人。
  2. 子组件拿到的是 data 还是 props?data 可改,props 只读。
  3. 要改数据时,改的是源头还是接收方?应改源头,让数据按单向数据流向下更新。
  4. 父级传值和子组件 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,不要随便用会变化的字段;插值表达式要写 &#123;&#123; item.text &#125;&#125;,不要漏花括号。转写稿中常被听错的 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,需要修改时应通知父组件处理。

先明确目标:添加功能要串起表单、事件、子传父 ​

这一节要做的不是单独写一个输入框,而是把“收集任务名称 → 触发添加 → 子组件通知父组件 → 父组件更新列表”整条链路串起来。笔记里的四步就是主线:

  1. 收集表单数据 v-model
  2. 监听事件(回车 + 点击都要进行添加)
  3. 子传父,将任务名称传递给父组件 App.vue
  4. 父组件接受到数据后 进行添加 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)
    }
  }
}

模板中展示 &#123;&#123; total.toFixed(2) &#125;&#125;。父组件传值:<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 是否能恢复数据。转写稿里父传子、子传父的调试思路同样适用:先打印数据,再改逻辑,不要一上来就写完整业务。

课件图示 ​

课件第19页:prop & data、单向数据流 共同点:都可以给组件提供数据。 区别: d

课件第21页:需求说明: ①拆分基础组件 ②渲染待办任务 ③添加任务 ④删除任务 ⑤底部合计和

课件第22页:总结 小结 核心步骤: ①拆分基础组件 新建组件→ 拆分存放结构→ 导入注册使用

课件第16页:总结 总结 1. 两种组件关系分类和对应的组件通信方案 父子关系 → props

课件第6页:组件的样式冲突scoped 默认情况:写在组件中的样式会全局生效→ 因此很容易造

课件第12页:props 和$emit 组件通信解决方案: 父子关系 非父子关系 1. pro

课件第13页:父子通信流程图: 父组件 子组件 子组件 data数据 props props

课件第14页:父→ 子 父组件通过props 将数据传递给子组件

代码示例 ​

示例一: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 属性选择器。

常见错误 ​

  1. 现象:组件页面不显示或提示未知组件 → 原因: 全局注册位置/名称写错,或局部注册后模板标签名与注册名不一致 → 修法: 全局注册放 main.js,注册名与模板标签逐字一致;局部注册检查 components 与模板。
  2. 现象:两个组件样式互相覆盖 → 原因: style 默认全局,未加 scoped,标签选择器过宽 → 修法: 组件样式加 scoped,避免无约束的 p {}、div {} 等选择器。
  3. 现象:组件 data 报错或多实例数据联动 → 原因: data 写成对象,多个实例共享同一引用 → 修法: 统一写 data() { return { ... } }。
  4. 现象:父传子子组件拿不到值 → 原因: 父模板属性名与子组件 props 名不一致,或动态值漏写冒号 → 修法: 属性名与 props 名保持一致,传变量/表达式用 :propName="value"。
  5. 现象:子组件修改 props 出现警告/报错 → 原因: 直接改外部数据,违反单向数据流 → 修法: 子组件 $emit 通知父组件,由父组件修改源数据。
  6. 现象:子传父父组件方法不触发 → 原因: $emit 事件名与父组件监听名不一致,或父方法未写在 methods → 修法: 事件名复制保持一致,父组件监听绑定到 methods 中的方法。
  7. 现象:列表渲染错乱或更新异常 → 原因: v-for 缺唯一 key,或传参传错层级 → 修法: 使用唯一 id 作为 :key,循环中传当前 item。
  8. 现象:持久化不生效或刷新丢失 → 原因: watch 未深度监听,或初始化未读取本地数据 → 修法: 监听列表变化并 deep: true,初始化时从 localStorage 读取。

练习 ​

  1. 将 BaseCount 改为:父组件持有 count,子组件通过 props 接收,点击加减时子组件 $emit,父组件修改数据,验证单向数据流。
  2. 拆分 TodoHeader、TodoMain、TodoFooter,完成列表渲染、添加、删除、底部合计、清空和 localStorage 持久化。
  3. 抽离商品卡片组件并全局注册,在“新鲜好物”和“热门品牌”中复用;父传子传 item,子传父传 id 实现“砍一刀”改价。
  4. 故意制造两个组件 p 标签样式冲突,再用 scoped 修复,并观察生成的自定义属性选择器。
  5. 同一组件渲染三次,分别加减各自计数,验证 data 函数保证实例数据独立。

原视频对照 ​

#视频时长
1案例-小兔仙组件拆分-注册全局组件08:25
2scoped解决样式冲突17:01
3data必须是函数06:44
4组件通信-父传子21:49
5梳理前面的知识点(画图)07:41
6组件通信-子传父20:01

小结 ​

  1. data 是组件自己的数据,props 是外部传入的数据,修改源头要找父组件。
  2. 父子通信的前提是先建立父子关系:导入、注册、使用缺一不可。
  3. 父传子靠自定义属性 + props 接收,名称必须严格对应。
  4. 子传父靠 $emit 触发自定义事件,父组件监听并处理。
  5. 组件样式默认全局,scoped 是解决样式冲突的常规手段。
  6. 组件 data 必须用函数返回新对象,保证每个实例独立。
  7. 综合案例的主线是:父组件持有列表,子组件负责展示与通知,父组件负责增删清空,监听变化做持久化。
  8. 排错优先查三件事:组件关系是否建立、命名是否一致、数据流向是否逆改。