组件通信

Vue.js 基础内容的讲解中,我们知道了什么是 Vue 组件,了解了它的功能,那么在实际的项目开发中,实现的页面是如何和组件对应起来的呢?下面举一个例子来说明这个问题。

以常见的登录页面为例,一般由用户名输入框、密码输入框以及登录按钮所组成。在用户输入完信息后,可以单击 “登录” 按钮进行登录,注意 “登录” 按钮一开始是不可单击的。

我们可以把输入框抽离成一个组件,“登录” 按钮也抽离成一个组件,它们共同存在于登录页面这个父组件中。这样,这些组件就有了关联,当我们在输入框中输入登录信息之后,就可以告诉 “登录” 按钮组件去更新自己的不可单击状态,当我们单击 “登录” 按钮时,就需要拿到输入框的登录信息来进行登录。所以,我们会发现,组件之间并不是孤立的,它们之间是需要通信的,正是这种组件间的相互通信才构成了页面上用户行为交互的过程。

组件通信概述

我们可以把所有页面都抽象成若干个组件,它们之间有父子关系的组件、兄弟关系的组件,可以使用图 3-9 来表示组件之间的关系。

image 2024 02 21 15 52 54 339
Figure 1. 图3-9 组件之间的关系

所有 Vue 组件的关系:

  • A 组件和 B 组件、B 组件和 C 组件、B 组件和 D 组件形成了父子关系。

  • C 组件和 D 组件形成了兄弟关系。

  • A 组件和 C 组件、A 组件和 D 组件形成了隔代关系(其中的层级可能是多级,即隔了多代)。

在明确了它们之间的关系之后,就需要理解它们之间如何通信,或者叫作如何传值。下面就来逐一讲解。

先使用代码来实现上面的 ABCD 四组组件的关系,如示例代码3-2-1所示。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
    <title>组件通信</title>
    <script src="https://unpkg.com/vue@3.2.28/dist/vue.global.js"></script>
    <style type="text/css">
        #app {
            text-align: center;
            line-height: 2;
        }
    </style>
</head>
<body>
    <!--根实例挂载的 DOM 对象 app-->
    <div id="app">
        {{str}}
        <component-b />
    </div>

    <script type="text/javascript">
        // 定义一个名为 componentC 的局部子组件
        const componentC = {
            data() {
                return {
                    str: 'I am C'
                }
            },
            template: '<span>{{str}}</span>'
        }

        // 定义一个名为 componentD 的局部子组件
        const componentD = {
            data() {
                return {
                    str: 'I am D'
                }
            },
            template: '<span>{{str}}</span>'
        }

        // 定义一个名为 componentB 的父组件,也是一个局部组件
        const componentB = {
            data() {
                return {
                    str: 'I am B'
                }
            },
            template: '<div>'+
                '{{str}}'+
                '<div>'+
                    '<component-c />,'+
                    '<component-d />'+
                '</div>'+
                '</div>',
            // 利用 components 可以将之前定义的C、D组件挂载到B组件上,component-c 和 component-d 是C/D组件的名称,
            // 对应 template 中的 <component-c/> 和 <component-d />
            components: {
                'component-c': componentC,
                'component-d': componentD
            }
        }

        // 定义一个根实例 vmA
        const vmA = Vue.createApp({
            data() {
                return {str: "I am A"}
            },
            // 将父组件B挂载到实例A中component-b对应的#app内的<component-b/>
            components: {
                'component-b': componentB
            }
        }).mount("#app")
    </script>
</body>
</html>

为了便于理解,上面的代码为完整的 index.html 代码,读者可以直接在浏览器中运行这段代码,运行结果如图3-10所示。

image 2024 02 21 16 25 08 024
Figure 2. 图3-10 组件通信

父组件向子组件通信

父组件向子组件通信可以理解成:

  • 父组件向子组件传值。

  • 父组件调用子组件的方法。

props

利用 props 属性可以实现父组件向子组件传值,如示例代码3-2-2所示。

示例代码3-2-2 props通信
// 定义一个名为 componentC 的局部子组件
const componentC = {
    props: ['info'], // 在子组件中使用props接收
    data() {
        return {
            str: 'I am C'
        }
    },
    template: '<span>{{str}} : {{info}}</span>'
}
...
// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    data() {
        return {
            str: 'I am B',
            // 传给子组件的值
            info: 'data from B'
        }
    },
    template: '<div>'+
        '{{str}}'+
        '<div>'+
            '<component-c :info=\'info\'/>,'+
            '<component-d />'+
        '</div>'+
        '</div>',
    // 利用 components 可以将之前定义的C、D组件挂载到B组件上,component-c 和 component-d 是C/D组件的名称,
    // 对应 template 中的 <component-c/> 和 <component-d />
    components: {
        'component-c': componentC,
        'component-d': componentD
    }
}

在上面的代码中,B 组件内的 data 中定义了 info 属性,准备好数据,在 template 中使用 C 组件时,通过 <component-c :info=\'info\'/> 将值传给 C 组件。这里采用的是 v-bind 的简写指令,等号后面的 info 就是在 data 中定义的 info,它被称为动态值,它是响应式的。当然,props 也可以直接接收一个静态值,代码如下:

<component-c info='data from B' />

需要注意的是,如果静态值是一个字符串,则可以省去 v-bind(即可以不用冒号),但是如果静态值是非字符串类型的值,则必须采用 v-bind 来绑定传入。

然后,在 C 组件中使用 props 接收 infoprops:['info'] 是一个由字符串组成的数组,表示可以接收多个 props,数组中的每个值和传入时的值要对应。然后,在子组件中使用 this.info 得到这个值,无须在 data 中定义,最后就可以使用插值表达式 {{info}} 来显示。

props 不仅可以传字符串类型的值,像数组、对象、布尔值都可以传递,在传递时 props 也可以定义成数据校验的形式,以此来限制接收的数据类型,提升规范性,避免得到意想之外的值,代码如下:

props: {
    info: {
        type: String,  // 限制为字符串类型
        default: ''    // 默认值
    }
}

props 验证失败时,控制台将会产生一个警告。所以,要么就是用 props:['info'] 来接收,要么添加数据格式校验,以严格的数据格式来传值。

type 可以是下列原生构造函数中的一个:

  • String

  • Number

  • Boolean

  • Array

  • Object

  • Date

  • Function

  • Symbol

另外,type 还可以是一个自定义的构造函数,并且通过 instanceof 来进行检查确认。例如,给定下列现成的构造函数,如示例代码3-2-3所示。

示例代码3-2-3 props类型
class Person (firstName, lastName) {
    constructor {
        this.firstName = firstName
        this.lastName = lastName
    }
}
// 在props接收时,这样设置
props: {
    author: Person
}

另外,需要说明的是,如果 props 传递的是一个动态值,每次父组件的 info 发生更新时,子组件中接收的 props 都将会刷新为最新的值。这意味着,我们不应该在一个子组件内部改变 props,如果这样做了,Vue 会在浏览器的控制台中发出警告。例如,在子组件的 mounted 方法中调用:

this.info= 'abc'

可以看到控制台上的警告,如图3-11所示。

image 2024 02 21 16 55 10 154
Figure 3. 图3-11 控制台上的警告信息

Vue 中父传子的方式形成了一个单向下行绑定,叫作 单向数据流。父级 props 的更新会向下流动到接收这个 props 的子组件中,触发对应的更新,但是反过来则不行。这样可以防止有多个子组件的父组件内的值被修改时,无法查找到哪个子组件修改的场景,从而导致应用中的数据流向无法清晰地追溯。

如果需要在子组件中监听 props 的变化,可以直接在子组件中使用监听器 watch,代码如下:

props: ['info'],
watch: {
    info(v) {
        console.log(v)
    }
}

如果遇到确实需要改变 props 值的应用场合,则可以采用下面的解决办法:

  • 使用 props 来传递一个初始值,该子组件接下来希望将其作为一个本地的 props 数据来使用,在这种情况下,最好定义一个本地的 data 属性,并将这个 props 用作其初始值,代码如下:

    props: ['info'],
    data() {
        return {
            myInfo: this.info
        }
    }
  • 使用 props 时,把它当作初始值,使用的时候需要进行一下转换。在这种情况下,最好使用这个 props 的值来定义一个计算属性:

    props: ['info'],
    computed: {
        myInfo() {
            return this.info.trim().toLowerCase()
        }
    }

props 机制是在 Vue 中非常常用的传值方法,所以掌握好是非常重要的,那么如何实现父组件调用子组件的方法呢?

$refs

利用 Vue 实例的 $refs 属性可以实现父组件调用子组件的方法,在之前的完整代码中,我们只修改部分代码,如示例代码3-2-4所示。

// 定义一个名为 componentD 的局部子组件
const componentD = {
    data() {
        return {
            str: 'I am D'
        }
    },
    template: '<span>{{str}}</span>',
    methods: {
        dFunc() {
            console.log('D的方法')
        }
    }
}

// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    data() {
        return {
            str: 'I am B',
            // 传给子组件的值
            info: 'data from B'
        }
    },
    template: '<div>'+
        '{{str}}'+
        '<div>'+
            '<component-c :info=\'info\'/>,'+
            '<component-d ref=\'componentD\'/>'+
        '</div>'+
        '</div>',
    // 利用 components 可以将之前定义的C、D组件挂载到B组件上,component-c 和 component-d 是C/D组件的名称,
    // 对应 template 中的 <component-c/> 和 <component-d />
    components: {
        'component-c': componentC,
        'component-d': componentD
    },
    mounted() {
        this.$refs.componentD.dFunc()
    }
}

当运行这段代码时,若在控制台上看到 console.log('D的方法'),就说明运行正常。当父组件想要调用子组件的方法时,首先需要给子组件绑定一个 ref 值(即 componentD),然后就可以在父组件当前的实例中通过 this.$refs.componentD 得到子组件的实例,拿到子组件的实例之后,就可以调用子组件定义在 methods 中的方法了。

需要说明的是,在 Vue 中,也可以给原生的 DOM 元素绑定 ref 值,这样通过 this.$refs 拿到的就是原生的 DOM 对象。代码如下:

<button ref="btn"></button>

ref 用于注册元素或子组件的引用。

使用选项式 API,引用将被注册在组件的 this.$refs 对象里:

<!-- 存储为 this.$refs.p -->
<p ref="p">hello</p>

使用组合式 API,引用将存储在与名字匹配的 ref 里:

<script setup>
import { ref } from 'vue'

const p = ref()
</script>

<template>
  <p ref="p">hello</p>
</template>

注意:变量名称要一致。

父组件可以访问子组件的 所有公开的 实例属性方法,其中包括子组件的 datacomputedmethods,以及 ref 引用的 DOM 元素或组件方法。

子组件向父组件通信

在前面的代码中,我们曾经尝试了直接修改父组件的 props,但是会报错,所以需要有一个新的机制来实现子组件向父组件通信,可以理解为下面两点:

  • 子组件向父组件传值。

  • 子组件调用父组件的方法。

与父组件向子组件通信不同的是,子组件调用父组件方法的同时就可以向父组件传值,使用 $emit 方法和自定义事件。

$emit

$emit 方法的主要作用是触发当前组件实例上的事件,所以子组件调用父组件方法就可以理解成子组件触发了绑定在父组件上的自定义事件。在之前的完整代码中,我们只修改部分代码,如示例代码3-2-5所示。

// 定义一个名为 componentC 的局部子组件
const componentC = {
    data() {
        return {
            str: 'I am C'
        }
    },
    template: '<span>{{str}}</span>',
    // 在子组件的 mounted 方法中调用 this.$emit 来触发自定义事件
    mounted() {
        this.$emit('myFunction', 'hi')
    }
}

// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    template: '<div>'+
        '<div>'+
        // 将 myFunction 方法通过 v-on 传入子组件
        '<component-c @myFunction=\'myFunction\' />,'+
        '</div>'+
        '</div>',
    components: {
        'component-c': componentC
    },
    methods: {
        // 定义父组件需要被子组件调用的方法
        myFunction(data) {
            console.log('来自子组件的调用', data)
        }
    }
}

首先需要在父组件的 methods 中定义 myFunction 方法,然后在 template 中使用 <component-c/> 组件时,将 myFunction 传入子组件,这里采用的是 v-on 指令(即简写 @myFunction)。在前面的章节中,我们使用 v-on 来监听原生 DOM 绑定的事件,例如 @click,这里的 @myFunction 实际上就是一个自定义事件。

然后,在子组件 C 中,通过 this.$emit('myFunction','hi') 就可以通知父组件对应的 myFunction 方法,第一个参数就是父组件中 v-on 指令的参数值(即 @myFunction),第二个参数是需要传给父组件的数据。如果在控制台中看到有 console.log('来自子组件的调用','hi'),就说明调用成功了。

这样,在完成子组件调用父组件方法的同时,也向父组件传递了数据,这里是使用 $emit 方法来实现的。子组件调用父组件还可以用其他方式来实现,接下来继续介绍。

$parent

这种方法比较直观,可以直接操作父子组件的实例,在子组件中直接通过 this.$parent 获取父组件的实例,从而调用父组件中定义的方法,类似于前文介绍的通过 $refs 获取子组件的实例。在之前的完整代码中,我们只修改部分代码,如示例代码3-2-6所示。

// 定义一个名为 componentC 的局部子组件
const componentC = {
    data() {
        return {
            str: 'I am C'
        }
    },
    template: '<span>{{str}}</span>',
    mounted() {
        // 直接采用 $parent 方法进行调用
        this.$parent.myFunction('$parent 方法调用')
    }
}

// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    template: '<div>'+
        '<div>'+
        '<component-c />,'+
        '</div>'+
        '</div>',
    components: {
        'component-c': componentC
    },
    methods: {
        // 定义父组件需要被子组件调用的方法
        myFunction(data) {
            console.log('来自子组件的调用', data)
        }
    }
}

需要注意的是,采用 $parent 方法调用时,在父组件的 template 中使用 <component-c /> 时,无须采用 v-on 方法传入 myFunction,因为 this.$parent 可以获取父组件的实例,所以其内定义的方法都可以调用。

但是,Vue 并不推荐以这种方法来实现子组件调用父组件,由于一个父组件可能会有多个子组件,因此这种方法对父组件中的状态维护是非常不利的,当父组件的某个属性被改变时,无法以循规溯源的方式去查找到底是哪个子组件改变了这个属性。因此,请有节制地使用 $parent 方法,它的主要目的是作为访问组件的应急方法。推荐使用 $emit 方法实现子组件向父组件的通信。

下面使用一张图来大致总结一下父子组件通信的方式,如图3-12所示。

image 2024 02 21 17 52 44 097
Figure 4. 图3-12 父子组件之间的通信

父子组件的双向数据绑定与自定义 v-model

在前面的章节中我们曾经讲过,父组件可以使用 props 给子组件传值,当父组件 props 更新时也会同步给子组件,但是子组件无法直接修改父组件的 props,这其实是一个单向的过程。但是在一些情况下,我们可能会需要对一个 props 进行 “双向绑定”,即子组件的值更改后,父组件也同步进行更改。

在之前的 2.2 节中,我么了解到 v-model 指令主要是结合一些原生的表单元素 <input> 等使用,对于我们 自定义的组件,也可以用 v-model 来实现组件通信,在之前的完整代码中,我们只修改部分代码,如示例代码3-2-7所示。

// 子组件
const componentD = {
    props: ['info'],
    template: '<span>'+
                '子组件的 info:{{info}}'+
                '<button @click="clickCallback">点我换Tom</button>'+
              '</span>',
    methods: {
        clickCallback() {
            this.$emit('update:info', 'Tom')
        }
    }
}

// 父组件
const componentB = {
    data() {
        return {
            info: 'Jack'
        }
    },
    template: '<div>'+
        '父组件的info:{{info}}'+
        '<div>'+
        '<component-d v-model:info="info"/>'+
        '</div>'+
        '</div>',

    components: {
        'component-d': componentD
    }
}

在父组件的 data 中定义了 info 属性,并且通过 v-model 的方式传给了子组件,代码如下:

<component-d v-model:info="info" />

这里使用了 $emit 方法,在子组件中,给按钮 button 绑定了一个单击事件,在事件回调函数中,采用如下代码:

this.$emit('update:info','Tom')

这样更新就会同步到父组件的 props 中,调用 $emit 方法实际上就是触发一个父组件的方法,这里的 update 是固定写法,代表更新,而 :info 表示更新 info 这个 prop,第二个参数 Tom 表示更新的值。其中,v-model 的配置含义如图 3-13 所示。

image 2024 02 21 18 05 44 866
Figure 5. 图3-13 v-model 的配置

在单击按钮之后,可以看到父组件中的 info 被更新成了 Tom,子组件的 info 也更新成了 Tom,这就完成了父子组件的 “双向绑定”。

非父子关系组件的通信

对于父子组件之间的通信,前面介绍的两种方式是完全可以实现的,但是对于不是父子关系的两个组件,那么又该如何实现通信呢?非父子关系组件的通信分为两种方式:

  • 拥有同一父组件的两个兄弟组件的通信。

  • 没有任何关系的两个独立组件的通信。

兄弟组件的通信

对于具有同一个父组件 B 的兄弟组件 CD 而言,可以借助父组件 B 这个桥梁,实现兄弟组件的通信。在之前的完整代码中,我们只修改部分代码,如示例代码 3-2-8 所示。

示例代码3-2-8 兄弟组件的通信
// 定义一个名为 componentC 的局部子组件
const componentC = {
    props: ['infoFromD'],
    template: '<span>收到来自D的消息:{{infoFromD}}</span>'
}

// 定义一个名为 componentD 的局部子组件
const componentD = {
    template: '<span><button @click="clickCallback">点我换通知C</button></span>',
    methods: {
        clickCallback() {
            // 先通知父元素
            this.$emit('saidToC', 'I am D')
        }
    }
}

// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    data() {
        return {
            infoFromD: ''
        }
    },
    template: '<div>'+
        '<div>'+
        '<component-c :infoFromD="infoFromD" />,'+
        '<component-d @saidToC="saidToC" />'+
        '</div>'+
        '</div>',
    components: {
        'component-c': componentC,
        'component-d': componentD
    },
    methods: {
        saidToC(data) {
            console.log('来自D组件的调用', data)
            // 在父元素中通过 props 的更新来更新 C 组件的数据
            this.infoFromD = data;
        }
    }
}

D 组件中通过 $emit 调用父组件的方法,同时在父组件中修改 data 中的 infoFromD,同时也影响到了作为 props 传递给 C 组件的 infoFromD,这就实现了兄弟组件的通信。

但是,这种方法总让人觉得比较绕,假如两个组件没有兄弟关系,那么又该采用什么方法来通信呢?

事件总线 EventBus 和 mitt

Vue 2 中,可以采用 EventBus 这种方法,实际上就是将沟通的桥梁换成自己,同样需要有桥梁作为通信中继。就像是所有组件共用相同的事件中心,可以向该中心发送事件或接收事件,所有组件都可以上下平行地通知其他组件。在之前的完整代码中,我们只修改部分代码,并用 Vue 2 的语法来写,如示例代码 3-2-9 所示。

示例代码3-2-9 EventBus 通信
// 定义一个名为 componentC 的局部子组件
const componentC = {
    data() {
        return {
            infoFromD: ''
        }
    },
    template: '<span>收到来自D的消息:{{infoFromD}}</span>',
    mounted: function () {
        this.$EventBus.$on('eventBusEvent', function (data) {
            this.infoFromD = data;
        }).bind(this)
    }
}

// 定义一个名为 componentD 的局部子组件
const componentD = {
    template: '<span><button @click="clickCallback">点我换通知C</button></span>',
    methods: {
        clickCallback() {
            // 先通知父元素
            this.$EventBus.$emit('eventBusEvent', 'I am D')
        }
    }
}

// 定义一个名为 componentB 的父组件,也是一个局部组件
const componentB = {
    data() {
        return {
            str: 'I am B'
        }
    },
    template: '<div>'+
        '<component-c />,'+
        '<component-d />'+
        '</div>',

    components: {
        'component-c': componentC,
        'component-d': componentD
    }
}

// 定义中央事件总线
var EventBus = new Vue(); // vue2语法

// 将中央事件总线赋值给 Vue.prototype,这样所有组件都能访问到了
Vue.prototype.$EventBus = EventBus;

// 定义一个根实例 vmA
var vmA = new Vue({ // vue2语法
    el: '#app',
    components: {
        'component-b': componentB
    }
})

在上面的代码中用到的 CD 组件,它们之间没有任何关系,在 C 组件的 mounted 方法中通过 this.$EventBus.$on('eventBusEvent',function(){…​}) 实现了事件的监听,然后在 D 组件的单击回调事件中通过 this.$EventBus.$emit('eventBusEvent') 实现了事件的触发,eventBusEvent 是一个全局的事件名。

接着,通过 new Vue() 实例化了一个 Vue 的实例,这个实例是一个没有任何方法和属性的空实例,称其为:中央事件总线(EventBus),然后将其赋值给 Vue.prototype.$EventBus,使得所有的组件都能够访问到。

$on 方法和 $emit 方法其实都是 Vue 实例提供的方法,这里的关键点就是利用一个空的 Vue 实例来作为桥梁,实现事件分发,它的工作原理是发布/订阅方法,通常称为 Pub/Sub,也就是发布和订阅的模式。

Vue 3 中,由于取消了 Vue 中全局变量 Vue.prototype.$EventBus 这种写法,所以采用 EventBus 这种事件总线来进行通信已经无法使用,取而代之,可以采用第三方事件总线库 mitt

在页面中引入 mittJavaScript 文件或者在项目中采用 import 方式引入 mitt,代码如下:

<script src="https://unpkg.com/mitt/dist/mitt.umd.js"></script>
或
import mitt from 'mitt'

mitt 的使用方法和 EventBus 非常类似,同样是基于 Pub/Sub 模式,并且更加简单,可以在需要进行通信的地方直接使用,如示例代码3-2-10所示。

示例代码3-2-10 mitt的使用
const emitter = mitt()

// 子组件
const componentC = {
    data () {
        return {
            infoFromD: ''
        }
    },
    template: '<span>收到来自D的消息:{{infoFromD}}</span>',
    mounted() {
        emitter.on('eventBusEvent', (data)=> {
            this.infoFromD = data;
        })
    }
}

// 子组件
const componentD = {
    template: '<span><button @click="clickCallback">点我换通知C</button></span>',
    methods: {
        clickCallback() {
            emitter.emit('eventBusEvent', 'I am D')
        }
    }
}

从上面的代码可以看到,与 EventBus 相比,mitt 的方式无须创建全局变量,使用起来更加简单。

事件总线的方式进行通信使用起来非常简单,可以实现任意组件之间的通信,其中没有多余的业务逻辑,只需要在状态变化组件触发一个事件,随后在处理逻辑组件监听该事件即可。这种方法非常适合小型的项目,但是对于一些大型的项目,要实现复杂的组件通信和状态管理,就需要使用 Vuex 了。

Vue 3 中,$on 方法被移除,因此如果想在 Vue 3 中实现类似于 Vue 2 中的 EventBus(事件总线)功能,可以通过使用 mitt 库实现 EventBus 方式来实现跨组件之间的事件通信:

mitt 是一个轻量级的事件总线库,它非常适合在 Vue 3 中实现事件通信。可以通过它来实现跨组件的事件监听和触发。

  1. 安装 mitt

    npm install mitt
  2. 创建 EventBus

    你可以创建一个单独的 EventBus 文件,并在 Vue 3 应用中使用它来触发和监听事件。

    eventBus.js
    import mitt from 'mitt';
    
    const eventBus = mitt();
    
    export default eventBus;
  3. 在组件中使用 EventBus

组件 A(发送事件)
<template>
  <button @click="sendMessage">Send Message</button>
</template>

<script>
import eventBus from './eventBus';

export default {
  methods: {
    sendMessage() {
      eventBus.emit('message', 'Hello from Component A');
    }
  }
}
</script>
组件 B(接收事件)
<template>
  <p>{{ message }}</p>
</template>

<script>
import { ref, onMounted, onUnmounted } from 'vue';
import eventBus from './eventBus';

export default {
  setup() {
    const message = ref('');

    // 监听事件
    const handleMessage = (msg) => {
      message.value = msg;
    };

    onMounted(() => {
      eventBus.on('message', handleMessage);
    });

    onUnmounted(() => {
      eventBus.off('message', handleMessage);
    });

    return { message };
  }
}
</script>

在这个示例中:

  • 组件 A 通过 eventBus.emit 触发了一个事件。

  • 组件 B 通过 eventBus.on 监听事件,当事件发生时更新 message

这种方式和 Vue 2 中的事件总线模式非常相似,但更加简洁和轻量。

provide/inject

通常,当需要从父组件向子组件传递数据时,我们使用 props。想象一下这样的结构:有一些深度嵌套的组件,深层的子组件只需要父组件的部分内容。在这种情况下,如果仍然将 props 沿着组件链逐级传递下去,可能会很麻烦。

对于这种情况,我们可以使用一对 provide(提供)和 inject(注入)。无论组件层次结构有多深,父组件都可以作为其所有子组件的依赖提供者。这个特性有两个部分:父组件有一个 provide 选项来提供数据,子组件有一个 inject 选项来开始使用这些数据,如图 3-14 所示。

image 2024 02 21 19 23 37 377
Figure 6. 图3-14 provide 和 inject

我们有这样的层次结构:

Root
└─ TodoList
    ├─ TodoItem
    └─ TodoListFooter
        ├─ ClearTodosButton
        └─ TodoListStatistics

如果要将 Todo-iTem 的长度直接传递给 TodoListStatistics,则要将 prop 逐级传递下去:TodoList→TodoListFooter→TodoListStatistics。通过 provide/inject 方法,我们可以直接执行以下操作,如示例代码 3-2-11 所示。

const app = Vue.createApp({})

app.component('todo-list', {
    data() {
        return {
            todos: ['Feed a cat', 'Buy tickets']
        }
    },
    provide: {
        user: 'John Doe'
    },
    template: `
        <div>
        {{ todos.length }}
        <!-- 模板的其余部分 -->
        </div>
    `
})

app.component('todo-list-statistics', {
    inject: ['user'],
    created() {
        console.log(this.user) // 注入 property: John Doe
    }
})

但是,如果我们尝试在此处提供一些组件的实例 data 属性,将是不起作用的,代码如下:

app.component('todo-list', {
    data() {
        return {
            todos: ['Feed a cat', 'Buy tickets']
        }
    },
    provide: {
        todoLength: this.todos.length
        // 将会导致错误 `Cannot read property 'length' of undefined`
    },
    ...
})

要访问组件实例 data 中的属性,我们需要将 provide 转换为返回对象的函数,代码如下:

app.component('todo-list', {
    data() {
        return {
            todos: ['Feed a cat', 'Buy tickets']
        }
    },
    provide() {
        return {
            todoLength: this.todos.length
        }
    },
    ...
})

这使我们能够更安全地继续开发该组件,而不必担心可能会更改/删除子组件所依赖的某些内容。这些组件之间的接口仍然是明确定义的,就像 props 一样。

实际上,可以将 inject 注入看作是 long range(跨组件)props,除了:

  • 父组件不需要知道哪些子组件使用它提供的 data 属性。

  • 子组件不需要知道注入的 data 属性来自哪里。

在上面的例子中,如果我们更改了 todos 的列表,这个变化并不会反映在注入 todoLength 属性中。这是因为默认情况下,provide/inject 绑定并不是响应式的。我们可以通过传递一个 ref 属性或 reactive 对象给 provide 来改变这种行为。在我们的例子中,如果想对祖先组件中的更改做出响应,则需要为提供的 todoLength 分配一个组合式 API computed 属性,如示例代码 3-2-12 所示。

app.component('todo-list', {
    ...
    provide() {
        return {
            todoLength: Vue.computed(() => this.todos.length)
        }
    }
})

app.component('todo-list-statistics', {
    inject: ['todoLength'],
    created() {
        console.log(this.todoLength.value) // Injected property: 5
    }
})

在这种情况下,任何对 todos.length 的改变都会被正确地反映在注入 todoLength 的组件中,我们会在后面的章节详细介绍组合式 API 的使用。