Vue.js 2.0 中兄弟组件之间的通信

IT技术 javascript vue.js vuejs2 vue-component vuex
2021-01-20 11:53:53

概述

在 Vue.js 2.x 中,model.sync将被弃用

那么,在Vue.js 2.x 中,兄弟组件之间通信的正确方法是什么


背景

据我了解 Vue.js 2.x,兄弟通信的首选方法是使用 store 或 event bus

根据Evan(Vue.js 的创建者)的说法:

还值得一提的是,“在组件之间传递数据”通常是一个坏主意,因为最终数据流变得不可追踪且很难调试。

如果一条数据需要被多个组件共享,首选 全局存储或者Vuex

[讨论链接]

和:

.once.sync已弃用。props现在总是单向下降。为了在父作用域中产生副作用,组件需要显式地创建emit一个事件,而不是依赖于隐式绑定。

因此,Evan 建议使用$emit()$on()


顾虑

让我担心的是:

  • 每个storeevent都有全局可见性(如果我错了,请纠正我);
  • 每次细微的交流都新建一个店铺太浪费了;

我想要的是兄弟组件的某些范围 eventsstores可见性。(或者也许我不理解上述想法。)


问题

那么,兄弟组件之间通信的正确方式是什么?

6个回答

您甚至可以缩短它并使用 Vue实例作为全局事件中心:

组件 1:

this.$root.$emit('eventing', data);

组件 2:

mounted() {
    this.$root.$on('eventing', data => {
        console.log(data);
    });
}
我是这个解决方案的忠实粉丝,因为我真的不喜欢有范围的事件。但是,我并不是每天都使用 VueJS,所以我很好奇是否有人发现这种方法存在问题。
2021-03-14 11:53:53
不错,简短且易于实现,也易于理解
2021-03-20 11:53:53
这比定义附加事件中心并将其附加到任何事件消费者更有效。
2021-03-28 11:53:53
所有答案中最简单的解决方案
2021-03-30 11:53:53
如果您只想直接与兄弟姐妹交流,请使用 $parent 而不是 $root
2021-03-31 11:53:53

对于 Vue.js 2.0,我使用了文档中演示的 eventHub 机制

  1. 定义集中式事件中心。

     const eventHub = new Vue() // Single event hub
    
     // Distribute to components using global mixin
     Vue.mixin({
         data: function () {
             return {
                 eventHub: eventHub
             }
         }
     })
    
  2. 现在在您的组件中,您可以使用

     this.eventHub.$emit('update', data)
    
  3. 听你说

     this.eventHub.$on('update', data => {
     // do your thing
     })
    

更新

请参阅alex 的回答,它描述了一个更简单的解决方案。

提醒一下:密切关注全局 Mixins,并尽可能避免使用它们,因为根据此链接vuejs.org/v2/guide/mixins.html#Global-Mixin它们甚至可能影响第三方组件。
2021-03-21 11:53:53
感谢您提供宝贵的反馈@GrayedFox,相应地更新了我的答案。
2021-03-21 11:53:53
一个更简单的解决方案是使用@Alex 所描述的 -this.$root.$emit()this.$root.$on()
2021-03-23 11:53:53
为了将来参考,请不要用其他人的答案更新您的答案(即使您认为它更好并且您参考了它)。链接到备用答案,或者如果您认为他们应该接受另一个答案,甚至可以要求 OP 接受另一个答案-但是将他们的答案复制到您自己的答案中是不好的形式,并且不鼓励用户在到期时给予信用,因为他们可能只是简单地支持您的只回答。鼓励他们通过不包含在您自己的答案中来导航到(并因此赞成)您所引用的答案。
2021-04-07 11:53:53
请注意,Vue 3 将不再支持此解决方案。请参阅 stackoverflow.com/a/60895076/752916
2021-04-09 11:53:53

状态范围

在设计 Vue 应用程序(或者实际上,任何基于组件的应用程序)时,有不同类型的数据取决于我们正在处理的问题,并且每个都有自己的首选通信渠道。

  • 全局状态:可能包括登录用户、当前主题等。

  • 本地状态:表单属性、禁用按钮状态等。

请注意,全局状态的一部分可能会在某个时刻以本地状态结束,并且它可以像任何其他本地状态一样传递给子组件,无论是完全还是稀释以匹配用例。


沟通渠道

通道是一个松散的术语,我将用来指代围绕 Vue 应用程序交换数据的具体实现。

每个实现都针对特定的通信渠道,其中包括:

  • 全局状态
  • 亲子
  • 父子
  • 兄弟姐妹

不同的关注点与不同的沟通渠道有关。

props: 直接亲子

Vue 中最简单的单向数据绑定通信通道。

事件:直接子父

$emit$on最简单的直接父子沟通的沟通渠道。事件启用 2 路数据绑定。

提供/注入:全局或远程本地状态

在 Vue 2.2+ 中添加,并且与 React 的上下文 API 非常相似,这可以用作事件总线的可行替代品。

在组件树中的任何一点,组件都可以提供一些数据,该行的任何子项都可以通过inject组件的属性访问这些数据

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

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

这可用于在应用程序的根部提供全局状态,或在树的子集中提供本地化状态。

中心化存储(全局状态)

VuexVue.js 应用程序状态管理模式 + 库它充当应用程序中所有组件的集中存储,其规则确保状态只能以可预测的方式改变。

现在你问

[S]我应该为每个次要通信创建 vuex 存储吗?

它在处理全局状态时真的很出色,包括但不限于:

  • 从后端接收的数据,
  • 全局 UI 状态就像一个主题,
  • 任何数据持久层,例如保存到后端或与本地存储接口,
  • Toast 消息或通知,
  • 等等。

所以你的组件可以真正专注于它们应该做的事情,管理用户界面,而全局存储可以管理/使用通用业务逻辑,并通过getteractions提供清晰的 API

这并不意味着您不能将它用于组件逻辑,但我个人会将该逻辑范围限定为仅具有必要全局 UI 状态的命名空间Vuex module

为了避免在全局状态下处理一大堆乱七八糟的东西,请参阅应用程序结构建议

参考文献和方法:边缘情况

尽管存在 props 和 events,有时您可能仍然需要直接访问 JavaScript 中的子组件。

它只是作为直接子操作逃生舱门- 您应该避免$refs从模板或计算属性中访问

如果您发现自己经常使用 refs 和 child 方法,则可能是时候提升状态或考虑此处或其他答案中描述的其他方式。

$parent: 边缘情况

与 类似$root,该$parent属性可用于从子项访问父实例。这可能很容易成为使用 prop 传递数据的懒惰替代方案。

在大多数情况下,访问父级会使您的应用程序更难以调试和理解,尤其是在您对父级中的数据进行变异时。稍后查看该组件时,将很难弄清楚该突变的来源。

实际上,您可以使用$parent,$ref浏览整个树结构$root,但这类似于将所有内容都设为全局并可能成为无法维护的意大利面。

事件总线:全局/远程本地状态

有关事件总线模式的最新信息,请参阅@AlexMA 的回答

这是过去的模式,将 props 从上到下传递到深层嵌套的子组件,几乎没有其他组件需要它们。谨慎使用精心挑选的数据。

小心:随后创建的将自身绑定到事件总线的组件将被绑定多次——导致多个处理程序被触发和泄漏。我个人从未觉得在我过去设计的所有单页应用程序中都需要事件总线。

下面演示了一个简单的错误如何导致泄漏,Item即使从 DOM 中删除组件仍然会触发。

请记住在destroyed生命周期挂钩中删除侦听器


组件类型

免责声明:以下“容器”与“展示”组件只是构建项目的一种方式,现在有多种替代方案,例如新的Composition API可以有效地替换我在下面描述的“应用程序特定容器”。

为了协调所有这些通信,为了简化可重用性和测试,我们可以将组件视为两种不同的类型。

  • 应用程序特定容器
  • 通用/展示组件

同样,这并不意味着应该重用通用组件或不能重用特定于应用程序的容器,但它们具有不同的职责。

应用程序特定容器

注意:请参阅新的Composition API作为这些容器的替代方案。

这些只是包装其他 Vue 组件(通用或其他应用程序特定容器)的简单 Vue 组件。这是 Vuex store 通信应该发生的地方,这个容器应该通过其他更简单的方式进行通信,比如 props 和事件监听器。

这些容器甚至可以根本没有原生 DOM 元素,让通用组件处理模板和用户交互。

以某种方式作用域eventsstores兄弟组件的可见性

这就是范围界定发生的地方。大多数组件不知道 store 并且这个组件应该(大部分)使用一个命名空间的 store module,其中包含一组有限的gettersactions应用提供的Vuex binding helpers

通用/展示组件

这些应该从 props 接收他们的数据,对他们自己的本地数据进行更改,并发出简单的事件。大多数时候,他们根本不应该知道 Vuex 商店的存在。

它们也可以称为容器,因为它们的唯一职责是分派到其他 UI 组件。


兄弟姐妹交流

那么,在这一切之后,我们应该如何在两个兄弟组件之间进行通信呢?

举个例子更容易理解:假设我们有一个输入框,它的数据应该在整个应用程序(树中不同位置的兄弟)之间共享,并通过后端持久化。

❌ 混合问题

最坏的情况开始,我们的组件将混合表示业务逻辑。

虽然对于一个简单的应用程序来说它看起来不错,但它有很多缺点:

  • 显式使用全局 axios 实例
  • UI 内的硬编码 API
  • 与根组件紧密耦合(事件总线模式)
  • 更难做单元测试

✅ 关注点分离

为了分离这两个关注点,我们应该将我们的组件包装在一个特定于应用程序的容器中,并将呈现逻辑保留在我们的通用输入组件中。

使用以下模式,我们可以:

  • 使用单元测试轻松测试每个问题
  • 在完全不影响组件的情况下更改 API
  • 随心所欲地配置 HTTP 通信(axios、fetch、添加中间件、测试等)
  • 任何地方重用输入组件(减少耦合)
  • 通过全局存储绑定对应用程序中任何位置的状态更改做出react
  • 等等。

我们的输入组件现在是可重用的,并且不知道后端和兄弟组件。

// MyInput.vue
// the template is the same as above
<script>
    export default {
        props: {
            initial: {
                type: String,
                default: ""
            }
        },
        data() {
            return {
                value: this.initial,
            };
        },
        methods: {
            onChange(value) {
                this.value = value;
                this.$emit('change', value);
            }
        }
    }
</script>

我们的应用程序特定容器现在可以成为业务逻辑和表示通信之间的桥梁。

// MyAppCard.vue
<template>
    <div class="container">
        <card-body>
            <my-input :initial="serverValue" @change="updateState"></my-input>
            <my-input :initial="otherValue" @change="updateState"></my-input>

        </card-body>
        <card-footer>
            <my-button :disabled="!serverValue || !otherValue"
                       @click="saveState"></my-button>
        </card-footer>
    </div>
</template>
<script>
    import { mapGetters, mapActions } from 'vuex';
    import { NS, ACTIONS, GETTERS } from '@/store/modules/api';
    import { MyButton, MyInput } from './components';

    export default {
        components: {
            MyInput,
            MyButton,
        },
        computed: mapGetters(NS, [
            GETTERS.serverValue,
            GETTERS.otherValue,
        ]),
        methods: mapActions(NS, [
            ACTIONS.updateState,
            ACTIONS.saveState,
        ])
    }
</script>

由于 Vuex store动作处理后端通信,我们这里的容器不需要知道 axios 和后端。

没错,这就是我的观点。社区中有一个概念,即 Vue/React 与 Vuex/Redux 齐头并进,它导致所有其他项目的膨胀存储比状态管理做更多的业务逻辑。我同意在较小的项目中,界限可能并且确实模糊,但是集中存储和业务逻辑的关联是错误的。大多数项目都有业务逻辑。并非所有项目都具有中心化状态。
2021-03-13 11:53:53
同意关于方法的评论是“与使用道具相同的耦合
2021-03-15 11:53:53
@vandroid 我创建了一个简单的例子,显示当监听器没有被正确删除时的泄漏,就像这个线程中的每个例子一样。
2021-03-16 11:53:53
@GHOST-34 我同意,自写这个答案以来,集中存储的使用已经下降。我一直在使用 React,我们已经从我们的工具集中完全删除了 Redux,而是使用了 Apollo GraphQL 客户端(它自动管理全局存储)和上下文的混合,这相当于provide/inject我相信的 vue,这不是甚至在我的回答中(尚未)提及,因为我回答时它相对较新。
2021-03-20 11:53:53
@GHOST-34 你的评论促使我更新我的答案。我删除了大部分对业务逻辑的引用,因为它与通信通道和状态管理完全不同,我引入了更多替代方案和细微差别,以邀请读者自己决定使用哪种解决方案。我还降低了关于 refs 和 child 方法的警告,因为在某些情况下这是一种完全有效的方式。
2021-04-07 11:53:53

好的,我们可以使用v-on事件通过父级在兄弟姐妹之间进行通信

Parent
 |- List of items // Sibling 1 - "List"
 |- Details of selected item // Sibling 2 - "Details"

假设我们想要Details在单击 中的某个元素时更新组件List


Parent

模板:

<list v-model="listModel"
      v-on:select-item="setSelectedItem" 
></list> 
<details v-model="selectedModel"></details>

这里:

  • v-on:select-item这是一个事件,将在List组件中调用(见下文);
  • setSelectedItem这是 aParent的更新方法selectedModel

JavaScript:

//...
data () {
  return {
    listModel: ['a', 'b']
    selectedModel: null
  }
},
methods: {
  setSelectedItem (item) {
    this.selectedModel = item // Here we change the Detail's model
  },
}
//...

List

模板:

<ul>
  <li v-for="i in list" 
      :value="i"
      @click="select(i, $event)">
        <span v-text="i"></span>
  </li>
</ul>

JavaScript:

//...
data () {
  return {
    selected: null
  }
},
props: {
  list: {
    type: Array,
    required: true
  }
},
methods: {
  select (item) {
    this.selected = item
    this.$emit('select-item', item) // Here we call the event we waiting for in "Parent"
  },
}
//...

这里:

  • this.$emit('select-item', item)将通过select-item直接在父项中发送项目并且父级会将其发送到Details视图。

如何处理兄弟姐妹之间的交流要视情况而定。但首先我想强调的是,全局事件总线方法正在 Vue.js 3 中消失请参阅此RFC因此这个答案。

最低共同祖先模式(或“LCA”)

在大多数情况下,我建议使用最低共同祖先模式(也称为“数据下降,事件上升”)。这种模式易于阅读、实现、测试和调试。它还创建了一个优雅、简单的数据流。

本质上,这意味着如果两个组件需要通信,将它们共享的状态放在最接近的组件中,这两个组件作为祖先共享。通过 props 将数据从父组件传递到子组件,并通过发出事件将信息从子组件传递到父组件(下面的示例代码)。

例如,一个人可能有一个电子邮件应用程序:地址组件需要将数据传递给消息正文组件(可能是为了预先填充“Hello <name>”),因此它们使用最接近的共享祖先(可能是电子邮件表单组件)保存收件人数据。

如果事件和props需要通过许多“中间人”组件,LCA 可能会很烦人。

有关更多详细信息,我向同事推荐这篇出色的博客文章(忽略它的示例使用 Ember 的事实,它的概念适用于许多框架)。

数据容器模式(例如,Vuex)

对于复杂的案例或亲子通信会涉及太多中间人的情况,请使用 Vuex 或等效的数据容器技术。

当单个商店变得过于复杂或杂乱无章时,请使用命名空间module例如,为具有许多互连的复杂组件集合(例如复杂日历)创建单独的命名空间可能是合理的。

发布/订阅(事件总线)模式

如果事件总线(即发布/订阅)模式对您的应用程序更有意义(从架构的角度来看),或者您需要从现有的 Vue.js 应用程序中删除 Vue.js 的全局事件总线,Vue.js 核心团队现在建议使用第三方库,例如mitt(请参阅第 1 段中引用的 RFC。)。

各种各样的

这是用于兄弟姐妹通信的 LCA 解决方案的一个小示例(可能过于简单)。这是一款名为whack-a-mole的游戏

在这个游戏中,玩家在“击打”一只鼹鼠时会获得分数,这会导致它隐藏,然后另一只鼹鼠出现在随机位置。为了构建这个包含“鼹鼠”组件的应用程序,人们可能会想,“鼹鼠组件 N 应该告诉鼹鼠组件 Y 在它被敲打后出现”。但是 Vue.js 不鼓励这种组件通信方法,因为 Vue.js 应用程序(和 html)实际上是树数据结构

这大概是件好事。一个大型/复杂的应用程序,其中节点在没有任何集中管理器的情况下相互通信,可能很难调试。此外,使用 LCA 的组件往往表现出低耦合和高可重用性

在此示例中,游戏管理器组件将鼹鼠可见性作为props传递给鼹鼠子组件。当一个可见的痣被“重击”(点击)时,它会发出一个事件。游戏管理器组件(公共祖先)接收事件并修改其状态。Vue.js 会自动更新 props,所以所有的鼹鼠组件都会收到新的可见性数据。

Vue.component('whack-a-mole', {
  data() {
    return {
      stateOfMoles: [true, false, false],
      points: 0
    }
  },
  template: `<div>WHACK - A - MOLE!<br/>
    <a-mole :has-mole="stateOfMoles[0]" v-on:moleMashed="moleClicked(0)"/>
    <a-mole :has-mole="stateOfMoles[1]"  v-on:moleMashed="moleClicked(1)"/>
    <a-mole :has-mole="stateOfMoles[2]" v-on:moleMashed="moleClicked(2)"/>
    <p>Score: {{points}}</p>
</div>`,
  methods: {
    moleClicked(n) {
      if(this.stateOfMoles[n]) {
         this.points++;
         this.stateOfMoles[n] = false;
         this.stateOfMoles[Math.floor(Math.random() * 3)] = true;
      }
    }
  }
})

Vue.component('a-mole', {
  props: ['hasMole'],
  template: `<button @click="$emit('moleMashed')">
      <span class="mole-button" v-if="hasMole">🐿</span><span class="mole-button" v-if="!hasMole">🕳</span>
    </button>`
})

var app = new Vue({
  el: '#app',
  data() {
    return { name: 'Vue' }
  }
})
.mole-button {
  font-size: 2em;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
  <whack-a-mole />
</div>