Skip to content

为组件增加事件与全局组件(Lesson5 1/3) ​

本页对应 Lesson05,共 7 段视频,约 1 小时 58 分。

学习目标 ​

  • 能够将 todoList 页面拆分为头部、主体、底部组件,并将各组件抽离到独立文件中完成导入、导出与注册使用。
  • 能够运用状态提升,将 todo 列表状态定义在父级实例中,并通过 props 与自定义事件完成兄弟组件间的数据传递和 todo 添加。
  • 能够使用 $emit 触发自定义事件,使用 $on、$once、$off 完成消息订阅、单次订阅与取消订阅,并说明发布者和订阅者需处于同一 Vue 实例。
  • 能够通过 Vue.prototype.$bus 创建事件总线,实现跨组件发布消息、订阅消息及携带参数传递数据。
  • 能够为组件绑定事件,并在需要监听组件根元素原生事件时正确使用 .native 修饰符,同时将处理函数定义在正确的组件实例中。
  • 能够说明全局组件注册与动态组件的基本用法,并区分其与局部组件注册、普通组件使用的差异。

知识点 ​

本节目标:让组件也能响应点击事件 ​

这一节只解决一个具体问题:父组件使用子组件时,给子组件标签写 @click,为什么点击没有反应?笔记给出的答案是:给事件名加 .native 修饰符。转写稿里的调试过程很直接:先写 @click="fn",点击组件文字,控制台没有输出;改成 @click.native="fn" 后,再点击,控制台输出 fn。这说明普通 @click 并不是绑定到组件根元素的原生 DOM 事件上。

为什么需要:组件是自定义标签,Vue 默认把组件上的 @click 当成“组件自定义事件”来监听,而不是浏览器原生 click。
怎么想:我要的是点击组件根元素时触发父组件方法,所以要让 Vue 把 click 当作原生事件绑到组件根元素。
怎么做:在事件名后加 .native。
易错点:不要以为写了 @click 就一定能触发;函数要定义在父实例的 methods 里,因为事件写在父模板的组件标签上。

先看完整代码:@click.native 与 props 传递 ​

笔记代码如下,保留原代码:

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<child @click.native="fn" :a="1" :styles="{color:'red'}" ></child>
</div>
</body>
<script>
	// 如果要为组件提供事件,那么需要在事件名后增加修饰符native
	new Vue({
		el:"#app",
		methods:{
			fn(){
				console.log("fn")
			}
		},
		components:{
			Child:{
				props:["styles","a"],
				template:(`
					<div :style="styles">
						child组件
					</div>
				`)
			}
		}
	})
</script>
</html>

这段代码里有两个知识点同时出现:第一,@click.native="fn" 给组件增加原生点击事件;第二,:a="1" 和 :styles="{color:'red'}" 是父组件向子组件传 props。子组件声明 props:["styles","a"],模板根元素写 :style="styles",所以文字会变成红色。a 在模板里没有使用,但可以作为“父传子属性”的观察点。

为什么需要:组件要复用,数据、样式不能都写死在子组件里,需要父组件传进去。
怎么想:父组件通过 : 绑定属性,子组件通过 props 接收,模板里再用。
怎么做:子组件声明 props:["styles","a"],根元素写 :style="styles"。
易错点:styles 传的是对象,不是字符串;props 名字要和绑定名一致;@click.native 的 fn 写在父实例 methods 中,不是写在子组件里。

调试过程:普通 @click 为什么没反应 ​

按转写稿的演示,先把组件标签写成 @click="fn",点击“child组件”文字,控制台没有输出。原因不是 fn 写错,而是这个 @click 被当作组件自定义事件监听。组件没有通过 $emit('click') 派发事件,所以父组件的 fn 不会执行。

为什么需要:理解“原生事件”和“组件自定义事件”的区别,才能知道修饰符不是可有可无的。
怎么想:组件标签上的事件默认监听组件自己派发的事件;要监听根元素的原生 DOM 事件,需要显式声明。
怎么做:把 @click="fn" 改成 @click.native="fn",再点击组件根元素。
易错点:.native 是加在事件名后的,不是加在方法上;不要写成 @click="fn.native";Vue 2 中组件模板通常要求单根元素,.native 绑定的是这个根元素。

事件函数应该写在哪里 ​

笔记里 fn 写在 new Vue 的 methods 中,因为 @click.native 写在父模板的 <child> 标签上,相当于父组件在监听子组件根元素。转写稿还提到另一种情况:如果事件直接写在子组件内部的按钮上,例如子组件模板里写 <button @click="fn">,那 fn 就应该定义在子组件的 methods 里。两者不要混。

为什么需要:事件写在哪个模板,处理函数通常就属于哪个组件实例。
怎么想:看 @click.native 写在父模板还是子模板:写在父模板组件标签上,函数放父 methods;写在子组件内部元素上,函数放子 methods。
怎么做:本节代码中 fn 放父实例 methods,控制台输出 fn。
易错点:把 fn 写进子组件 methods,父模板却用 @click.native="fn",会找不到方法;反过来也会出问题。

小结:记住“组件事件 + native”的边界 ​

本节的核心结论是:给组件增加原生点击事件,需要在事件名后加 .native;同时,组件上的属性传递仍按 props 规则走。调试时先看控制台有没有输出,再检查事件写在哪一层、函数定义在哪一层、是否漏了 .native。不要把它和 $emit 自定义事件混为一谈:本节笔记只演示 .native 绑定原生事件,没有展开自定义事件派发。

全局组件要解决什么问题 ​

为什么需要:有些组件会在多个页面、多个 Vue 实例甚至其他组件里反复出现。如果每个实例都写一遍 components 注册,既重复又容易漏。笔记注释已经点明:全局组件“可以在任意实例以及组件中使用的组件”。

怎么想:把“注册”从某个实例里拿出来,交给 Vue 本身。用 Vue.component(组件名, 配置项) 注册一次,之后在任意挂载模板里写对应标签即可使用。局部组件则只属于当前 new Vue 实例或当前父组件。

怎么做:笔记中先执行 Vue.component("one", {...})、Vue.component("two", {...}),再 new Vue({ el:"#app", components:{ Child:{...} } })。Child 是局部组件,但它的模板里直接写了 <one></one>,因为 one 已全局注册,所以在子组件模板中也能用。

易错点:Vue.component 只是“注册”,不会自动把组件塞到页面上;必须在模板里写 <one></one> 才会渲染。注册要写在 new Vue 使用之前。组件名和标签名要对应,实际项目里建议用短横线命名,减少大小写歧义。

从代码看配置项:data、methods、template ​

为什么需要:组件要维护自己的状态和交互,不能把状态写成所有实例共享的普通对象,否则一个组件改数据会影响另一个组件。

怎么想:组件的 data 必须是函数,函数返回一个新对象,这样每个组件实例拿到独立数据。methods 里通过 this 访问当前实例的数据。template 描述组件结构。

怎么做:笔记里 one 的 data(){ return { a:1, b:2 } },methods.fn 中执行 this.a++、this.b += 2;模板中绑定 {{a}}、{{b}},按钮绑定 @click="fn"。two 只有模板,也必须有根元素。

易错点:data 不要写成 data:{...} 普通对象;template 字符串里最好只有一个根元素,笔记用 <div> 包住 <h3>、<p>、<button>;事件方法名要和模板中一致。

调试过程:注册了为什么页面没出现 ​

为什么需要:初学组件最常见的现象是“代码写了,页面没变化”。转写稿里就出现过类似调试:组件定义好后,页面上的头部没有出现,原因是“定义好了组件了,但是我们没有使用它”,需要在挂载容器模板里写组件标签,写完后效果才出来。

怎么想:把“定义组件”和“使用组件”分开检查。先看 Vue.component 是否在 new Vue 前执行;再看模板里有没有写标签;最后看标签名是否和注册名一致。

怎么做:笔记的 #app 中写了 <Child></Child>、<one></one>、<two></two>。Child 内部又写了 <one></one>。这样 one 在根实例和局部组件 Child 中都能渲染。

易错点:只注册不写标签;标签拼错;把全局组件误以为必须写进 components;在 #app 外写组件标签,导致 Vue 不接管。

全局组件与局部组件的边界 ​

为什么需要:全局注册方便,但所有地方都能用会让依赖不清晰;局部注册更可控。转写稿讲组件拆分时也强调把头部、主体、底部拆成组件,抽离后仍要明确“在哪里注册、在哪里使用”。

怎么想:通用基础组件可全局注册;页面专用组件放当前实例的 components。笔记中 one、two 是全局,Child 是局部,正好对比。

怎么做:全局用 Vue.component("one", {...});局部用 components:{ Child:{ template:... } }。局部组件只在当前实例模板中可用;全局组件可在任意实例及组件模板中使用。

易错点:把局部组件拿到另一个实例中用会找不到;全局组件过多会增加命名冲突风险;Child 这种大写开头在 HTML 模板中要留意大小写转换,建议统一短横线命名。

本节完整代码 ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<Child></Child>
	<one></one>
	<two></two>
</div>
</body>
<script>
	// 全局组件:可以在任意实例以及组件中使用的组件
	// 第一个参数是组件名,第二个参数是组件的配置项
	Vue.component("one",{
		data(){
			return {
				a:1,
				b:2
			}
		},
		methods:{
			fn(){
				this.a ++;
				this.b +=2;
			}
		},
		template:(`
			<div>
				<h3>one</h3>
				<p>a:{{a}}</p>
				<p>b:{{b}}</p>
				<button @click="fn">点击</button>
			</div>
		`)
	});
	Vue.component("two",{
		template:(`
			<div>
				<h3>two</h3>
			</div>
		`)
	})
	new Vue({
		el:"#app",
		components:{
			Child:{
				template:(`
					<div>
						<h3>Child</h3>
						<one></one>
					</div>
				`)
			}
		}
	})
</script>
</html>

检查时按顺序看:全局注册是否在 new Vue 前;#app 内是否写了 <one>、<two>;Child 内是否也能写 <one>;data 是否为函数;每个 template 是否只有一个根元素。这样就能把本节“全局组件”的注册、使用和调试过程串起来。

先看痛点:为什么需要动态组件 ​

为什么需要:页面上要在多个组件之间切换时,最容易写成 v-if / v-else-if 一条链。本节笔记里被注释掉的代码就是这种写法:<one v-if="index===0">、<two v-else-if="index===1">……组件一多,模板会越来越长,新增一个组件就要再加一个分支,维护成本高。转写稿里反复强调组件拆分后代码阅读性会变差,动态组件解决的是同一类问题:不要把“当前显示哪个组件”写死在模板里。

怎么想:把“当前用哪个组件”抽成一个数据状态,例如 index;再把组件名放进数组 arr。模板只写一个内置组件 <component>,用 :is 绑定 arr[index]。这样模板结构不变,变的是数据。

怎么做:先注册四个组件 One/Two/Three/Four,再准备 arr:["one","two","three","four"] 和 index:0,模板写 <component :is="arr[index]"></component>。

易错点:is 前必须有冒号,表示绑定表达式;如果写成 is="arr[index]",会被当成普通字符串。arr 里的名字要与组件注册名能对应上;笔记里注册的是 One,数组写的是小写 "one",Vue 会做名称解析,但实际项目更建议统一命名风格。

核心写法:component + is 把组件名数据化 ​

为什么需要:动态组件的关键不是“多写一个标签”,而是把组件切换变成数据驱动。转写稿里讲 todoList 时提到“视图通过数据来驱动”,这里同样适用:index 变,arr[index] 变,渲染的组件就变。

怎么想:component 是 Vue 内置组件,is 的值告诉它“要渲染哪个组件”。这个值可以是组件名字符串,也可以是组件对象。本节用字符串数组,最适合做轮播式切换。

怎么做:保留下面完整代码,重点看模板中的 <component :is="arr[index]"></component> 和 changeIndex 的自增归零逻辑。

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<button @click="changeIndex">切换</button>
	<!--使用动态组件:component是内置组件,is属性的值是要使用的组件的名字-->
	<component :is="arr[index]"></component>
<!--	<component v-show="index===i" v-for="(item,i) in arr" :is="item" :key="i"></component>-->
	
	<!--未使用动态组件-->
<!--	<one v-if="index===0"></one>-->
<!--	<two v-else-if="index===1"></two>-->
<!--	<three v-else-if="index===2"></three>-->
<!--	<four v-else-if="index===3"></four>-->
</div>
</body>
<script>
	new Vue({
		el:"#app",
		data:{
			arr:["one","two","three","four"],
			index:0
		},
		methods:{
			changeIndex(){
				this.index++;
				if(this.index>3) this.index=0;
			}
		},
		components:{
			One:{
				template:(`
					<div style="background:red">one</div>
				`)
			},
			Two:{
				template:(`
					<div style="background:green">two</div>
				`)
			},
			Three:{
				template:(`
					<div style="background:yellow">three</div>
				`)
			},
			Four:{
				template:(`
					<div style="background:pink">four</div>
				`)
			}
		}
	})
</script>
</html>

易错点:changeIndex 里 if(this.index>3) this.index=0; 是循环切换的关键;不归零会越界,arr[index] 变成 undefined,页面就不渲染目标组件。组件模板必须只有一个根元素,笔记里每个模板都是一个 div。

切换与调试:从 v-if 链改成动态组件的验证过程 ​

为什么需要:只看代码容易以为“能切换”,实际要验证 index 是否变化、is 是否取到正确组件名、组件是否注册成功。

怎么想:转写稿里做 todoList 时先拆组件、再抽离文件、再传参,调试思路是“先确认数据状态,再确认视图”。动态组件也一样:先点按钮看 index 是否 0→1→2→3→0,再看 arr[index] 是否对应 one/two/three/four,最后看渲染出的背景色和文字是否变化。

怎么做:打开控制台或在 changeIndex 里临时打印 this.index;点击“切换”按钮,观察红、绿、黄、粉四个块依次出现。若不变,依次检查:@click 是否绑定、方法名是否一致、components 是否注册、:is 是否漏冒号、数组值是否写错。

易错点:不要同时保留注释里的 v-if 链和动态组件,否则会出现重复渲染或干扰判断。笔记中另一条 v-show + v-for 方案是把所有组件都渲染出来再控制显示,和 component :is 的“只渲染当前组件”思路不同,初学阶段先用本节主写法。

组件注册与命名:动态组件的“名字”从哪来 ​

为什么需要::is 绑定的是“组件名”,名字对不上就找不到组件。转写稿里把组件抽到 com 目录时强调导出、引入、注册要一致,动态组件同样依赖注册关系。

怎么想:本节采用局部注册:components:{ One:{...}, Two:{...} }。arr 中存的是查找组件的键。模板里只写一次 <component>,由 Vue 根据 is 的值去 components 里找。

怎么做:保持 arr 与 components 的键对应;如果改成全局注册,也要保证 Vue.component('one', {...}) 的名字与 arr 一致。实际项目建议统一用 kebab-case 或 PascalCase,避免大小写混乱。

易错点:组件名不是模板里的文字内容,template 里的 one/two 只是显示文本;真正决定渲染哪个组件的是 components 的键和 arr[index]。另外,动态组件切换时组件实例会变化,若组件内部有输入状态,不能想当然认为它会自动保留,需要结合后续 keep-alive 等知识再处理。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:为组件增加事件(.native 修饰符) ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<child @click.native="fn" :a="1" :styles="{color:'red'}"></child>
</div>
</body>
<script>
	// 如果要为组件提供事件,那么需要在事件名后增加修饰符 native
	new Vue({
		el: "#app",
		methods: {
			fn() {
				console.log("fn")
			}
		},
		components: {
			Child: {
				props: ["styles", "a"],
				template: (`
					<div :style="styles">
						child组件
					</div>
				`)
			}
		}
	})
</script>
</html>

关键行与易错点

  • @click.native="fn":@click 监听的是“组件自定义事件”,组件内部没有 $emit('click'),所以事件不会触发;加 .native 后才会把原生 DOM 的 click 事件绑定到组件根元素上。
  • fn 必须定义在使用组件的那一层(这里是根实例)的 methods 里,写在子组件里是接不到的。
  • :a="1" 传数字、:styles="{color:'red'}" 传对象,都需要 :(v-bind),否则传过去的是字符串 "1"、"{color:'red'}"。
  • 子组件必须用 props: ["styles","a"] 声明接收,否则 :style="styles" 拿不到值。
  • 组件名 Child 写在 HTML 模板里会被浏览器转成小写 child,Vue 内部会做驼峰匹配,所以能生效;但命名上建议统一用短横线写法,避免歧义。

示例二:全局组件 ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<Child></Child>
	<one></one>
	<two></two>
</div>
</body>
<script>
	// 全局组件:可以在任意实例以及组件中使用的组件
	// 第一个参数是组件名,第二个参数是组件的配置项
	Vue.component("one", {
		data() {
			return {
				a: 1,
				b: 2
			}
		},
		methods: {
			fn() {
				this.a++;
				this.b += 2;
			}
		},
		template: (`
			<div>
				<h3>one</h3>
				<p>a:{{a}}</p>
				<p>b:{{b}}</p>
				<button @click="fn">点击</button>
			</div>
		`)
	});
	Vue.component("two", {
		template: (`
			<div>
				<h3>two</h3>
			</div>
		`)
	})
	new Vue({
		el: "#app",
		components: {
			Child: {
				template: (`
					<div>
						<h3>Child</h3>
						<one></one>
					</div>
				`)
			}
		}
	})
</script>
</html>

关键行与易错点

  • Vue.component("one", {...}) 必须在 new Vue({...}) 之前注册,否则模板编译时找不到组件。
  • 全局组件 one 既能直接在 #app 里用,也能在局部组件 Child 的模板里用——这正是“全局”的含义;Child 是局部组件,只能在 #app 内使用。
  • 组件的 data 必须是函数并 return 一个对象(data(){ return {a:1,b:2} }),这样每个组件实例都有独立数据;若写成对象字面量,多个实例会共享同一份数据。
  • this.a++ 是在组件自己的实例上操作,不会影响别的 one 实例。
  • template 要求只有一个根元素,所以 one 里的 <h3>、<p>、<button> 都被包在一个 <div> 里,否则会报错。

示例三:动态组件 ​

html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
	<script src="lib/vue.js"></script>
</head>
<body>
<div id="app">
	<button @click="changeIndex">切换</button>
	<!--使用动态组件:component是内置组件,is属性的值是要使用的组件的名字-->
	<component :is="arr[index]"></component>
<!--	<component v-show="index===i" v-for="(item,i) in arr" :is="item" :key="i"></component>-->

	<!--未使用动态组件-->
<!--	<one v-if="index===0"></one>-->
<!--	<two v-else-if="index===1"></two>-->
<!--	<three v-else-if="index===2"></three>-->
<!--	<four v-else-if="index===3"></four>-->
</div>
</body>
<script>
	new Vue({
		el: "#app",
		data: {
			arr: ["one", "two", "three", "four"],
			index: 0
		},
		methods: {
			changeIndex() {
				this.index++;
				if (this.index > 3) this.index = 0;
			}
		},
		components: {
			One: {
				template: (`
					<div style="background:red">one</div>
				`)
			},
			Two: {
				template: (`
					<div style="background:green">two</div>
				`)
			},
			Three: {
				template: (`
					<div style="background:yellow">three</div>
				`)
			},
			Four: {
				template: (`
					<div style="background:pink">four</div>
				`)
			}
		}
	})
</script>
</html>

关键行与易错点

  • <component :is="arr[index]"> 中 component 是 Vue 内置组件,is 的值是“要渲染的组件名”,index 一变,渲染的组件就跟着变,不用写一堆 v-if / v-else-if。
  • is 绑定的名字要能和注册名对上:arr 里写 "one",注册的是 One,Vue 会做驼峰匹配;如果名字对不上(如写成 "one1"),会渲染失败或当作普通标签。
  • index 必须在 data 里定义,页面才能响应 changeIndex 里对 this.index 的修改。
  • if (this.index > 3) this.index = 0; 是循环切换的边界处理,少了这句,arr[index] 会取到 undefined。
  • 每次切换都会销毁旧组件、创建新组件,组件内部状态不会保留;如果需要保留状态,要用 <keep-alive> 包裹(本节未展开)。

常见错误 ​

  1. 现象:给组件写 @click="fn" 点击无反应 → 原因:未绑定到组件根元素原生事件 → 修法:改为 @click.native="fn"。
  2. 现象:子组件里 this.$emit('add'),父实例的 $on('add') 不触发 → 原因:$emit 与 $on 不在同一 Vue 实例 → 修法:统一挂到 Vue.prototype.$bus = new Vue() 后,用 this.$bus.$emit/$on。
  3. 现象:先发布消息,后注册订阅,回调不执行 → 原因:发布时还没有订阅者 → 修法:先 $on 订阅,再 $emit 发布。
  4. 现象:$once 注册后第二次发布无响应 → 原因:$once 只响应一次并自动移除 → 修法:需要多次响应改用 $on。
  5. 现象:子组件直接 this.todoList = [...] 报错或失效 → 原因:直接覆盖 props → 修法:通过 $emit 通知父级,由父级更新状态。
  6. 现象:动态组件 <component :is="arr[index]"> 不渲染 → 原因:is 值不是已注册组件名或大小写不一致 → 修法:确认组件已注册,is 使用对应组件名字符串。
  7. 现象:抽离组件后页面空白/报未定义 → 原因:export default、import 名称或路径不一致 → 修法:核对导出方式与引入名称,保持文件名和组件名一致。
  8. 现象:mounted 中拿不到预期 DOM 内容 → 原因:挂载完成时机理解错误,子组件 mounted 先于父实例 → 修法:在对应组件自身 mounted 中取 DOM,父级逻辑放到父 mounted。

练习 ​

  1. 给 child 组件根元素绑定点击事件,分别用不加 .native 和加 .native 测试,并说明差异。
  2. 全局注册 one、two,在局部组件 Child 中同时使用,再在根实例中使用,检查注册名与标签名写法。
  3. 用 <component :is="arr[index]"> 实现四个组件循环切换,并用 v-if/v-else-if 写一版对比代码量。
  4. 完成 todo 添加:父级定义 todoList,头部组件 $emit 提交标题,父级校验空值和重名后更新,主体组件通过 props 渲染。
  5. 用 Vue.prototype.$bus 实现两个兄弟组件通信:A 发布消息,B 订阅;再分别验证 $once 只执行一次、$off 后不再响应。

原视频对照 ​

#视频时长
1完成todoList组件拆分14:37
2将组件放置到外部07:30
3完成todo添加27:49
4完成todo添加208:05
5$emit $on $once $off27:40
6事件总线22:56
7为组件增加事件09:42

小结 ​

  1. 组件根元素原生事件要用 .native,普通元素事件不需要。
  2. 全局组件先注册后使用,标签名建议用 kebab-case。
  3. 动态组件核心是 :is,值应是已注册组件名。
  4. 兄弟组件共享状态优先放到共同父级,即状态提升。
  5. 子组件不能直接覆盖 props,修改共享状态要通过 $emit 交给父级。
  6. $emit/$on/$once/$off 必须基于同一实例;跨组件常用事件总线统一实例。
  7. 事件总线要先订阅后发布;$once 只响应一次,$off 用于取消订阅。
  8. 组件抽离后重点检查导出/导入、注册名、props 接收名三者一致。