本文是介绍独立组件开发的系列文章的第二篇。
组件的通信
组件一般来说有以下几种关系:
- 父子关系
- 隔代关系
- 兄弟关系
组件间经常会通信,Vue
内置的通信手段一般有两种:
ref
:给元素或组件注册引用信息;$parent
/$children
:访问父 / 子实例。
这两种方式都是直接得到组件的实例,然后直接调用组件的方法或访问数据,比如下面的示例中,用 ref
来访问组件):
组件
// component-a
export default {
data () {
return {
title: 'Vue.js'
}
},
methods: {
sayHello () {
window.alert('Hello');
}
}
}
调用方
<template>
<component-a ref="comA"></component-a>
</template>
<script>
export default {
mounted() {
const comA = this.$refs.comA
console.log(comA.title) // Vue.js
comA.sayHello() // 弹窗
}
}
</script>
$parent
和 $children
跟ref
类似,也是基于当前上下文获取父组件或全部子组件的实例,然后调用组件实例上的方法。
这两种方法的弊端是,无法在跨级或兄弟间通信,比如下面的结构:
// parent.vue
<component-a></component-a>
<component-b></component-b>
<component-b></component-b>
在 component-a
组件中,想访问到两个 component-b
组件,那这种情况下,就得配置额外的插件或工具了,比如 Vuex
和 Bus
的解决方案。
但是,我们开发的是独立组件,不应该依赖任何第三方库,如果你依赖了vuex
,但是用户没有安装vuex
,那就会直接报错。
因此,有没有不依赖任何三方库,就可以轻松得到任意的组件实例,或在任意组件间进行通信,且适用于任意场景呢?
provide / inject
基本使用
provide / inject
是 Vue.js 2.2.0 版本后新增的 API,在官网中的介绍:
这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在其上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。
并且文档中有如下提示:
provide 和 inject 主要为高阶插件/组件库提供用例。并不推荐直接用于应用程序代码中。
我们先来看一下这个 API 怎么用,假设有两个组件: A.vue 和 B.vue,B 是 A 的子组件。
// A.vue
export default {
provide: {
name: 'xiaoming'
}
}
// B.vue
export default {
inject: ['name'],
mounted () {
console.log(this.name); // xiaoming
}
}
在 A.vue 里,我们设置了一个 provide: name,值为 xiaoming
,它的作用就是将 name 这个变量提供给它的所有子组件。而在 B.vue 中,通过 inject
注入了从 A 组件中提供的 name 变量,那么在组件 B 中,就可以直接通过 this.name 访问这个变量了,它的值也是 xiaoming
。
这就是 provide / inject API 最核心的用法。
需要注意的是:
provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
那provide/inject
有什么用呢?
使用场景
1. 替代 Vuex
在稍微大点的vue
项目中,我们一般使用 Vuex
做状态管理,它是一个专为 Vuejs
开发的状态管理模式。 使用Vuex
目的是跨组件通信、全局数据维护、多人协同开发。比如:用户的登录信息等全局的状态和数据。
当然,我们的目的并不是为了替代 Vuex
,而是为了演示如何用provide/inject
来实现vuex
的功能。
一般在 webpack
中使用 Vuejs
,都会有一个入口文件 main.js,里面通常导入了 Vue
、VueRouter
、ElementUI
等库,通常也会导入一个入口组件 app.vue
作为根组件。一个简单的 app.vue
可能只有以下代码:
<template>
<div>
<router-view></router-view>
</div>
</template>
<script>
export default {
}
</script>
使用 provide / inject
替代 Vuex
,就是在这个 app.vue
文件上做文章。
我们把 app.vue
理解为一个最外层的根组件,用来存储所有需要的全局数据和状态,甚至是计算属性(computed
)、方法(methods
)等。因为你的项目中所有的组件(包含路由),它的父组件(或根组件)都是 app.vue,所以我们把整个 app.vue 实例通过 provide
对外提供。
<template>
<div>
<router-view></router-view>
</div>
</template>
<script>
export default {
provide() {
return {
app: this
}
}
}
</script>
上面,我们把整个 app.vue
的实例 this
对外提供,命名为 app。接下来任何组件(包括路由),只要通过 inject
注入 app.vue
的 app
,都可以直接通过 this.app.xxx
来访问 app.vue
的 data
、computed
、methods
等内容。
app.vue
是整个项目第一个被渲染的组件,而且只会渲染一次,即使切换路由,·app.vue· 也不会被再次渲染,利用这个特性,很适合做一次性全局的状态数据管理,例如,我们将用户的登录信息保存起来:
<script>
export default {
provide() {
return {
app: this
}
},
data() {
return {
userInfo: null
}
},
methods: {
getUserInfo() {
// 这里通过 ajax 获取用户信息后,赋值给 this.userInfo,以下为伪代码
$.ajax('/user/info', data => {
this.userInfo = data
})
}
},
mounted() {
this.getUserInfo()
}
}
</script>
任何页面或组件,只要通过 inject
注入 app
后,就可以直接访问 userInfo
的数据了,比如:
<template>
<div>
{{ app.userInfo }}
</div>
</template>
<script>
export default {
inject: ['app']
}
</script>
除了直接使用数据,还可以调用方法。比如在某个页面里,修改了个人资料,这时一开始在 app.vue
里获取的 userInfo
已经不是最新的了,需要重新获取。可以这样使用:
<template>
<div>
{{ app.userInfo }}
</div>
</template>
<script>
export default {
inject: ['app'],
methods: {
changeUserInfo() {
// 这里修改完用户数据后,通知 app.vue 更新,以下为伪代码
$.ajax('/user/update', () => {
// 直接通过 this.app 就可以调用 app.vue 里的方法
this.app.getUserInfo()
})
}
}
}
</script>
只要理解了 this.app
是直接获取整个 app.vue
的实例后,使用起来就得心应手了。如果开发一个不是很复杂的项目,我们完全可以使用 provide / inject
来实现全局数据的管理。而不需要配置复杂的Vuex
。
2. 独立组件使用 provide / inject
独立组件使用provide / inject
,主要是具有联动关系的组件。比如要开发一个具有数据校验功能的表单组件 Form
,它其实是两个组件,一个是 Form
,一个是 FormItem
,FormItem
是 Form
的子组件,它需要获取 Form
组件上的一些特性(props
),所以就需要得到父组件 Form
实例。
这里的难点是Form
和 FormItem
不一定是父子关系,中间很可能间隔了其它组件,所以不能单纯使用 $parent
来向上获取实例。
再没有provide / inject
这对api
之前,一种比较可行的方案是用计算属性动态获取:
computed: {
form () {
let parent = this.$parent;
while (parent.$options.name !== 'Form') {
parent = parent.$parent;
}
return parent;
}
}
每个组件都可以设置 name
选项,作为组件名的标识,利用这个特点,通过向上遍历,直到找到需要的组件。
这个方法虽然可行,但是相比一个 provide/inject
来说太不优雅了。如果用 inject
,可能只需要一行代码:
export default {
inject: ['form']
}
派发与广播——自行实现 dispatch
和 broadcast
provide / inject
API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系。然后有两种场景它不能很好的解决:
- 父组件向子组件(支持跨级)传递数据;
- 子组件向父组件(支持跨级)传递数据;
这种父子(含跨级)传递数据的通信方式,Vue
并没有提供原生的 API 来支持,而是推荐使用大型数据状态管理工具 Vuex
,但是我们开发独立组件是无法使用的,那有没有另外的方式呢?
$on
与 $emit
$emit
会在当前组件实例上触发自定义事件,并传递一些参数给监听器的回调,一般来说,都是在父级调用这个组件时,使用 @on
的方式来监听自定义事件的,比如在子组件中触发事件:
// child.vue,部分代码省略
export default {
methods: {
handleEmitEvent () {
this.$emit('test', 'Hello Vue.js');
}
}
}
在父组件中监听由 child.vue
触发的自定义事件 test:
<!-- parent.vue,部分代码省略-->
<template>
<child-component @test="handleEvent">
</template>
<script>
export default {
methods: {
handleEvent (text) {
console.log(text); // Hello Vue.js
}
}
}
</script>
这里看似是在父组件 parent.vue
中绑定的自定义事件 test
的处理句柄,然而事件 test
并不是在父组件上触发的,而是在子组件 child.vue
里触发的,只是通过 v-on
在父组件中监听。
既然是子组件自己触发的,那它自己也可以监听到,这就要使用 $on
来监听实例上的事件。也就是,组件在自己内部使用 $emit
触发事件,并在内部用 $on
监听它。
<template>
<div>
<button @click="handleEmitEvent">触发自定义事件</button>
</div>
</template>
<script>
export default {
methods: {
handleEmitEvent() {
// 在当前组件上触发自定义事件 test,并传值
this.$emit('test', 'Hello Vue.js')
}
},
mounted() {
// 监听自定义事件 test
this.$on('test', text => {
window.alert(text)
})
}
}
</script>
$on
监听了自己触发的自定义事件 test
,因为有时不确定何时会触发事件,一般会在 mounted
或 created
钩子中来监听。
从上面的示例中,这绝对是多此一举的,因为大可在 handleEmitEvent
里直接写 window.alert(text)
,没必要绕一圈。
之所以显得多此一举,是因为 handleEmitEvent
是当前组件内的 <button>
调用的,如果这个方法不是它自己调用,而是其它组件调用的,那这个用法就大有可为了。
$on
和$emit
在Vue3
中弃用了,但是官方推荐了一个库:mitt,可以达到同样的效果。
实现 dispatch 和 broadcast 方法
尽管我们可以通过新建一个vue
实例,并通过vue
实例上的$on
和$emit
来实现数据的传递,类似于eventBus
。
但是在独立组件开发中是不推荐这样做的,因为它又引入了一个新的vue
实例来作为数据中转,我们想要的是直接操作组件实例来达到数据通信的效果。
在 Vue 1
中,提供了两个方法:$dispatch
和 $broadcast
,前者用于向上级派发事件,只要是它的父级(一级或多级以上),都可以在组件内通过 $on
监听到。后者相反,是由上级向下级广播事件的。
这两个方法虽然看起来很好用,但是在 Vue 2
中都废弃了,官方给出的解释是:
因为基于组件树结构的事件流方式有时让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱。
虽然在业务开发中,它没有 Vuex
这样专门管理状态的插件清晰好用,但对独立组件(库)的开发,绝对是福音。因为独立组件一般层级并不会很复杂,并且剥离了业务,不会变的难以维护。
我们要实现的 dispatch
和 broadcast
方法,将具有以下功能:
- 在子组件调用
dispatch
方法,向上级指定的组件实例上触发自定义事件,并传递数据,且该上级组件已预先通过$on
监听了这个事件; - 相反,在父组件调用
broadcast
方法,向下级指定的组件实例上触发自定义事件,并传递数据,且该下级组件已预先通过$on
监听了这个事件。
实现这对方法的关键点在于,如何正确地向上或向下找到对应的组件实例,并在它上面触发方法。
在寻找组件实例上,我们的惯用伎俩就是通过遍历来匹配组件的 name
选项,在独立组件里,每个组件的 name
值应当是唯一的。
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
parent.$emit.apply(parent, [eventName].concat(params));
}
},
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params);
}
}
};
该方法可能在很多组件中都会使用,复用起见,我们封装在混合mixins
里。那它就这么使用:
import Emitter from '../mixins/emitter.js'
export default {
mixins: [ Emitter ],
methods: {
handleDispatch () {
this.dispatch();
},
handleBroadcast () {
this.broadcast();
}
}
}
这两个方法都接收了三个参数,第一个是组件的 name
值,用于向上或向下递归遍历来寻找对应的组件,第二个和第三个就是上文分析的自定义事件名称和要传递的数据。
现在有 A.vue 和 B.vue 两个组件,其中 B
是 A
的子组件,中间可能跨多级,在 A
中向 B
通信:
<!-- A.vue -->
<template>
<button @click="handleClick">触发事件</button>
</template>
<script>
import Emitter from '../mixins/emitter.js'
export default {
name: 'componentA',
mixins: [Emitter],
methods: {
handleClick() {
this.broadcast('componentB', 'on-message', 'Hello Vue.js')
}
}
}
</script>
// B.vue
export default {
name: 'componentB',
created () {
this.$on('on-message', this.showMessage);
},
methods: {
showMessage (text) {
window.alert(text);
}
}
}
找到任意组件实例——findComponents 系列方法
上面已经介绍了两种组件间通信的方法:provide / inject
和 dispatch / broadcast
。
它们都有各自的使用场景和局限,比如前者多用于子组件获取父组件的状态,后者常用于父子组件间通过自定义事件通信。
现在介绍第三种通信方式:findComponents
系列方法,它并非 Vue
内置,而是需要自行实现,以工具函数的形式来使用,它是一系列的函数,可以说是组件通信的终极方案。
findComponents
系列方法最终都是返回组件的实例,进而可以读取或调用该组件的数据和方法。
它适用于以下场景:
- 由一个组件,向上找到最近的指定组件;
- 由一个组件,向上找到所有的指定组件;
- 由一个组件,向下找到最近的指定组件;
- 由一个组件,向下找到所有指定的组件;
- 由一个组件,找到指定组件的兄弟组件。
5 个不同的场景,对应 5 个不同的函数,实现原理也大同小异。都是通过递归、遍历,找到指定组件的 name
选项匹配的组件实例并返回。
向上找到最近的指定组件——findComponentUpward
function findComponentUpward (context, componentName) {
let parent = context.$parent;
let name = parent.$options.name;
while (parent && (!name || [componentName].indexOf(name) < 0)) {
parent = parent.$parent;
if (parent) name = parent.$options.name;
}
return parent;
}
export { findComponentUpward };
findComponentUpward
接收两个参数,第一个是当前上下文,比如你要基于哪个组件来向上寻找,一般都是基于当前的组件,也就是传入 this
;第二个参数是要找的组件的 name
。
比如,有组件 A
和组件 B
,A
是 B
的父组件,在 B
中获取和调用 A
中的数据和方法:
<!-- component-a.vue -->
<template>
<div>
组件 A
<component-b></component-b>
</div>
</template>
<script>
import componentB from './component-b.vue'
export default {
name: 'componentA',
components: { componentB },
data() {
return {
name: 'xiaoming'
}
},
methods: {
sayHello() {
console.log('Hello, Vue.js')
}
}
}
</script>
<!-- component-b.vue -->
<template>
<div>组件 B</div>
</template>
<script>
import { findComponentUpward } from '../utils/index'
export default {
name: 'componentB',
mounted() {
const comA = findComponentUpward(this, 'componentA')
if (comA) {
console.log(comA.name) // xiaoming
comA.sayHello() // Hello, Vue.js
}
}
}
</script>
使用起来很简单,只要在需要的地方调用 findComponentUpward
方法就行,第一个参数一般都是传入 this,即当前组件的上下文(实例)。
向下找到最近的指定组件——findComponentDownward
function findComponentDownward (context, componentName) {
const childrens = context.$children;
let children = null;
if (childrens.length) {
for (const child of childrens) {
const name = child.$options.name;
if (name === componentName) {
children = child;
break;
} else {
children = findComponentDownward(child, componentName);
if (children) break;
}
}
}
return children;
}
export { findComponentDownward };
context.$children
得到的是当前组件的全部子组件,所以需要遍历一遍,找到有没有匹配到的组件 name
,如果没找到,继续递归找每个 $children 的 $children,直到找到最近的一个为止。
找到指定组件的兄弟组件——findBrothersComponents
function findBrothersComponents (context, componentName, exceptMe = true) {
let res = context.$parent.$children.filter(item => {
return item.$options.name === componentName;
});
let index = res.findIndex(item => item._uid === context._uid);
if (exceptMe) res.splice(index, 1);
return res;
}
export { findBrothersComponents };
findBrothersComponents
多了一个参数 exceptMe
,是否把本身除外,默认是 true。
寻找兄弟组件的方法,是先获取 context.$parent.$children
,也就是父组件的全部子组件,这里面当前包含了本身,所有也会有第三个参数 exceptMe
。
Vue
在渲染组件时,都会给每个组件加一个内置的属性 _uid
,这个 _uid
是不会重复的,借此可以从一系列兄弟组件中把自己排除掉。
其他函数就不做介绍,只有你开发过 Vue
独立组件,才会明白这 5 个函数的强大之处。
总结
本文介绍了在开发独立组件时常用的三种通信方式:
1、provide / inject
:通常是子组件用来获取父组件的状态数据。所有的子组件,不管跨多少层级,都能获取到父组件的数据。但是这种方式子组件是被动的,它不能主动的把数据传递给父组件;
2、dispatch / broadcast
:这种传递方式可以让父子组件都能主动的传递数据,解决了provide / inject
比较被动的局限, 这样就数据的传递就更加灵活;
3、findComponents
:这是一种终结方式,其实所有通信方式都是找到对应的组件实例,然后调用组件的某个方法实现传参。即使是vue
内置的ref
,它也是组件实例的引用。