Skip to content

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

本页对应 组件通信与进阶用法,共 6 段视频,约 35 分钟。

学习目标 ​

  • 能够将记事本页面拆分为 ToDoHeader、ToDoMain、ToDoFooter 三个组件,并在 App 中完成导入、注册和使用,使页面结构与原功能正常渲染。
  • 能够使用 props 完成父传子数据渲染与底部统计,使用 $emit 完成子传父删除、添加、清空功能,并保证数据修改权保留在父组件 App 中。
  • 能够说明 v-model 的原理,完成表单类组件封装,并使用 v-model 简化父子组件间的双向绑定代码。
  • 能够使用 .sync 修饰符实现父子组件数据同步,并使用 provide / inject 完成非父子组件通信。
  • 能够使用 ref 和 $refs 获取 DOM 元素或组件实例,并完成相应操作。
  • 能够使用 watch 监听 list 变化,将数据持久化到 localStorage,并在初始化时读取本地数据恢复列表。

知识点 ​

作用与场景:为什么要跨层级共享数据 ​

为什么需要:在组件化开发里,数据通常放在祖先组件中,但真正使用数据的组件可能在很深的后代里。若只靠 props 一层层往下传,中间组件会变成“搬运工”,代码冗长且难维护。
怎么想:先判断“数据提供方”和“数据使用方”是不是直接父子。如果是直接父子,props 足够;如果跨了多层,就可以考虑 provide / inject。
怎么做:祖先组件用 provide 提供数据,任意层级的后代组件用 inject 注入数据。
易错点:不要把所有通信都改成 provide / inject;直接父子仍优先用 props / $emit,否则数据流向不直观。

本节场景是跨层级共享数据:

跨层级共享数据场景图

转写稿里的记事本案例可以帮我们理解这个需求:页面被拆成 TodoHeader、TodoMain、TodoFooter,list 数据放在 APP 中,TodoMain 通过 props 接收 list,删除、添加、清空都要通过 $emit 通知 APP 修改。这个案例反复强调“数据的修改权在 APP,子组件不能直接改”。当组件只有一层父子关系时,props / $emit 是清楚的;但如果 TodoFooter 里还有更深的后代也要拿 list,逐层传就会麻烦,这正是 provide / inject 的用武之地。注意:转写稿讲的是父子/子父通信案例,不是直接演示 provide / inject,这里是用它对照“跨层级”的必要性。

父组件 provide:提供数据 ​

为什么需要:祖先组件要把数据开放给后代,而不是只给直接子组件。
怎么想:provide 写在祖先组件中,用函数返回一个对象,键名就是后代 inject 时要用的名字。
怎么做:

js
export default {
  provide () {
    return {
      // 普通类型【非响应式】
      color: this.color, 
      // 复杂类型【响应式】
      userInfo: this.userInfo, 
    }
  }
}

易错点:provide 是函数,不要写成普通对象;键名要和后代 inject 一致;简单类型提供的是当前值,后续父组件变化不会自动同步给后代。

子/孙组件 inject:获取数据 ​

为什么需要:后代组件不想经过中间层,直接拿到祖先提供的数据。
怎么想:inject 用数组列出要注入的键名,注入后可以通过 this.xxx 使用。
怎么做:

js
export default {
  inject: ['color','userInfo'],
  created () {
    console.log(this.color, this.userInfo)
  }
}

调试时可以在 created 里打印 this.color 和 this.userInfo,确认注入名字是否写对、数据是否拿到。
易错点:inject 的键名必须和 provide 返回对象的键名完全一致;拼写错误时通常拿到 undefined;不要在子/孙组件里直接修改注入的数据。

响应式与修改限制:简单类型不响应,复杂类型响应 ​

为什么需要:很多人会误以为 provide 出去的数据都会自动响应,实际并非如此。
怎么想:笔记明确说:provide 提供的简单类型数据不是响应式的,复杂类型数据是响应式的,推荐提供复杂类型数据。
怎么做:需要共享且会变化的数据,优先放在复杂类型对象中提供,例如 userInfo: this.userInfo;后代通过 this.userInfo 使用。
易错点:不要把简单类型当成响应式数据依赖;子/孙组件通过 inject 获取的数据不能在自身组件内修改,数据的修改应回到提供方处理。

与记事本案例对照:数据修改权仍在提供方 ​

转写稿中删除功能有一段关键推导:删除按钮在 TodoMain 里,但 list 是 APP 的数据,TodoMain 只是接收者,不能直接改;所以要用子传父 $emit 通知 APP,由 APP 写方法修改数据。添加、清空也是同样思路:TodoHeader 把输入框值传给 APP,TodoFooter 清空也要通知 APP。这跟 provide / inject 的注意点一致:inject 拿到数据可以使用,但不要在后代里直接改。若确实要改,应让提供方修改,或由提供方提供修改方法。
易错点:把“拿到数据”误当成“拥有修改权”;在深层组件里直接改注入对象,会让数据流向混乱。

调试与易错点小结 ​

  1. 先检查 provide 是否写成函数并返回对象。
  2. 再检查 inject 数组里的名字与 provide 键名是否一致。
  3. 用 created 打印注入结果,确认拿到的是 undefined 还是真实数据。
  4. 修改简单类型后观察后代是否变化,理解“简单类型非响应式”。
  5. 后代只读使用,不在自身组件内直接修改注入数据。
  6. 直接父子通信仍优先 props / $emit,跨层级再考虑 provide / inject。

为什么需要 v-model ​

在表单开发里,最常见需求是:输入框里输入内容,Vue 中的数据要跟着变;数据被代码修改后,输入框也要跟着显示新值。如果每次都手写“给 value 赋值 + 监听 input 事件”,代码会重复,而且容易漏掉其中一半。v-model 就是为这种“双向绑定”场景准备的语法糖。

怎么想:不要先把 v-model 当成魔法,而要先把它还原成“属性绑定 + 事件监听”。在输入框上,它本质上就是 value 属性和 input 事件的合写。怎么做:写 v-model="msg" 时,Vue 会帮你完成两件事:用 :value="msg" 让数据流向视图,用 @input 把用户输入写回 msg。易错点:不要把 v-model 理解成“绑定了一个叫 model 的属性”,它只是 Vue 提供的简写。

本质:value 属性 + input 事件 ​

笔记里的核心例子是:

vue
<template>
  <div id="app" >
    <input v-model="msg" type="text">

    <input :value="msg" @input="msg = $event.target.value" type="text">
  </div>
</template>

为什么需要这样拆?因为只看 v-model 不容易理解双向绑定到底谁在改谁。怎么想:第一行是简写,第二行是展开写法。v-model="msg" 等价于“把 msg 放到输入框的 value 上,同时在输入时把输入框当前值赋回 msg”。怎么做:读代码时先找 :value,它负责“数据变,视图跟着变”;再找 @input,它负责“视图变,数据跟着变”。易错点:@input 里不能写 msg = $event,因为事件对象不是输入值;要写 $event.target.value,从事件对象的目标元素上取当前输入值。

$event 是什么 ​

笔记特别提醒:$event 用于在模板中获取事件的形参。为什么需要它?因为 @input 触发时,Vue 会把原生事件对象传进模板表达式,但模板里不能像 JS 函数那样随便声明形参,所以用 $event 接住它。怎么想:$event 不是固定等于输入框的值,它代表“这次事件的实参”。在 input 事件里,通常通过 $event.target.value 取输入框当前内容。怎么做:模板中写 @input="msg = $event.target.value";如果事件处理写成方法,则方法参数可以接收事件对象,再取 event.target.value。易错点:把 $event 直接赋给 msg,得到的是事件对象而不是字符串;另外事件名、变量名拼写错误也会导致不生效。

作用:双向绑定如何发生 ​

v-model 的作用是提供数据的双向绑定。笔记总结为两句话:数据变,视图跟着变,对应 :value;视图变,数据跟着变,对应 @input。为什么需要强调这两条?因为很多初学者只记住了“输入框能改数据”,却忽略了代码改数据时输入框也要更新。怎么想:把数据流拆成两条线看,一条从 data 到 input 的 value,一条从用户输入事件回到 data。怎么做:在调试时可以先改 data 看输入框是否更新,再在输入框输入看 data 是否更新,两条线都通才是真正的双向绑定。易错点:只写 :value 会出现“数据能显示,但输入不回去”;只写 @input 会出现“输入能改数据,但初始值/代码修改显示不出来”。

在记事本案例中的推导 ​

转写稿里添加功能提到:为了拿到输入框的值,需要给输入框做双向绑定,用一个变量接住输入内容;点击按钮时,子组件不能直接改父组件的 list,而要通过子传父通知父组件。这里能帮助我们理解 v-model 的使用位置:输入框负责收集用户输入,按钮事件负责把值交出去。转写稿中反复说的“紫唇户/自传富/子传富”应纠正为“子传父”;“实践名/世界名”应纠正为“事件名”;“dollar.emit/刀刃一MIT”应纠正为 $emit。在模板中可以直接写 $emit('add', todoName);如果写在 methods 方法里,则要写 this.$emit('add', todoName)。易错点:子组件不要直接修改父组件传入的 props;事件名要与父组件监听名一致;方法里漏写 this 会报错。

其他表单元素上的原理 ​

笔记指出:不同的表单元素,v-model 在底层的处理机制不一样。比如给 checkbox 使用 v-model,底层处理的是 checked 属性和 change 事件。为什么需要知道?因为 v-model 不是所有元素都统一等于 value + input,它会根据表单元素类型选择合适属性和事件。怎么想:文本框主要看 value 和 input;checkbox 看选中状态和变化事件。怎么做:现阶段按笔记要求,先掌握应用在文本框上的原理即可;遇到 checkbox 时知道它底层不是简单套 value + input。易错点:不要拿文本框的展开写法硬套到所有表单元素上;不同元素绑定的属性、事件不同,但“语法糖”的思想一致。

代码示例与练习 ​

笔记中的练习结构如下,保留代码用于观察两个输入框:

vue
<template>
  <div class="app">
    <input type="text"  />
    <br /> 
    <input type="text" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      msg1: '',
      msg2: '',
    }
  },
}
</script> 
<style>
</style>

为什么需要这个示例?它给出了两个输入框和两个数据 msg1、msg2,方便你分别练习 v-model 写法和展开写法。怎么想:可以给第一个输入框写 v-model="msg1",给第二个输入框写 :value="msg2" @input="msg2 = $event.target.value",然后观察两者行为是否一致。怎么做:输入内容时看 data 是否变化;在控制台或代码中修改 msg1/msg2,看输入框是否同步。易错点:data 必须返回对象;变量名要与模板一致;$event.target.value 不能漏掉 value;在方法中处理事件时注意 this 的使用。

易错点小结 ​

第一,v-model 是语法糖,本质要能还原成 :value 和 @input。第二,$event 是事件形参,输入框取值要写 $event.target.value。第三,双向绑定要同时验证“数据到视图”和“视图到数据”两条线。第四,子组件不能直接改父组件数据,添加、删除、清空等操作应通过子传父事件通知父组件。第五,模板中可直接写 $emit,methods 中要写 this.$emit,事件名和接收参数要对应。按这个思路练习,v-model 就不再是死记的指令,而是可以拆开、可以调试的绑定过程。

需求目标:让父组件的 selectId 和子组件选中值同步 ​

为什么需要:把下拉框封装成 BaseSelect 后,selectId 在 App.vue 里,选中状态却在子组件里。如果只在子组件内部写死选中值,父组件拿不到用户选择,表单提交、联动查询都没法做。

怎么想:父组件要把初始值传给子组件,用 props;子组件选中变化后要把新值传回父组件,用自定义事件 $emit;两者合起来就是 Vue2 的 v-model,默认对应 value 属性和 input 事件。

怎么做:先保留笔记中的原始结构,再逐步改造。

改造前 App.vue:

vue
<template>
  <div class="app">
    <BaseSelect></BaseSelect>
  </div>
</template>

<script>
import BaseSelect from './components/BaseSelect.vue'
export default {
  data() {
    return {
      selectId: '102',
    }
  },
  components: {
    BaseSelect,
  },
}
</script>

改造前 BaseSelect.vue:

vue
<template>
  <div>
    <select>
      <option value="101">北京</option>
      <option value="102">上海</option>
      <option value="103">武汉</option>
      <option value="104">广州</option>
      <option value="105">深圳</option>
    </select>
  </div>
</template>

<script>
export default {
}
</script>

易错点:此时 selectId 虽然写了 '102',但子组件没有接收,select 也没有绑定 value,页面不会按 '102' 选中上海。转写稿里反复强调“数据修改权在 APP,子组件只接收不能直接改”,这里同样适用:selectId 的修改权在父组件。

父传子:用 props 把 value 传进去 ​

为什么需要:子组件不知道父组件当前选的是哪个城市,必须由父组件把 selectId 传下来。

怎么想:v-model="selectId" 在 Vue2 中会向子组件传一个名为 value 的 prop。子组件声明 props: ['value'],模板里用 :value="value" 绑定给原生 select。

怎么做:App.vue 改为 <BaseSelect v-model="selectId"></BaseSelect>;BaseSelect.vue 中声明 props,并写 <select :value="value">。

易错点:不要写成 value="value",那只是字符串;要用 :value="value"。也不要在子组件里写 this.value = ... 直接改 prop,转写稿中“子组件不能直接改接收到的数据”就是这个意思。

子传父:change 时 $emit('input', 新值) ​

为什么需要:用户改选城市后,父组件的 selectId 必须更新,否则双向绑定只完成了一半。

怎么想:原生 select 的值变化触发 change,在事件里拿到 $event.target.value,再用 $emit('input', 新值) 通知父组件。Vue2 的 v-model 默认监听 input 事件。

怎么做:给 select 加 @change,可以模板内联写,也可以抽方法。转写稿提到“模板中直接写 $emit 不用 this,写到 JS 方法里就要 this.$emit”,这里两种写法都可。

易错点:事件名必须和 v-model 默认约定一致,Vue2 下是 input,不是随便写 change。自定义事件要加在真正传值的子组件 BaseSelect 上,不要加到无关组件上。$event.target.value 拿到的是字符串,和 option 的 value 类型保持一致。

完整改造代码 ​

App.vue:

vue
<template>
  <div class="app">
    <BaseSelect v-model="selectId"></BaseSelect>
  </div>
</template>

<script>
import BaseSelect from './components/BaseSelect.vue'
export default {
  data() {
    return {
      selectId: '102',
    }
  },
  components: {
    BaseSelect,
  },
}
</script>

BaseSelect.vue:

vue
<template>
  <div>
    <select :value="value" @change="handleChange">
      <option value="101">北京</option>
      <option value="102">上海</option>
      <option value="103">武汉</option>
      <option value="104">广州</option>
      <option value="105">深圳</option>
    </select>
  </div>
</template>

<script>
export default {
  props: {
    value: {
      type: String,
      default: '',
    },
  },
  methods: {
    handleChange(e) {
      this.$emit('input', e.target.value)
    },
  },
}
</script>

调试与易错点 ​

调试时先刷新页面,看 select 是否默认选中上海,因为父组件初始 selectId 是 '102'。再手动改选武汉,在父组件方法或 watch 中打印 selectId,应变成 '103'。转写稿中“点一下、传过来、父组件接收参数”的排查思路同样适用:先确认子组件有没有收到 value,再确认 $emit 有没有触发,最后确认父组件有没有更新 selectId。

易错点集中在三处:一是只写 :value 不写 @change/$emit,只能父传子;二是只写 $emit 不写 :value,子组件初始选中不同步;三是事件名、prop 名写错,导致 v-model 失效。把这三处对齐,表单类组件的双向绑定就封装完成了。

1. 目标:为什么要用 v-model 简化双向绑定 ​

为什么需要:父组件把数据传给子组件时,常规写法是 :value="selectId" 加 @input="..."。如果每个子组件都手写一遍“传值 + 监听事件 + 回写父组件数据”,代码会重复,父子通信的意图也不够直观。

怎么想:v-model 不是新魔法,它本质上是“绑定 value + 监听 input”的语法糖。自定义组件只要遵守这个约定,父组件就能用一行 v-model 完成双向绑定。

怎么做:父组件写 <BaseSelect v-model="selectId"></BaseSelect>;子组件用 props 接收 value,在值变化时 $emit('input', 新值)。

易错点:不要以为 v-model 可以绕过“子组件不能直接改 props”。子组件仍然只能通过事件通知父组件,由父组件修改数据。

2. 本质:v-model 是 :value 和 @input 的简写 ​

为什么需要:理解简写关系,才能知道自定义组件为什么必须按约定写 value 和 input。

怎么想:把 v-model="selectId" 拆开看,就是给子组件传 value,并监听子组件发出的 input 事件。子组件发什么,父组件就收到什么。

怎么做:子组件内部用 :value="value" 把值渲染到 select 上;用户选择后触发 change,在 handleChange 里 $emit('input', e.target.value)。

易错点:事件名必须是 input,不是 change。change 只是原生 select 的事件,子组件对父组件通信仍要发 input。如果写成 $emit('change', ...),父组件的 v-model 收不到。

3. 子组件怎么写:props 接收 value,事件触发 input ​

为什么需要:子组件要显示父组件传来的值,又要把用户操作后的新值送回去。

怎么想:props 是只读的,子组件不能写 this.value = ...。正确路径是“接收 value → 用户操作 → emit input”。

怎么做:

vue
<select :value="value" @change="handleChange">...</select>
props: {
  value: String
},
methods: {
  handleChange (e) {
    this.$emit('input', e.target.value)
  }
}

易错点:props 里字段名要叫 value,因为父组件 v-model 默认传的就是 value;事件名要叫 input。名字不一致时,父组件数据不会更新,但页面可能不报明显错误,调试时要先检查这两个约定。

4. 父组件怎么写:v-model 直接绑定数据 ​

为什么需要:父组件希望少写重复的 :value 和 @input,让模板更清楚。

怎么想:父组件只关心“这个子组件的值绑定到哪个数据上”。只要子组件实现了约定,就可以直接用 v-model。

怎么做:

vue
<BaseSelect v-model="selectId"></BaseSelect>

易错点:v-model 绑定的必须是父组件里可写的数据,比如 data 中的 selectId。不要绑定到只读计算属性或常量上。另外,父组件不要把 v-model 写到错误组件上;在记事本案例里,删除事件来自 to do main,添加事件来自 to do header,事件要加在对应的子组件标签上。

5. 从记事本添加功能看推导与调试 ​

为什么需要:转写稿里的添加功能能说明“数据修改权在父组件”这个前提。list 在 APP 中,to do header 里的输入框只负责收集值,不能直接改 list。

怎么想:输入框先用 v-model 绑定 todoName,这样输入框的值和变量同步;点击按钮时,子组件通过 $emit('add', this.todoName) 把值传给父组件;父组件用 @add="handleAdd" 接收,再向 list 中添加对象并清空输入框。

怎么做:核心语句是:

js
this.$emit('add', this.todoName)

父组件监听并处理:

vue
<ToDoHeader @add="handleAdd"></ToDoHeader>

易错点:如果点击按钮没反应,先查三处:子组件有没有 $emit;事件名 add 和父组件 @add 是否一致;父组件有没有写对应方法。转写稿里也提醒过:$emit 写在模板里可直接写,写在 methods 里要用 this.$emit。另外,v-model 简化的是输入框取值/回写,不是替代子传父;添加数据仍要遵守“子通知父、父改数据”。

6. 易错点汇总 ​

为什么需要:这类语法糖出错时往往不报大错,只是“值不同步”,需要按约定排查。

怎么想:先拆 v-model,再看子组件是否 props: { value },是否 $emit('input', 新值),最后看父组件绑定的数据是否可写。

怎么做:调试时临时把 v-model 改回 :value 和 @input,能快速定位是哪一半没生效;检查事件名大小写和拼写;检查是否误把 change 当成对外事件。

易错点:不要直接修改 props;不要漏写 $emit 的第二个参数;不要把 v-model 用在没有实现 value/input 约定的自定义组件上。掌握这套约定后,BaseSelect 这类组件就能用一行代码完成父子双向绑定。

为什么需要 .sync:子组件不能直接改父组件的 props ​

为什么需要:父组件把数据通过 props 传给子组件后,子组件只能读,不能直接改。转写稿里删除功能也反复强调:数据在 App 里,list 的修改权在父组件,子组件不能直接“把 list 删一条”。弹框组件同理:visible 由父组件控制,子组件不能写 this.visible = false。

怎么想:子组件要改父组件数据,必须“通知父组件”,让父组件自己改。通知方式就是 $emit 自定义事件。转写稿里删除是子组件 $emit('del', id),父组件监听后改 list;添加是子组件把输入框值 $emit('add', todoName) 给父组件;清空任务只 $emit('clear'),不传数据。.sync 就是把“通知父组件改某个 prop”这件事规范化、简写化。

怎么做:父组件写 :visible.sync="isShow",子组件在需要关闭时写 this.$emit('update:visible', false)。

易错点:不要写成 this.visible = false;不要漏掉 update: 前缀;属性名必须前后一致。

.sync 的作用与场景 ​

作用:实现子组件与父组件数据的“双向绑定”效果,简化代码。简单理解:子组件可以修改父组件传过来的 props 值,但本质仍是子组件通知、父组件修改。

场景:封装弹框类基础组件,visible 为 true 显示,false 隐藏。父组件控制显隐,子组件点击关闭按钮时通知父组件把 visible 改成 false。

怎么想:如果不用 .sync,父组件要写两个东西:一个传值,一个监听更新事件;子组件要 $emit('update:visible', false)。.sync 把这两步合在一起。

易错点:.sync 不是让子组件直接拥有修改权,数据修改权仍在父组件。

.sync 的本质::属性名 和 @update:属性名 合写 ​

为什么需要:完整写法代码多,容易漏写监听。

怎么想:把“传 prop”和“监听 update 事件并赋值”合并。

怎么做:

vue
//.sync写法
<BaseDialog :visible.sync="isShow" />
--------------------------------------
//完整写法
<BaseDialog 
  :visible="isShow" 
  @update:visible="isShow = $event" 
/>

子组件:

vue
props: {
  visible: Boolean
},

this.$emit('update:visible', false)

易错点:事件名必须是 update:属性名;$event 就是子组件 emit 出来的新值。若属性叫 isShow,就应写 update:isShow,不能写 update:visible。

代码示例:弹框组件的父子结构 ​

下面保留笔记中的示例代码。示例中父组件用 :isShow="isShow" 传值,子组件用 props: { isShow: Boolean } 接收;若要让子组件关闭弹框,就按上面的本质改成 :isShow.sync="isShow",子组件 $emit('update:isShow', false)。

App.vue:

vue
<template>
  <div class="app">
    <button @click="openDialog">退出按钮</button>
    <BaseDialog :isShow="isShow"></BaseDialog>
  </div>
</template>

<script>
import BaseDialog from './components/BaseDialog.vue'
export default {
  data() {
    return {
      isShow: false,
    }
  },
  components: {
    BaseDialog,
  },
}
</script>

<style>
</style>

BaseDialog.vue:

vue
<template>
  <div class="base-dialog-wrap" v-show="isShow">
    <div class="base-dialog">
      <div class="title">
        <h3>温馨提示:</h3>
        <button class="close">x</button>
      </div>
      <div class="content">
        <p>你确认要退出本系统么?</p>
      </div>
      <div class="footer">
        <button>确认</button>
        <button>取消</button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    isShow: Boolean,
  }
}
</script>

<style scoped>
.base-dialog-wrap {
  width: 300px;
  height: 200px;
  box-shadow: 2px 2px 2px 2px #ccc;
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 0 10px;
}
.base-dialog .title {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 2px solid #000;
}
.base-dialog .content {
  margin-top: 38px;
}
.base-dialog .title .close {
  width: 20px;
  height: 20px;
  cursor: pointer;
  line-height: 10px;
}
.footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 26px;
}
.footer button {
  width: 80px;
  height: 40px;
}
.footer button:nth-child(1) {
  margin-right: 10px;
  cursor: pointer;
}
</style>

调试过程与易错点 ​

转写稿里调试删除、添加、清空时,核心排查点都是“事件有没有写对位置”。例如删除事件必须加在 ToDoMain 这个子组件标签上,因为数据是它传上来的;不能写到 Header 或 Footer 上。.sync 同理:@update:visible 是 BaseDialog 发出来的,就要写在 BaseDialog 标签上。

转写稿中的“复传子/自传富”应纠正为“父传子/子传父”,“真听”应纠正为“侦听”,“1句化”应纠正为“持久化”。这些说法背后都是同一件事:父组件持有数据,子组件通过事件通知父组件修改。写 .sync 时先检查三处:父组件是否写 .sync,子组件 props 名是否一致,$emit 事件名是否为 update:属性名。

小结 ​

  1. 父组件如果想让子组件修改传过去的值,必须加 .sync 修饰符(Vue2 写法)。
  2. 子组件要修改父组件的 props 值,必须使用 this.$emit('update:属性名', 新值) 语法。

作用:拿当前组件内的 DOM 或组件实例 ​

为什么需要:Vue 里大多数时候用数据驱动视图,但图表容器、视频、canvas 这类东西必须拿到真实 DOM 才能初始化。用 document.querySelector('.box') 是从整个页面找,组件一复用就容易拿到别的组件里的盒子,不稳定。

怎么想:ref 是“给当前组件模板里的元素或子组件起一个引用名”,$refs 是“当前组件实例上保存这些引用的对象”。它的查找范围是当前组件内,所以更精确。

怎么做:模板里写 ref="chartRef",在 mounted 里通过 this.$refs.chartRef 取。注意是 mounted 之后才有,因为那时 DOM 才渲染好。

易错点:ref 名字要和 $refs 后面的名字一致;this.$refs 是对象,不是函数;不要把它当成跨组件通信工具。

语法:模板加 ref,mounted 里取 ​

笔记里的两步很关键:

html
<div ref="chartRef">我是渲染图表的容器</div>
html
mounted () {
  console.log(this.$refs.chartRef)
}

为什么需要:先打印确认拿到的是不是那个 DOM,再传给 ECharts 初始化,比直接写选择器更稳。

怎么想:先问“我要拿的东西在不在当前组件模板里?”在,才用 ref;不在,应该走父子通信,而不是指望 $refs 跨组件拿。

易错点:mounted 里打印如果是 undefined,先检查模板 ref 名和 this.$refs.xxx 是否一致;如果是组件标签上的 ref,拿到的是组件实例,不是 DOM,本节示例演示的是 DOM。

代码示例:BaseChart 中初始化 ECharts ​

App.vue:

vue
<template>
  <div class="app">
    <BaseChart></BaseChart>
  </div>
</template>

<script>
import BaseChart from './components/BaseChart.vue'
export default {
  components:{
    BaseChart
  }
}
</script>

<style>
</style>

BaseChart.vue:

vue
<template>
  <div class="base-chart-box" ref="baseChartBox">子组件</div>
</template>

<script>
// yarn add echarts 或者 npm i echarts
import * as echarts from 'echarts'

export default {
  mounted() {
    // 基于准备好的dom,初始化echarts实例
    var myChart = echarts.init(document.querySelect('.base-chart-box'))
    // 绘制图表
    myChart.setOption({
      title: {
        text: 'ECharts 入门示例',
      },
      tooltip: {},
      xAxis: {
        data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子'],
      },
      yAxis: {},
      series: [
        {
          name: '销量',
          type: 'bar',
          data: [5, 20, 36, 10, 10, 20],
        },
      ],
    })
  },
}
</script>

<style scoped>
.base-chart-box {
  width: 400px;
  height: 300px;
  border: 3px solid #000;
  border-radius: 6px;
}
</style>

调试提醒:笔记代码里 document.querySelect('.base-chart-box') 少了一个 or,实际应为 document.querySelector,否则运行会报错。也正因选择器容易写错、容易全页面查找,更推荐在 mounted 里用 this.$refs.baseChartBox 拿当前组件内的盒子,再 echarts.init(this.$refs.baseChartBox)。

从转写稿看“查找范围”和“修改权” ​

转写稿没有展开 ref 的调试,但它反复在讲组件拆分后的边界:数据放在 App 里,子组件通过 props 接收,不能直接改;删除、添加、清空要“子传父”,由父组件改数据。这里有几个同音字要纠正:转写稿里的“自传富/紫唇户”是子传父,“复传子/受狐传子”是父传子,“真听器”是侦听器,“轻空/星空”是清空,“店名/世界名”是事件名,“例似/历史”是 list。

这个思路迁移到 ref 上就是:先判断“要拿的东西是不是当前组件模板里的”。是,用 ref;不是,别用 $refs 越界拿,而要走父子通信。这样组件复用后,ref 只影响自己这一份 DOM,不会像全页面选择器那样互相干扰。

易错点清单 ​

  1. ref 写在模板上,$refs 在 JS 里取,名字必须一致。
  2. 一定要在 mounted 及之后取,created 阶段 DOM 还没挂载。
  3. document.querySelect 是拼写错误,正确是 document.querySelector;但本节更推荐 this.$refs.xxx。
  4. ref 在 v-for 里会得到数组,不要当单个 DOM 用。
  5. 给子组件加 ref 拿到的是组件实例,不是子组件里的 DOM;要拿子组件内部 DOM,应在子组件自己内部用 ref。
  6. 不要把 $refs 当成跨组件改数据的捷径,数据修改权仍按父子通信规则来。

课件图示 ​

课件第29页:总结 总结 1. 表单类基础组件封装思路 ①父传子:父组件动态传递prop 数据

课件第27页:表单类组件封装& v-model 简化代码 <BaseSelect :cityI

课件第28页:表单类组件封装& v-model 简化代码 2. 父组件v-model 简化代码

课件第24页:非父子通信(拓展) - provide & inject export defa

课件第23页:1. 创建一个都能访问到的事件总线(空Vue 实例) → utils/Event

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

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

课件第18页:props 校验 思考:组件的prop 可以乱传么? 作用:为组件的prop 指

代码示例 ​

示例一:v-model 原理(完整 HTML 页面,保存为 .html 双击即可运行) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>v-model 原理</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
</head>
<body>
  <div id="app">
    <!-- v-model 写法:语法糖 -->
    <input v-model="msg1" type="text" />
    <br />
    <!-- 等价写法::value 绑定 + @input 事件 -->
    <input :value="msg2" @input="msg2 = $event.target.value" type="text" />

    <p>msg1:{{ msg1 }}</p>
    <p>msg2:{{ msg2 }}</p>
  </div>

  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          msg1: '',
          msg2: '',
        }
      },
    })
  </script>
</body>
</html>

关键行与易错点:

  • v-model 本质是语法糖:value 属性 + input 事件 的合写,两个输入框效果完全一致。
  • 数据变 → 视图变:靠 :value 单向绑定;视图变 → 数据变:靠 @input 事件回写。
  • $event 用于在模板中获取事件的形参(这里是原生 input 事件对象),所以要用 $event.target.value 取值。
  • 易错:写成 @input="msg2 = $event.value" 是错的,必须从 $event.target 上取;@input 里能直接写赋值语句,是因为模板中 this 已隐式指向组件实例。
  • 注意:不同表单元素底层机制不同,checkbox 底层处理的是 checked 属性和 change 事件,本节只需掌握文本框原理。

示例二:表单类组件封装 + v-model 简化(Vue2 单文件组件,放入脚手架运行) ​

父组件 App.vue

vue
<template>
  <div class="app">
    <!-- v-model 简化写法 -->
    <BaseSelect v-model="selectId"></BaseSelect>
    <p>当前选中:{{ selectId }}</p>
  </div>
</template>

<script>
import BaseSelect from './components/BaseSelect.vue'
export default {
  data() {
    return {
      selectId: '102',
    }
  },
  components: {
    BaseSelect,
  },
}
</script>

<style>
</style>

子组件 components/BaseSelect.vue

vue
<template>
  <div>
    <select :value="value" @change="handleChange">
      <option value="101">北京</option>
      <option value="102">上海</option>
      <option value="103">武汉</option>
      <option value="104">广州</option>
      <option value="105">深圳</option>
    </select>
  </div>
</template>

<script>
export default {
  props: {
    value: String,
  },
  methods: {
    handleChange(e) {
      this.$emit('input', e.target.value)
    },
  },
}
</script>

<style>
</style>

关键行与易错点:

  • 子组件 props 必须叫 value、事件必须叫 input,这是 v-model 的默认约定,名字改了就失效。
  • 子组件内不能直接修改 props(数据修改权在父组件),只能 this.$emit('input', e.target.value) 通知父组件改。
  • 父组件 <BaseSelect v-model="selectId"> 等价于 :value="selectId" @input="selectId = $event"。
  • 易错:select 用的是 @change 事件而不是 @input;props 里写成 selectId 之类的名字,接收不到父组件传的值。
  • 易错:e.target.value 拿到的是字符串,如果需要数字要自行转换。

示例三:.sync 修饰符实现弹框双向绑定(Vue2 单文件组件) ​

父组件 App.vue

vue
<template>
  <div class="app">
    <button @click="openDialog">退出按钮</button>
    <!-- .sync 写法 -->
    <BaseDialog :visible.sync="isShow" />

    <!-- 完整写法(与上面等价):
    <BaseDialog :visible="isShow" @update:visible="isShow = $event" />
    -->
  </div>
</template>

<script>
import BaseDialog from './components/BaseDialog.vue'
export default {
  data() {
    return {
      isShow: false,
    }
  },
  components: {
    BaseDialog,
  },
  methods: {
    openDialog() {
      this.isShow = true
    },
  },
}
</script>

<style>
</style>

子组件 components/BaseDialog.vue

vue
<template>
  <div class="base-dialog-wrap" v-show="visible">
    <div class="base-dialog">
      <div class="title">
        <h3>温馨提示:</h3>
        <button class="close" @click="close">x</button>
      </div>
      <div class="content">
        <p>你确认要退出本系统么?</p>
      </div>
      <div class="footer">
        <button @click="close">确认</button>
        <button @click="close">取消</button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    visible: Boolean,
  },
  methods: {
    close() {
      this.$emit('update:visible', false)
    },
  },
}
</script>

<style scoped>
.base-dialog-wrap {
  width: 300px;
  height: 200px;
  box-shadow: 2px 2px 2px 2px #ccc;
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 0 10px;
}
.base-dialog .title {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 2px solid #000;
}
.base-dialog .content {
  margin-top: 38px;
}
.base-dialog .title .close {
  width: 20px;
  height: 20px;
  cursor: pointer;
  line-height: 10px;
}
.footer {
  display: flex;
  justify-content: flex-end;
  margin-top: 26px;
}
.footer button {
  width: 80px;
  height: 40px;
}
.footer button:nth-child(1) {
  margin-right: 10px;
  cursor: pointer;
}
</style>

关键行与易错点:

  • .sync 本质是 :属性名 和 @update:属性名 的合写,作用就是让子组件能“修改”父组件传过来的 props。
  • 子组件修改时固定写法:this.$emit('update:visible', false),事件名中的 visible 必须和 props 名、.sync 前的属性名三者完全一致。
  • 父组件 :visible.sync="isShow" 写的是父组件自己的数据 isShow,子组件 props 收的是 visible,两者名字不必相同但事件名要跟 props 对齐。
  • 易错:直接在子组件里写 this.visible = false 会报错(props 只读),必须走 $emit。
  • 易错:事件名写成 update:isShow 或 update-visible,父组件的 .sync 都接收不到,弹框关不掉。
  • 场景记忆点:弹框类基础组件的 visible(true 显示 / false 隐藏)就是 .sync 的典型用法。

常见错误 ​

1. provide 里给了个简单类型,却指望子组件跟着变

  • 现象:父组件 this.color = 'red' 改了,孙组件 this.color 还是旧值,页面不更新。
  • 原因:provide(){ return { color: this.color } } 取的是当时的值拷贝,不是引用,后续父组件再变与它无关。
  • 修法:要么把数据包成复杂类型(对象/数组)提供出去,要么在父组件里把整个对象换掉而非改属性,并在孙组件里按引用读;单纯需要"变了通知"的场景改用事件或 Vuex/Pinia。

2. 子组件直接改 props 上的数组/对象

  • 现象:this.list.push(...)、this.list.splice(...) 能生效但不报错,this.list = [] 直接报 "Avoid mutating a prop";有时数据变了父组件却没同步存进 localStorage。
  • 原因:数据所有权在 App,props 只读;引用类型虽然能"改得动",但破坏了单向数据流,父组件感知不到。
  • 修法:一律 this.$emit('del', id) / $emit('add', name) 把动作抛给父组件,由 App 调方法改 list;子组件只做展示和发事件。

3. @自定义事件 挂错组件

  • 现象:点击删除没反应,事件发出去了父组件收不到。
  • 原因:事件是从 TodoMain 发上来的,却写成了 <TodoHeader @del="...">(或挂到 <TodoFooter> 上),监听者和触发者不是同一对父子。
  • 修法:先确认 $emit 写在哪个子组件里,监听就加到模板中那一个子组件标签上;同时核对事件名大小写、kebab-case 与 camelCase 是否一致。

4. 自定义组件的 v-model 不生效

  • 现象:<BaseSelect v-model="selectId"> 里选了半天,父组件的 selectId 不变;或一渲染就报警告说缺少 prop。
  • 原因:v-model 在组件上是 :value + @input 的语法糖,子组件没用 props: { value: ... } 接收,或 $emit 的事件名写成了 change、update:value。
  • 修法:子组件 props: { value: String },<select :value="value" @change="handleChange">,方法里 this.$emit('input', e.target.value);两处命名必须严格对上。

5. .sync 的事件名写错

  • 现象:<BaseDialog :visible.sync="isShow"> 点关闭按钮,弹窗关不掉。
  • 原因:子组件写成 this.$emit('update', false) 或 'update-visible',而 .sync 监听的是 update:visible。
  • 修法:$emit('update:' + 属性名, 新值),属性名与父组件传的 prop 名逐字一致(冒号不能换成短横线)。

6. ref 拿不到 DOM 或拿到别人的 DOM

  • 现象:mounted 里 this.$refs.chartRef 是 undefined;或者图表渲染到了页面别处的盒子上。
  • 原因:模板里忘了写 ref="chartRef"(或名字拼错);用 document.querySelector('.base-chart-box') 时命中了页面上第一个同名 class。
  • 修法:先确认 ref 属性与 $refs.xxx 名字完全一致,且只能在 mounted 之后访问;容器获取一律改成 this.$refs.chartRef,不要用 document 全局查找。

7. 首次进入页面读 localStorage 直接报错

  • 现象:清空缓存后刷新,控制台 Unexpected token o in JSON / Cannot read properties of null。
  • 原因:JSON.parse(localStorage.getItem('todoList')) 在没存过时拿到 null,JSON.parse(null) 得到 null,后续 list.length 就炸了。
  • 修法:JSON.parse(localStorage.getItem('todoList')) || [] 给兜底;写入端 JSON.stringify 不要漏。

练习 ​

  1. 改造 BaseSelect 支持 v-model:父组件 App 用 <BaseSelect v-model="selectId">,初始值 '102'。要求下拉框默认选中"上海",切换后父组件的 selectId 跟着变。写出子组件的 props、模板绑定和 $emit 三处代码,并说明为什么这里必须用 input 而不是 change。

  2. 用 .sync 封装确认弹窗:把笔记里的 BaseDialog 改成 <BaseDialog :visible.sync="isShow"> 的写法,点 x 和"取消"都要关闭,点"确认"关闭并打印"已退出"。写出子组件那行 $emit 和父组件的完整标签,再把 .sync 版本展开成完整写法对照一遍。

  3. provide/inject 改造成可修改:爷爷组件 provide 一个 userInfo 对象给孙子组件显示,孙子组件有个按钮要把名字改成"张三"。用两种方案实现:① provide 一个修改方法;② 让孙子 $emit 一路传上去。比较两种写法的层数代价,说出什么时候该用哪个。

  4. ref 修 bug:给出下面这段代码,找出至少两个问题并改正(提示:初始化时机 + 查找范围)。

    js
    mounted () {
      var myChart = echarts.init(document.querySelector('.base-chart-box'))
    }

    改完后补充:如果在弹窗里渲染图表,mounted 时容器宽高为 0 导致图表变形,你会怎么处理?

  5. 记事本 watch 该写在哪:需求是"list 一变化就存 localStorage"。分别说明把 watch 写在 TodoHeader、TodoMain、TodoFooter、App 里各会漏掉哪些操作,并写出 App 中正确的 watch 结构(含 JSON.stringify 与读取时的兜底)。

原视频对照 ​

#视频时长
1记事本案例-拆分组件07:45
2记事本案例-渲染功能06:41
3记事本案例-删除功能06:08
4记事本案例-添加功能06:55
5记事本案例-底部统计和清空03:58
6记事本案例-存到本地存储04:10

小结 ​

  1. 数据放在哪个组件,修改权就只在那个组件;其余组件一律靠 props 读、靠 $emit 提需求——这是本模块所有案例反复踩同一条线的根源。
  2. provide/inject 解决的是"层级太深不想一层层传",不是"让子孙随便改";简单类型天然不响应,要响应就传对象,要修改就传方法。
  3. v-model、.sync、props + $emit 三者是同一件事的三种写法,只是糖的甜度不同:能默写出展开写法,才敢在报错时定位到是 prop 名还是事件名对不上。
  4. 自定义组件用 v-model,子组件的入口名固定是 value、出口名固定是 input(Vue 2);用 .sync 则是 属性名 配 update:属性名——命名错一个字,功能全废。
  5. ref/$refs 优于 document.querySelector,因为它把查找范围锁在当前组件内;代价是必须在 mounted 之后访问,且 ref 名要与模板逐字对应。
  6. 拆分组件的顺序永远是:先切结构 → 再建父子关系(导入/注册/使用)→ 最后接数据流;一次性把结构、数据、事件混着改,出错时根本分不清是哪一层的问题。
  7. watch 要写在唯一持有数据的那个组件里,否则新增、删除、清空这类不同来源的操作必然漏存;localStorage 读写必须成对考虑 JSON.stringify 和 || 默认值 兜底。
  8. 每个案例做完备份一份 src,把"语法练习"和"业务实现"分开积累——后面写项目时你会发现,真正卡住你的从来不是业务,而是这些语法细节的熟练度。