组件是 Vue 里绕不过去的一关。语法本身不难,Vue.component 一注册,模板里当标签用就行。真正卡人的是那些「为什么必须这样写」的规则:为什么组件的 data 一定要写成函数,为什么模板只能有一个根元素,为什么在子组件里改 props 会挨骂,为什么 table 里放自定义组件会渲染错位。这些规则不是 Vue 团队随手定的,每一条背后都有具体的原因。这篇把组件的注册、传值、事件、插槽、通信这几块串起来讲一遍,重点放在规则背后的机制上,顺带把原文里几处写错的代码改对。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 全局注册和局部注册的差别,各自什么时候用
- 组件的
data为什么必须是函数,不是函数会发生什么 - DOM 模板的解析限制,以及
is属性在解决什么问题 props的传值规则,为什么静态传值拿到的永远是字符串- 单向数据流,子组件想改父组件数据的三种正确姿势
prop验证的完整写法和自定义构造器校验- 自定义事件、
.native修饰符,以及v-model在组件上的原理 - 动态组件
component :is和keep-alive的缓存 - 具名插槽的内容分发规则
- 父子、子父、兄弟三个方向的组件通信
- 这些写法在 Vue 3 里哪些变了
# 一、组件的基本使用
# 1.1 注册组件
注册组件就是利用 Vue.component() 方法,先传入一个自定义组件的名字,然后传入这个组件的配置:
Vue.component('mycomponent',{
template: `<div>这是一个自定义组件</div>`,
data () {
return {
message: 'hello world'
}
}
})
这样就创建了一个全局组件,之后在任何 Vue 实例挂载的 DOM 元素中都可以使用它。
还可以在某个 Vue 实例中注册只有自己能用的局部组件,写在 components 选项里:
var app = new Vue({
el: '#app',
data: {
},
components: {
'my-component': {
template: `<div>这是一个局部的自定义组件,只能在当前Vue实例中使用</div>`,
}
}
})
两种组件在模板里的用法没差别:
<div id="app">
<mycomponent></mycomponent>
<my-component></my-component>
</div>
那该选哪种?我的判断标准是看复用范围。全局注册胜在方便,注册一次到处能用,但它有个实实在在的代价:无论项目里有没有真的用到,这个组件的代码都会被打进最终产物,而且打包工具没法通过静态分析摇掉它。所以只有确实全站在用的基础组件(按钮、图标、弹窗)才值得全局注册,业务组件一律局部注册。
组件名的写法也有讲究。用 kebab-case(短横线)注册的组件,模板里只能用 kebab-case;用 PascalCase(首字母大写)注册的,两种写法都能用。在字符串模板和 DOM 模板里表现还不一样,稳妥的做法是注册时用 PascalCase,在单文件组件的模板里也用 PascalCase,在 DOM 模板里用 kebab-case。
# 1.2 模板的要求
组件的模板只能有一个根元素,下面的写法是不允许的:
template: `<div>这是一个局部的自定义组件,只能在当前Vue实例中使用</div>
<button>hello</button>`,
为什么有这条限制?因为 Vue 2 的虚拟 DOM 实现里,一个组件实例对应一棵虚拟节点树,而树必须有唯一的根。有了唯一根节点,diff 的时候才能从一个确定的起点开始比对,组件的 $el 也才有一个明确的指向。
实际写业务时,绕过它的办法就是外面包一层 div。代价是多一层无意义的 DOM 嵌套,在做 flex 布局或者写 CSS 选择器的时候偶尔会碍事。
Vue 3 已经支持多根节点了(官方叫 Fragment),这条限制不存在了。但相应地,多根节点组件上的非 prop 属性没法自动继承(Vue 不知道该往哪个根上放),需要用 v-bind="$attrs" 手动指定落到哪个元素上。
# 1.3 组件中的 data 必须是函数
注册组件时传入的配置和创建 Vue 实例差不多,但有一个关键差别:data 属性必须是一个函数。
原因在于 JavaScript 里对象类型的变量保存的是引用。如果像 Vue 实例那样直接传一个对象,当页面上存在多个这样的组件时,它们会共享同一份数据,导致一个组件中数据的改变会引起其他组件数据的改变。
而使用一个返回对象的函数,每次创建组件实例时都会调用一次这个函数,拿到一个全新的对象,各实例之间互不影响。
这个坑很有画面感:一个列表里渲染了十个计数器组件,点第一个的加号,十个数字一起跳。写业务的时候不太容易碰到,因为 .vue 单文件组件里 data 天然就是函数写法,写错了 ESLint 也会拦。但面试问到「为什么」的时候,能不能说到「引用类型共享」这一层,差别就出来了。
顺带说一句,根实例(new Vue({}))的 data 可以是对象,因为根实例全局只有一份,不存在复用问题。
# 1.4 关于 DOM 模板的解析
当使用 DOM 作为模板时(例如把 el 选项挂载到一个已存在的元素上),会受到 HTML 的一些限制,因为 Vue 只有在浏览器解析和标准化 HTML 之后才能获取模板内容。
像 ul、ol、table、select 这些元素限制了能被它包裹的元素,而 option 这样的元素只能出现在某些特定元素内部。在自定义组件中使用这些受限制的元素时会出问题,例如:
<table>
<my-row>...</my-row>
</table>
自定义组件 my-row 被认为是无效的内容,浏览器在解析阶段就会把它从 table 里挪出去,等 Vue 接手时结构已经乱了。这时应使用特殊的 is 属性:
<table>
<tr is="my-row"></tr>
</table>
也就是说,标准 HTML 中一些元素里只能放置特定的子元素,另一些元素只能存在于特定的父元素中。比如 table 中不能直接放 div,tr 的父元素不能是 div。所以当使用自定义组件时,标签名还是用那些合法的标签名,把自定义组件的名字填在 is 属性里。
有一点要强调,这是浏览器的 HTML 解析规则在作怪,跟 Vue 没关系。所以如果模板不是从页面 DOM 里读出来的,这些限制就不适用。以下三种来源不受影响:
type="text/x-template"的模板标签- JavaScript 内联模板字符串
.vue单文件组件
一般情况下,只要用了 vue-cli 或者 Vite 走单文件组件,就完全不用操心这一节的内容。这也是我建议新手直接上脚手架、别从 CDN 引入开始学的原因之一,能少踩一整类坑。
# 二、组件的属性和事件
在 HTML 中使用元素会有一些属性,如 class、id,还可以绑定事件,自定义组件也是一样的。当在一个组件中使用了其他自定义组件时,就会利用子组件的属性和事件来和父组件进行数据交流。

父子组件之间的通信可以概括成 props down,events up。父组件通过属性 props 向下传递数据给子组件,子组件通过事件 events 给父组件发送消息。
比如子组件需要某个数据,就在内部定义一个 prop 属性,然后父组件像给 HTML 元素指定特性值一样,把自己的 data 属性传递给子组件的这个属性。而当子组件内部发生了什么事情,就通过自定义事件把涉及到的数据暴露出来,供父组件处理。
<my-component v-bind:foo="baz" v-on:event-a="doThis(arg1, arg2)"></my-component>
这一行里,foo 是 my-component 组件内部定义的一个 prop 属性,baz 是父组件的一个 data 属性;event-a 是子组件定义的一个事件,doThis 是父组件的一个方法。
整个过程是这样的:
- 父组件把
baz数据通过prop传递给子组件的foo - 子组件内部拿到
foo的值,进行相应的操作 - 当子组件内部发生了一些变化、希望父组件知道时,触发
event-a事件,把数据发送出去 - 父组件把这个事件的处理器绑定为
doThis方法,子组件发送的数据作为参数传进来 - 父组件根据这些数据进行相应的操作
这套约定看着简单,但它定义了整个组件树的数据流向:数据只能从上往下流,变更的意图只能从下往上报。所有组件通信的复杂方案(Vuex、provide/inject、event bus)都是在这条基本规则不够用的时候才出场的。
# 三、属性 Props
Vue 组件通过 props 属性来声明自己接收哪些属性,然后父组件就可以往里面传递数据:
Vue.component('mycomponent',{
template: '<div>这是一个自定义组件,父组件传给我的内容是:{{myMessage}}</div>',
props: ['myMessage'],
data () {
return {
message: 'hello world'
}
}
})
调用该组件:
<div id="app">
<mycomponent my-message="hello"></mycomponent>
</div>
注意,由于 HTML 特性不区分大小写,所以在 DOM 模板里传递属性时,myMessage 应该转换成 kebab-case 形式写作 my-message="hello"。声明的时候用驼峰,模板里用短横线,Vue 会自动做这层转换。
这条规则只在 DOM 模板里成立。单文件组件的模板是由 Vue 自己编译的,不经过浏览器的大小写标准化,所以写 :myMessage 也能工作。但为了统一,社区惯例还是模板里一律 kebab-case。
# 3.1 v-bind 绑定属性值
一般情况下,使用 v-bind 给元素特性传递值时,Vue 会把引号里的内容当作一个表达式来求值。
class 和 style 是两个例外,用 v-bind:class 和静态 class 同时传入类名,效果会叠加,因为对这两个特性 Vue 采用的是合并而不是替换的原则。
# 3.2 动态绑定特性值
想要把父组件的属性绑定到子组件,应该使用 v-bind,这样父组件中数据改变时能反映到子组件。
根据传递的属性类型不同,在子组件中更改这个属性会出现两种情况。
第一种,当父组件传递的属性是引用类型时,在子组件中更改属性内部的内容会直接影响父组件: