Vue组件数据通讯新姿势:$attrs 和 $listeners

发布于 大漠

学习Vue也有一段时间了,在项目中使用Vue也有好几个了,但Vue组件间的状态管理(数据通信)一直是自己的死穴。对于Vue组件间的数据通信,无外呼是父组件向子组件、子组件向父组件、兄弟组件以及嵌套组件之间的数据通信。而且组件之间的通信方式也有很多种。@Gongph的《Vue 父子组件通信的十种方式》一文就详细的介绍了Vue组件,指的是父子组件之间的数据通信就有差不多十种方式。但很多时候我们组件之间的数据通信不仅仅是停留在父子组件之间的数据通信。比如说还有兄弟组件和嵌套组件之间的数据通信。

如果我们抛开嵌套组件之间的数据通信,我们可以用简单的下图来描述Vue组件之间的数据通信:

事实上除了上图方式对数据进行通信之外,还有一些其他的方式,比如父组件获取子组件数据和事件可以通过:

  • 通过给子组件绑定ref属性来获取子组件实例
  • 通过this.$children获取子组件实例

对于子组件获取父组件数据和事件,可以通过:

  • 通过props传递父组件数据和事件,或者通过$emit$on实现事件传递
  • 通过ref属性,调用子组件方法,传递数据;通过props传递父组件数据和事件,或者通过$emit$on实现事件传递
  • 通过this.$parent.$data或者this.$parevent._data获取父组件数据,通过this.$parent执行父组件方法

对于兄弟组件之间数据通信和事件传递,可以通过:

  • 利用eventBus挂载全局事件
  • 利用$parent进行数据传递,$parent.$children调用兄弟组件事件

另外,复杂一点的,可以通过Vuex完成Vue组件数据通信。特别是多级嵌套组件间的数据通信。但如果仅仅是数据之间传递,而不做中间处理,使用Vuex有点浪费。不过,自Vue 2.4版本开始提供了另一种方法:

使用v-bind="$attrs"将父组件中不被认为props特性绑定的属性传递给子组件。

通常该方法会配合interiAttrs一起使用。之所以这样使用是因为两者的出现使得组件之间跨组件的通信在不依赖Vuex和eventBus的情况下变得简洁,业务清晰。

其实这也就是我们今天要了解的另一个知识点。多级嵌套组件之间,我们如何借助$attrs$listeners来实现数据之间的通信。

业务场景

刚才提到过,我们接下来要聊的是多级嵌套组件之间的数据通信。为了让事情不变得太过于复杂(因为太复杂,对于初学者而言不易于理解和学习)。这里我们就拿三级组件之间的嵌套来举例。比如我们有三个组件ComponentAComponentBComponentC,而且它们之间的关系是ComponentA > ComponentB > ComponentC>是包含关系),用下图来描述或许更易于明白他们之间的关系:

就三级嵌套的组件而言,他们的关系相对而言要简单一些:

  • ComponentA组件是ComponentB组件的父组件,他们的关系是父子关系
  • ComponentB组件是ComponentC组件的父组件,他们的关系也是父子关系
  • ComponentA组件是ComponentC组件的祖先组件,他们的关系是祖孙关系

对于这三个组件之间的数据通信,按照我们前面所掌握的知识,估计想到的是:

**props向下,$emit**向上。

也就是说,ComponentAComponentB可以通过props的方式向子组件传递,ComponentBComponentA通过在ComponentB组件中$emit向上发送事件,然后在ComponentA组件中$on的方式监听发送过来的事件。对于ComponentBComponentC两组件之间的通信也可以使用类似的方式。但对于ComponentA组件到ComponentC组件之间的通信,需要借助ComponentB组件做为中转站,当ComponentA组件需要把信息传递给ComponentC组件时,ComponentB接受ComponentA组件的信息,然后利用属性传递给ComponentC组件。

就此而言,这是一种解决方案,但如果我们嵌套的组件层级过多时将会导致代码繁琐,代码维护也较困难。

除了上述方式可以完成组件之间数据通信外,还有其他的方式,比如借助Vuex的全局状态共享;使用eventBus创建Vue的实例实现事件的监听和发布,从而实现组件之间的数据通信。但都过于太浪费,所以我们应该寻找其他更为简易的解决方案,其中文章开始提到的$attrs以及$listeners

简单地说,利用$attrs实现祖孙组件间的数据传递,$listeners实现祖孙组件间的事件监听。接下来看看怎么使用这两个特性来完成跨级嵌套组件之间的数据通信。

术语解释

在具体掌握$attrs$listeners是如何完成组件数据通信之前,先来简单地了解一下他们具体是什么?

Vue的官网对$attrs$listeners的描述分别是这样的:

$attrs的解释

包含了父作用域中不作为 props 被识别 (且获取) 的特性绑定 (classstyle 除外)。当一个组件没有声明任何 props 时,这里会包含所有父作用域的绑定 (classstyle 除外),并且可以通过 v-bind="$attrs" 传入内部组件 —— 在创建高级别的组件时非常有用。

$listeners的解释

包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。它可以通过 v-on="$listeners" 传入内部组件 —— 在创建更高层次的组件时非常有用。

官方解释的已经非常的清楚了。事实上,你可以把$attrs$listeners比作两个集合,其中$attrs是一个属性集合,而$listeners是一个事件集合,两者都是以对象的形式来保存数据

更简单地说,利用$attrs实现祖孙组件间的数据传递,$listeners实现祖孙组件间的事件监听。而且$attrs继承所有的父组件属性(除props传递的属性、classstyle,一般用在子组件的子元素上;$listeners是一个对象,里面包含了作用在这个组件上的所有监听器,配合v-on将所有事件监听器指向这个组件的某个特定的子元素(相当于子组件继承父组件的事件)。

为了更易于帮助大家理解这两个属性,我们还是通过一些简单的示例来演示吧。先来看一个简单的示例:

<!-- ChildComponent.vue -->
<template>
    <div class="child-component">
        <h1>我是一个 {{ professional }}</h1>
    </div>
</template>

<script>
    export default {
        name: 'ChildComponent',
        props: {
            professional: {
                type: String,
                default: '码农'
            }
        },
        created () {
            console.log(this.$attrs, this.$listeners)

            // 调用父组件App.vue中的triggerTwo()方法
            this.$listeners.two()
        }
    }
</script>

<!-- App.vue -->
<template>
    <div id="app">
        <img alt="Vue logo" src="./assets/logo.png">
        <ChildComponent 
        :professional = "professional"
        :name = "name"
        @one.native = "triggerOne"
        @two = "triggerTwo"
        />
    </div>
</template>

<script>
    import ChildComponent from './components/ChildComponent.vue'

    export default {
        name: 'app',
        data() {
            return {
                professional:  '屌丝码农',
                name:'大漠'
            }
        },
        components: {
            ChildComponent
        },
        methods: {
            triggerOne () {
                console.log('one')
            },
            triggerTwo () {
                console.log('two')
            }
        }
    }
</script>

示例代码可以在Github的Vue Demos中获取app-vue-communication项目的step1分支获取。

从上面的代码中我们可以看出来,在父组件App.vue中,调用子组件ChildComponent时有两个属性和两个方法,共别是其中有一个属性是props声明的(professional),事件一个是.native修饰器(监听组件根元素的原生事件)

这个简单的示例告诉我们可以通过$attrs$listeners进行数据传递,在需要的地方进行调用和处理。比如上面子组件ChildComponent中通过this.$listeners.two()访问了父组件App.vue中的triggerTwo()方法。当然,我们还可以通过v-on="$listeners"一级级地往下传递,不管组件嵌套层级有多深。这个后面我们会详细介绍。

另外,上面的示例中,其中有一个属性是props,比如professional属性,另外还有一个非props属性,比如name。组件编译之后会把非props属性当成原始属性对待,从而添加到DOM元素(HTML标签上),比如上例中的name

这样的结果或许并不是大家所想要的,如果想去掉HTML标签中name的属性,以至于该属性不暴露出来,我们可以借助inheritAttrs属性来完成。

inheritAttrs的默认值true,继承所有的父组件属性(除props的特定绑定)作为普通的HTML特性应用在子组件的根元素上,如果你不希望组件的根元素继承特性设置inheritAttrs: false,但是class属性会继承。简单的说,** inheritAttrs:true 继承除props之外的所有属性;inheritAttrs:false 只继承class属性**。

如果我们在子组件ChildComponent中添加inheritAttrs: false,重新编译出来的代码中name(非props)属性再不会暴露出来:

多级嵌套组件数据通信

前面花了很长的篇幅解释了$attrs$listeners以及它们是如何在组件中进行数据通信的。回到我们的示例中来,看看文章开头提以的三级嵌套组件之间的数据是如何借助$attrs$listeners实现数据通信。具体代码可以将分支切换到step2中:

<!-- ComponentC.vue -->
<template>
    <div class="component-c">
        <h3>组件C中设置的props: {{ name }}</h3>
        <p>组件C中的$attrs: {{ $attrs }}</p>
        <p>组件C中的$listeners: {{ $listeners }}</p>
    </div>
</template>

<script>
    export default {
        name: 'ComponentC',
        props: {
            name: {
                type: String,
                default: '大漠'
            }
        },
        inheritAttrs: false,
        mounted () {
            this.$emit('test2')
            console.log('ComponentC',this.$attrs, this.$listeners)
        }
    }
</script>

<!-- ComponentB.vue -->
<template>
    <div class="component-b">
        <h3>组件B中的props: {{ age }}</h3>
        <p>组件B中的$attrs: {{ $attrs }}</p>
        <p>组件B中的$listeners: {{ $listeners }}</p>

        <hr />
        <ComponentC v-bind="$attrs" v-on="$listeners" />
    </div>
</template>

<script>
    import ComponentC from './ComponentC'

    export default {
        name: 'ComponentB',
        props: {
            age: {
                type: Number,
                default: 30
            }
        },
        inheritAttrs: false,
        components: {
            ComponentC
        },
        mounted () {
            this.$emit('test1')
            console.log('ComponentB',this.$attrs, this.$listeners)
        }
    }
</script>

<!-- ComponentA.vue -->
<template>
    <div class="component-a">
        <ComponentB :name="name" :age="age"  @on-test1="onTest1" @on-test2="onTest2" />
    </div>
</template>

<script>
    import ComponentB from './ComponentB'

    export default {
        name: 'ComponentA',
        components: {
            ComponentB
        },
        data () {
            return {
                name: '大漠_w3cplus',
                age: 23
            }
        },
        methods: {
            onTest1 () {
                console.log('test1 runing...')
            },
            onTest2 () {
                console.log('test2 running...')
            }
        }
    }
</script>

<!-- App.vue -->
<template>
    <div id="app">
        <img alt="Vue logo" src="./assets/logo.png">
        <ComponentA />
    </div>
</template>

<script>
    import ComponentA from './components/ComponentA.vue'

    export default {
        name: 'app',
        components: {
            ComponentA
        }
    }
</script>

这个时候你在页面中将看到的结果如下:

其于上面的基础上,我们来看一个简单的示例(切到分支step3),一个模态框的数据通信:

<!-- ModalHeader.vue -->
<template>
    <div class="modal-header">
        <h5 class="modal-title">{{ modalTitle }}</h5>
        <button type="button" class="close" data-dismiss="modal" aria-label="Close" @click="close">
        <span aria-hidden="true">&times;</span>
        </button>
    </div>
</template>

<script>
export default {
    name: 'ModalHeader',
    props: {
        modalTitle: {
            type: String,
            default: 'Modal Title'
        }
    },
    inheritAttrs: false,
    methods: {
        close () {
            this.$emit('on-close')
        }
    },
    mounted () {
        console.log('ModalHeader',this.$attrs, this.$listeners)
    }
}
</script>

<!-- ModalBody.vue -->
<template>
    <div class="modal-body">
        <slot>{{ modalContent }}</slot>
    </div>
</template>

<script>
export default {
    name: 'ModalBody',
    props: {
        modalContent: {
            type: String,
            default: 'Modal body text goes here.'
        }
    },
    inheritAttrs: false,
    mounted () {
        console.log('ModalBody',this.$attrs, this.$listeners)
    }
}
</script>

<!-- ModalFooter.vue -->
<template>
    <div class="modal-footer">
        <button class="btn btn-secondary" data-dismiss="modal" @click="close">{{ secondaryButtonContent }}</button>
        <button class="btn btn-primary" @click="save">{{ primaryButtonContent }}</button>
    </div>
</template>

<script>
export default {
    name: 'ModalFooter',
    props: {
        secondaryButtonContent: {
            type: String,
            default: 'Close'
        },
        primaryButtonContent: {
            type: String,
            default: 'Save'
        }
    },
    inheritAttrs: false,
    methods: {
        save () {
            this.$emit('on-save')
        },
        close () {
            this.$emit('on-close')
        }
    },
    mounted () {
        console.log('ModalFooter',this.$attrs, this.$listeners)
    }
}
</script>

<!-- Modal.vue -->
<template>
    <div class="modal" tabindex="-1" role="dialog" v-if="show">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <ModalHeader v-bind="$attrs" v-on="$listeners" />
                <ModalBody v-bind="$attrs" v-on="$listeners" />
                <ModalFooter v-bind="$attrs" v-on="$listeners" />
            </div>
        </div>
    </div>
</template>

<script>
import ModalHeader from './ModalHeader'
import ModalBody from './ModalBody'
import ModalFooter from './ModalFooter'

export default {
    name: 'Modal',
    props: {
        show: {
            type: Boolean,
            default: false
        }
    },
    components: {
        ModalHeader,
        ModalBody,
        ModalFooter
    },
    inheritAttrs: false,
}
</script>

<!--  MaskBackdrop.vue -->
<template>
    <div class="modal-backdrop" v-if="show" @click="close">
    </div>
</template>

<script>

export default {
    name: 'MaskBackdrop',
    props: {
        show: {
            type: Boolean,
            default: false
        }
    },
    inheritAttrs: false,
    mounted () {
        console.log('MaskBackdrop',this.$attrs, this.$listeners)
    },
    methods: {
        close () {
            this.$emit('on-close')
        }
    }
}
</script>

你将看到的效果如下:

在浏览器调试器中,我们可以看以相应$attrs$listeners打印出来的值:

小结

啰嗦了这么多,主要就是阐述了Vue 2.4版本之后的$attrs$listeners是什么以及怎么利用他们来实现组件之间的数据通信。使用这两个特性可以实现跨组件(嵌套)组件之间的数据通信。最后希望这篇文章对大家或多或少有所收获。结合前面的教程,我们可以了解到组件之间数据通信有很多种方式,具体哪种更好应该根据不同的场景来对待,选择最适合的。如果您在这方面有更多的经验或者文章中有不正之处,烦请路过的大神多多拍正。Nike Air Force 1 '07 LV8 Crocodile Leather Black Dark Grey 718152-018