主题
组件通信与进阶用法(三)
本页对应 组件通信与进阶用法,共 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 拿到数据可以使用,但不要在后代里直接改。若确实要改,应让提供方修改,或由提供方提供修改方法。
易错点:把“拿到数据”误当成“拥有修改权”;在深层组件里直接改注入对象,会让数据流向混乱。
调试与易错点小结
- 先检查
provide是否写成函数并返回对象。 - 再检查
inject数组里的名字与provide键名是否一致。 - 用
created打印注入结果,确认拿到的是undefined还是真实数据。 - 修改简单类型后观察后代是否变化,理解“简单类型非响应式”。
- 后代只读使用,不在自身组件内直接修改注入数据。
- 直接父子通信仍优先
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:属性名。
小结
- 父组件如果想让子组件修改传过去的值,必须加
.sync修饰符(Vue2 写法)。 - 子组件要修改父组件的 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,不会像全页面选择器那样互相干扰。
易错点清单
ref写在模板上,$refs在 JS 里取,名字必须一致。- 一定要在
mounted及之后取,created阶段 DOM 还没挂载。 document.querySelect是拼写错误,正确是document.querySelector;但本节更推荐this.$refs.xxx。ref在v-for里会得到数组,不要当单个 DOM 用。- 给子组件加
ref拿到的是组件实例,不是子组件里的 DOM;要拿子组件内部 DOM,应在子组件自己内部用ref。 - 不要把
$refs当成跨组件改数据的捷径,数据修改权仍按父子通信规则来。
课件图示








代码示例
示例一: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不要漏。
练习
改造 BaseSelect 支持 v-model:父组件 App 用
<BaseSelect v-model="selectId">,初始值'102'。要求下拉框默认选中"上海",切换后父组件的selectId跟着变。写出子组件的props、模板绑定和$emit三处代码,并说明为什么这里必须用input而不是change。用 .sync 封装确认弹窗:把笔记里的 BaseDialog 改成
<BaseDialog :visible.sync="isShow">的写法,点x和"取消"都要关闭,点"确认"关闭并打印"已退出"。写出子组件那行$emit和父组件的完整标签,再把.sync版本展开成完整写法对照一遍。provide/inject 改造成可修改:爷爷组件 provide 一个
userInfo对象给孙子组件显示,孙子组件有个按钮要把名字改成"张三"。用两种方案实现:① provide 一个修改方法;② 让孙子$emit一路传上去。比较两种写法的层数代价,说出什么时候该用哪个。ref 修 bug:给出下面这段代码,找出至少两个问题并改正(提示:初始化时机 + 查找范围)。
jsmounted () { var myChart = echarts.init(document.querySelector('.base-chart-box')) }改完后补充:如果在弹窗里渲染图表,
mounted时容器宽高为 0 导致图表变形,你会怎么处理?记事本 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 |
小结
- 数据放在哪个组件,修改权就只在那个组件;其余组件一律靠
props读、靠$emit提需求——这是本模块所有案例反复踩同一条线的根源。 provide/inject解决的是"层级太深不想一层层传",不是"让子孙随便改";简单类型天然不响应,要响应就传对象,要修改就传方法。v-model、.sync、props + $emit三者是同一件事的三种写法,只是糖的甜度不同:能默写出展开写法,才敢在报错时定位到是 prop 名还是事件名对不上。- 自定义组件用
v-model,子组件的入口名固定是value、出口名固定是input(Vue 2);用.sync则是属性名配update:属性名——命名错一个字,功能全废。 ref/$refs优于document.querySelector,因为它把查找范围锁在当前组件内;代价是必须在mounted之后访问,且ref名要与模板逐字对应。- 拆分组件的顺序永远是:先切结构 → 再建父子关系(导入/注册/使用)→ 最后接数据流;一次性把结构、数据、事件混着改,出错时根本分不清是哪一层的问题。
watch要写在唯一持有数据的那个组件里,否则新增、删除、清空这类不同来源的操作必然漏存;localStorage读写必须成对考虑JSON.stringify和|| 默认值兜底。- 每个案例做完备份一份
src,把"语法练习"和"业务实现"分开积累——后面写项目时你会发现,真正卡住你的从来不是业务,而是这些语法细节的熟练度。