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

Communication between sibling components in Vue.js 2.0

概览

在Vue.js2.x,model.sync will be deprecated.

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


背景

据我了解Vue.js2.x,兄弟姐妹通信的首选方法是使用存储或事件总线

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

It's also worth mentioning "passing data between components" is generally a bad idea, because in the end the data flow becomes untrackable and very hard to debug.

If a piece of data needs to be shared by multiple components, prefer global stores or Vuex.

[Link to discussion]

并且:

.once and .sync are deprecated. Props are now always one-way down. To produce side effects in the parent scope, a component needs to explicitly emit an event instead of relying on implicit binding.

因此,Evan suggests 使用 $emit()$on()


担忧

让我担心的是:

我想要的是一些 scope eventsstores 兄弟组件的可见性。 (或者我没看懂上面的思路。)


问题

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

如果我想“破解”Vue.js 中的正常通信模式,特别是现在 .sync 已弃用,我通常会创建一个简单的 EventEmitter 来处理组件之间的通信.来自我的最新项目之一:

import {EventEmitter} from 'events'

var Transmitter = Object.assign({}, EventEmitter.prototype, { /* ... */ })

使用此 Transmitter 对象,您可以在任何组件中执行以下操作:

import Transmitter from './Transmitter'

var ComponentOne = Vue.extend({
  methods: {
    transmit: Transmitter.emit('update')
  }
})

并创建一个“接收”组件:

import Transmitter from './Transmitter'

var ComponentTwo = Vue.extend({
  ready: function () {
    Transmitter.on('update', this.doThingOnUpdate)
  }
})

同样,这是针对特定用途的。不要将整个应用程序基于此模式,而是使用 Vuex 之类的东西。

好的,我们可以通过 parent 使用 v-on 事件在兄弟姐妹之间进行交流。

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

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


Parent中:

模板:

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

这里:

  • v-on:select-item 这是一个事件,将在 List 组件中调用(见下文);
  • setSelectedItem 这是 Parent 更新 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 直接在 parent 中发送项目。 parent 会将其发送到 Details 视图。

在 Vue.js 2.0 中,我使用的是 in the documentation.

演示的 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
     })
    

更新

请参阅 ,其中描述了一个更简单的解决方案。

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

组件 1:

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

组件 2:

mounted() {
    this.$root.$on('eventing', data => {
        console.log(data);
    });
}

状态范围

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

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

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

请注意,全局状态的一部分可能会在某个时刻结束于局部状态,并且它可以像任何其他局部状态一样向下传递给子组件,无论是完整的还是稀释的以匹配 use-case.


沟通渠道

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

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

  • 全局状态
  • Parent-child
  • Child-parent
  • 兄弟姐妹

不同的关注点涉及不同的沟通渠道。

Props:直接Parent-Child

Vue 中用于 one-way 数据绑定的最简单的通信通道。

Events:直接Child-Parent

$emit$on。直接 Child-Parent 通信的最简单的通信渠道。事件启用双向数据绑定。

Provide/Inject:全局或远程本地状态

在 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
  }
})

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

集中式存储(全局状态)

注意:Vuex 5 将是 Pinia apparently. Stay tuned. (Tweet)

Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.

现在 :

[S]hould I create vuex store for each minor communication?

在处理全局状态时真的很闪耀,包括但不限于:

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

因此您的组件可以真正专注于它们应该做的事情,管理用户界面,而全局商店可以 manage/use 通用业务逻辑并通过 [= 提供清晰的 API 42=].

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

为了避免处理全局状态中的所有内容,请参阅 Application structure 建议。

Refs 和方法:边缘案例

Despite the existence of props and events, sometimes you might still need to directly access a child component in JavaScript.

It is only meant as an escape hatch for direct child manipulation - you should avoid accessing $refs from within templates or computed properties.

如果您发现自己经常使用引用和子方法,可能是时候 lift the state up 或考虑此处或其他答案中描述的其他方法了。

$parent:边缘案例

Similar to $root, the $parent property can be used to access the parent instance from a child. This can be tempting to reach for as a lazy alternative to passing data with a prop.

In most cases, reaching into the parent makes your application more difficult to debug and understand, especially if you mutate data in the parent. When looking at that component later, it will be very difficult to figure out where that mutation came from.

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

事件总线:Global/distant本地状态

有关事件总线模式的 up-to-date 信息,请参阅

这是过去的模式,将 props 从远方向下传递到深层嵌套的子组件,几乎没有其他组件需要这些。 谨慎选择数据。

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

下面演示了一个简单的错误如何导致泄漏,即使从 DOM.

中删除 Item 组件仍然触发。

// A component that binds to a custom 'update' event.
var Item = {
  template: `<li>{{text}}</li>`,
  props: {
    text: Number
  },
  mounted() {
    this.$root.$on('update', () => {
      console.log(this.text, 'is still alive');
    });
  },
};

// Component that emits events
var List = new Vue({
  el: '#app',
  components: {
    Item
  },
  data: {
    items: [1, 2, 3, 4]
  },
  updated() {
    this.$root.$emit('update');
  },
  methods: {
    onRemove() {
      console.log('slice');
      this.items = this.items.slice(0, -1);
    }
  }
});
<script src="https://unpkg.com/vue@2.5.17/dist/vue.min.js"></script>

<div id="app">
  <button type="button" @click="onRemove">Remove</button>
  <ul>
    <item v-for="item in items" :key="item" :text="item"></item>
  </ul>
</div>

记得在destroyed生命周期挂钩中移除监听器。


组件类型

免责声明: 以下 "containers" versus "presentational" components is just one way to structure a project and there are now multiple alternatives, like the new Composition API 可以有效地替代我在下面描述的“特定于应用程序的容器”。

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

  • 应用特定容器
  • Generic/presentational 组件

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

特定于应用程序的容器

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

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

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

scope somehow events or stores visibility for siblings components

这是范围界定发生的地方。大多数组件不知道商店,该组件应该(主要)使用一个命名空间商店模块,其中有限的一组 gettersactions 应用提供的 Vuex binding helpers.

Generic/presentational 组件

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

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


兄弟姐妹交流

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

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

❌ 混合关注

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

// MyInput.vue
<template>
    <div class="my-input">
        <label>Data</label>
        <input type="text"
            :value="value" 
            :input="onChange($event.target.value)">
    </div>
</template>
<script>
    import axios from 'axios';

    export default {
        data() {
            return {
                value: "",
            };
        },
        mounted() {
            this.$root.$on('sync', data => {
                this.value = data.myServerValue;
            });
        },
        methods: {
            onChange(value) {
                this.value = value;
                axios.post('http://example.com/api/update', {
                        myServerValue: value
                    });
            }
        }
    }
</script>

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

  • 显式使用全局 axios 实例
  • Hard-codedAPI里面的UI
  • 与根组件紧密耦合(事件总线模式)
  • 更难做单元测试

✅ 关注点分离

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

通过以下模式,我们可以:

  • 轻松
  • 更改 API 完全不影响组件
  • 根据需要配置 HTTP 通信(axios、获取、添加中间件、测试等)
  • 在任何地方重复使用 input 组件(减少耦合)
  • 通过全局商店绑定从应用程序中的任何位置对状态更改做出反应
  • 等等

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

// 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 actions处理后端通信,我们这里的容器不需要知道axios和后端。

如何处理手足之间的沟通,要视情况而定。但首先我想强调 全局事件总线方法将在 Vue.js 3 中消失。看到这个 RFC。因此这个答案。

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

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

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

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

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

更多细节,请同事参考this excellent blog post。 (忽略它的示例使用Ember,它的概念适用于许多框架)。

数据容器模式(例如 Vuex)

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

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

Publish/Subscribe(事件总线)模式

如果事件总线(即 publish/subscribe) pattern makes more sense for your app (from an architecture standpoint), or you need to remove Vue.js's global event bus from an existing Vue.js app, the Vue.js core team now recommends using a third party library such as mitt。(请参阅第 1 段中引用的 RFC。)。

其他

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

的游戏

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

这可能是一件好事。一个 large/complex 应用程序,其中节点在没有任何集中管理器的情况下相互通信,可能很难调试。此外,使用 LCA 的组件往往表现出较低的 coupling and high reusability.

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

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>

在我的例子中,我有一个 table 和 editable 单元格。当用户从一个单元格点击到另一个单元格以编辑内容时,我一次只希望一个单元格成为 editable 。 解决方案是使用父子(props)和子父(event)。 在下面的示例中,我循环遍历 'rows' 的数据集并使用 rowIndex 和 cellIndex 为每个单元格创建唯一(坐标)标识符。单击单元格时,会从子元素触发一个事件,直到父元素告诉父元素已单击哪个坐标。然后,父级设置 selectedCoord 并将其传递回子组件。所以每个子组件都知道自己的坐标和选择的坐标。然后它可以决定是否使自己成为 editable。

<!-- PARENT COMPONENT -->
<template>
<table>
    <tr v-for="(row, rowIndex) in rows">
        <editable-cell
            v-for="(cell, cellIndex) in row"
            :key="cellIndex"
            :cell-content="cell"
            :coords="rowIndex+'-'+cellIndex"
            :selected-coords="selectedCoords"
            @select-coords="selectCoords"
        ></editable-cell>
    </tr>
</table>
</template>
<script>
export default {
    name: 'TableComponent'
    data() {
        return {
            selectedCoords: '',
        }
    },
    methods: {
        selectCoords(coords) {
            this.selectedCoords = coords;
        },
    },
</script>

<!-- CHILD COMPONENT -->
<template>
    <td @click="toggleSelect">
        <input v-if="coords===selectedCoords" type="text" :value="cellContent" />
        <span v-else>{{ cellContent }}</span>
    </td>
</template>
<script>
export default {
    name: 'EditableCell',
    props: {
        cellContent: {
            required: true
        },
        coords: {
            type: String,
            required: true
        },
        selectedCoords: {
            type: String,
            required: true
        },
    },
    methods: {
        toggleSelect() {
            const arg = (this.coords === this.selectedCoords) ? '' : this.coords;
            this.$emit('select-coords', arg);
        },
    }
};
</script>