MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Vue组件通信的深度解析

2021-11-196.5k 阅读

Vue 组件通信基础

在 Vue 开发中,组件是构建应用的基本单元。组件之间常常需要进行数据传递和交互,这就涉及到组件通信。Vue 提供了多种方式来实现组件之间的通信,每种方式适用于不同的场景。

父子组件通信

父子组件通信是最为常见的一种通信方式。在这种通信模式下,父组件向子组件传递数据,子组件接收并使用这些数据。

父组件向子组件传递数据: 父组件通过在子组件标签上以属性的形式传递数据。例如,我们有一个 Parent 组件和一个 Child 组件。

<template>
  <div>
    <Child :message="parentMessage"></Child>
  </div>
</template>

<script>
import Child from './Child.vue';

export default {
  components: {
    Child
  },
  data() {
    return {
      parentMessage: '这是来自父组件的数据'
    };
  }
};
</script>

在上述代码中,Parent 组件将 parentMessage 数据通过 :message 属性传递给了 Child 组件。这里的 :message 是一个自定义属性,它将 parentMessage 的值绑定到了子组件的 message 属性上。

子组件接收数据: 子组件通过 props 选项来接收父组件传递的数据。

<template>
  <div>
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  props: ['message']
};
</script>

Child 组件中,通过 props 数组声明了可以接收名为 message 的属性。这样,子组件就能使用父组件传递过来的数据了。

props 不仅可以接收简单的数据类型,如字符串、数字等,还可以接收对象、数组等复杂类型。同时,props 还支持类型校验和默认值设置。

<template>
  <div>
    <p>{{ user.name }}</p>
  </div>
</template>

<script>
export default {
  props: {
    user: {
      type: Object,
      required: true,
      default: () => ({})
    }
  }
};
</script>

在上述代码中,user 属性被声明为 Object 类型,并且是必须传递的。如果父组件没有传递该属性,将会抛出警告。default 函数返回一个空对象作为默认值。

子组件向父组件传递数据: 子组件向父组件传递数据通常是通过自定义事件来实现的。当子组件内部发生了某些事件,需要通知父组件时,就可以触发自定义事件。

<template>
  <div>
    <button @click="sendDataToParent">点击我向父组件传递数据</button>
  </div>
</template>

<script>
export default {
  methods: {
    sendDataToParent() {
      this.$emit('child-event', '这是来自子组件的数据');
    }
  }
};
</script>

Child 组件中,当按钮被点击时,通过 this.$emit 触发了一个名为 child - event 的自定义事件,并传递了一个字符串数据。

父组件通过在子组件标签上监听这个自定义事件来接收数据。

<template>
  <div>
    <Child @child - event="handleChildEvent"></Child>
  </div>
</template>

<script>
import Child from './Child.vue';

export default {
  components: {
    Child
  },
  methods: {
    handleChildEvent(data) {
      console.log('接收到子组件的数据:', data);
    }
  }
};
</script>

Parent 组件中,通过 @child - event 监听了子组件触发的 child - event 事件,并指定了 handleChildEvent 方法来处理接收到的数据。

非父子组件通信

在一些复杂的应用场景中,组件之间可能并不是父子关系,但仍然需要进行通信。Vue 提供了几种方式来实现非父子组件之间的通信。

事件总线: 事件总线是一种简单的实现非父子组件通信的方式。它通过创建一个空的 Vue 实例作为事件中心,所有需要通信的组件都可以通过这个实例来触发和监听事件。 首先,创建一个事件总线实例。可以在一个单独的文件中创建,例如 eventBus.js

import Vue from 'vue';
export const eventBus = new Vue();

然后,在需要发送数据的组件中触发事件。假设我们有 ComponentAComponentB 两个非父子组件,ComponentA 要向 ComponentB 传递数据。

<template>
  <div>
    <button @click="sendData">点击发送数据</button>
  </div>
</template>

<script>
import { eventBus } from './eventBus.js';

export default {
  methods: {
    sendData() {
      eventBus.$emit('data - event', '这是来自 ComponentA 的数据');
    }
  }
};
</script>

ComponentA 中,通过 eventBus.$emit 触发了一个名为 data - event 的事件,并传递了数据。

ComponentB 中监听这个事件来接收数据。

<template>
  <div>
    <p>接收到的数据: {{ receivedData }}</p>
  </div>
</template>

<script>
import { eventBus } from './eventBus.js';

export default {
  data() {
    return {
      receivedData: ''
    };
  },
  created() {
    eventBus.$on('data - event', (data) => {
      this.receivedData = data;
    });
  }
};
</script>

ComponentBcreated 钩子函数中,通过 eventBus.$on 监听了 data - event 事件,并在事件触发时更新 receivedData

虽然事件总线使用起来很方便,但当应用规模变大时,事件的管理会变得困难,容易出现命名冲突等问题。

Vuex: Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 适用于大型应用中,当多个组件需要共享状态时,它能很好地管理这些状态。

首先,安装 Vuex。

npm install vuex --save

然后,创建一个 Vuex 实例。在 store.js 文件中进行配置。

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    sharedData: '初始共享数据'
  },
  mutations: {
    updateSharedData(state, newData) {
      state.sharedData = newData;
    }
  },
  actions: {
    updateSharedDataAction({ commit }, newData) {
      commit('updateSharedData', newData);
    }
  }
});

export default store;

在上述代码中,state 定义了共享的状态数据,mutations 定义了修改状态的方法,actions 通常用于处理异步操作并提交 mutations

在组件中使用 Vuex。

<template>
  <div>
    <p>{{ sharedData }}</p>
    <button @click="updateData">更新数据</button>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  computed: {
   ...mapState(['sharedData'])
  },
  methods: {
   ...mapActions(['updateSharedDataAction']),
    updateData() {
      this.updateSharedDataAction('新的共享数据');
    }
  }
};
</script>

在组件中,通过 mapStatestate 中的 sharedData 映射到组件的计算属性中,通过 mapActionsactions 中的 updateSharedDataAction 映射到组件的方法中。这样,组件就可以方便地获取和修改共享状态了。

深度解析 Vue 组件通信原理

父子组件通信原理

props 传递原理: 当父组件向子组件传递数据时,Vue 会在子组件实例创建过程中,将父组件传递的属性值解析并挂载到子组件的 _props 对象上。子组件通过 props 选项声明的属性名,从 _props 对象中获取对应的值。 在 Vue 的渲染过程中,会根据 props 的变化来重新渲染子组件。当父组件的 data 中与传递给子组件的 props 相关的数据发生变化时,Vue 的响应式系统会检测到这个变化,并触发子组件的重新渲染,从而实现数据的实时更新。 例如,在前面的父子组件通信示例中,当 Parent 组件的 parentMessage 数据发生变化时,Child 组件会因为 props 的更新而重新渲染,展示新的数据。

自定义事件原理: 子组件触发自定义事件是基于 Vue 的事件系统。每个 Vue 实例都有一个 $emit 方法,用于触发实例上的自定义事件。当子组件调用 this.$emit('event - name', data) 时,Vue 会在该实例的事件队列中查找是否有监听 event - name 事件的回调函数。 父组件通过在子组件标签上使用 @event - name="callback" 来监听子组件触发的事件。在父组件渲染时,会将这个监听关系记录下来。当子组件触发事件时,Vue 会根据记录的监听关系,找到父组件对应的回调函数并执行,从而实现子组件向父组件传递数据。

事件总线原理

事件总线本质上也是基于 Vue 的事件系统。创建的空 Vue 实例 eventBus 就像一个全局的事件中心。各个组件通过 eventBus.$emit 触发事件,通过 eventBus.$on 监听事件。 当一个组件调用 eventBus.$emit('event - name', data) 时,eventBus 会遍历自身的事件队列,找到所有监听 event - name 事件的回调函数并执行,同时将传递的数据作为参数传入回调函数。其他组件通过 eventBus.$on('event - name', callback) 注册的回调函数就会被触发,从而实现非父子组件之间的数据传递。

Vuex 原理

状态管理机制: Vuex 的核心是 store,它包含了应用的所有状态(state)。Vuex 使用了 Vue 的响应式系统来追踪状态的变化。当 state 中的数据发生变化时,依赖于这些数据的组件会自动重新渲染。 例如,在前面的 Vuex 示例中,sharedDatastate 中的数据,当它发生变化时,使用了该数据的组件会因为 Vue 的响应式系统而重新渲染。

mutations 和 actionsmutations 是唯一允许直接修改 state 的地方。这是为了保证状态变化的可预测性。当调用 commit('mutation - name', data) 时,会触发对应的 mutation 函数,在函数中对 state 进行修改。 actions 通常用于处理异步操作,如 API 请求等。它通过 commit 方法来提交 mutations。这样可以将异步操作和状态修改分离,使代码结构更加清晰。例如,在 updateSharedDataAction 中,可以先进行异步操作,然后再通过 commit 提交 updateSharedData 来修改 state

实际应用场景分析

父子组件通信场景

表单组件: 在一个表单应用中,父组件可能包含一个表单容器,子组件是各种表单输入框,如文本框、下拉框等。父组件需要向子组件传递初始值、占位符等数据,子组件在用户输入完成后,通过自定义事件将输入的值传递给父组件进行验证和提交。 例如,有一个登录表单,父组件 LoginForm 包含用户名和密码输入框子组件 InputText

<template>
  <div>
    <InputText :placeholder="用户名" :initialValue="''" @input - change="handleInputChange"></InputText>
    <InputText :placeholder="密码" :initialValue="''" @input - change="handleInputChange"></InputText>
    <button @click="submitForm">提交</button>
  </div>
</template>

<script>
import InputText from './InputText.vue';

export default {
  components: {
    InputText
  },
  data() {
    return {
      formData: {
        username: '',
        password: ''
      }
    };
  },
  methods: {
    handleInputChange(field, value) {
      this.formData[field] = value;
    },
    submitForm() {
      // 处理表单提交逻辑
      console.log('提交表单数据:', this.formData);
    }
  }
};
</script>

InputText 子组件中:

<template>
  <div>
    <input :placeholder="placeholder" :value="value" @input="handleInput">
  </div>
</template>

<script>
export default {
  props: ['placeholder', 'initialValue'],
  data() {
    return {
      value: this.initialValue
    };
  },
  methods: {
    handleInput(e) {
      this.value = e.target.value;
      this.$emit('input - change', 'username', this.value);
    }
  }
};
</script>

在这个场景中,父组件通过 props 向子组件传递初始值和占位符,子组件通过自定义事件将用户输入的值传递给父组件。

树形结构组件: 在一个文件目录树或组织结构树等树形结构组件中,父节点组件和子节点组件之间需要进行通信。父组件可能需要向子组件传递节点数据、展开/收缩状态等,子组件可能需要向父组件传递节点的点击事件、选中状态变化等。 例如,有一个文件目录树组件 DirectoryTree,父组件 App 包含该组件。

<template>
  <div>
    <DirectoryTree :treeData="directoryTree"></DirectoryTree>
  </div>
</template>

<script>
import DirectoryTree from './DirectoryTree.vue';

export default {
  components: {
    DirectoryTree
  },
  data() {
    return {
      directoryTree: [
        {
          name: '根目录',
          children: [
            {
              name: '文件夹 1',
              children: []
            },
            {
              name: '文件夹 2',
              children: []
            }
          ]
        }
      ]
    };
  }
};
</script>

DirectoryTree 组件中,通过递归组件来展示树形结构,父节点组件向子节点组件传递节点数据,子节点组件通过自定义事件向父节点组件传递点击事件等。

<template>
  <ul>
    <li v - for="(node, index) in treeData" :key="index">
      {{ node.name }}
      <DirectoryTree v - if="node.children.length > 0" :treeData="node.children" @node - click="handleNodeClick"></DirectoryTree>
    </li>
  </ul>
</template>

<script>
export default {
  props: ['treeData'],
  methods: {
    handleNodeClick(node) {
      this.$emit('node - click', node);
    }
  }
};
</script>

非父子组件通信场景

购物车应用: 在一个购物车应用中,商品列表组件和购物车组件可能并不是父子关系。商品列表组件中的每个商品项都有添加到购物车的功能,当用户点击添加到购物车时,需要将商品信息传递给购物车组件。 可以使用事件总线来实现这个功能。

<template>
  <div>
    <ProductList></ProductList>
    <Cart></Cart>
  </div>
</template>

<script>
import ProductList from './ProductList.vue';
import Cart from './Cart.vue';

export default {
  components: {
    ProductList,
    Cart
  }
};
</script>

ProductList 组件中:

<template>
  <div>
    <div v - for="(product, index) in products" :key="index">
      <p>{{ product.name }}</p>
      <button @click="addToCart(product)">添加到购物车</button>
    </div>
  </div>
</template>

<script>
import { eventBus } from './eventBus.js';

export default {
  data() {
    return {
      products: [
        {
          name: '商品 1',
          price: 100
        },
        {
          name: '商品 2',
          price: 200
        }
      ]
    };
  },
  methods: {
    addToCart(product) {
      eventBus.$emit('add - to - cart', product);
    }
  }
};
</script>

Cart 组件中:

<template>
  <div>
    <ul>
      <li v - for="(item, index) in cartItems" :key="index">
        {{ item.name }} - {{ item.price }}
      </li>
    </ul>
  </div>
</template>

<script>
import { eventBus } from './eventBus.js';

export default {
  data() {
    return {
      cartItems: []
    };
  },
  created() {
    eventBus.$on('add - to - cart', (product) => {
      this.cartItems.push(product);
    });
  }
};
</script>

当商品列表组件中的商品添加到购物车时,通过事件总线触发 add - to - cart 事件,购物车组件监听该事件并更新购物车列表。

对于大型购物车应用,使用 Vuex 会更加合适。可以将购物车的状态(如商品列表、总价等)存储在 Vuex 的 state 中,商品列表组件通过 dispatch 触发 actions 来更新购物车状态,购物车组件通过 mapState 获取购物车状态并展示。

// store.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    cartItems: []
  },
  mutations: {
    addToCart(state, product) {
      state.cartItems.push(product);
    }
  },
  actions: {
    addProductToCart({ commit }, product) {
      commit('addToCart', product);
    }
  }
});

export default store;

ProductList 组件中:

<template>
  <div>
    <div v - for="(product, index) in products" :key="index">
      <p>{{ product.name }}</p>
      <button @click="addToCart(product)">添加到购物车</button>
    </div>
  </div>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  data() {
    return {
      products: [
        {
          name: '商品 1',
          price: 100
        },
        {
          name: '商品 2',
          price: 200
        }
      ]
    };
  },
  methods: {
   ...mapActions(['addProductToCart']),
    addToCart(product) {
      this.addProductToCart(product);
    }
  }
};
</script>

Cart 组件中:

<template>
  <div>
    <ul>
      <li v - for="(item, index) in cartItems" :key="index">
        {{ item.name }} - {{ item.price }}
      </li>
    </ul>
  </div>
</template>

<script>
import { mapState } from 'vuex';

export default {
  computed: {
   ...mapState(['cartItems'])
  }
};
</script>

这样,通过 Vuex 实现了购物车应用中不同组件之间状态的统一管理和通信。

多视图切换应用: 在一个具有多个视图的应用中,例如一个包含首页、详情页、设置页等的应用。不同视图组件之间可能需要共享一些状态,如用户登录状态、主题设置等。 使用 Vuex 可以方便地管理这些共享状态。例如,用户登录状态可以存储在 Vuex 的 state 中,登录组件通过 dispatch 触发 actions 来更新登录状态,各个视图组件通过 mapState 获取登录状态,根据登录状态来展示不同的内容,如显示登录按钮或用户信息等。

// store.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    isLoggedIn: false
  },
  mutations: {
    setLoggedIn(state, value) {
      state.isLoggedIn = value;
    }
  },
  actions: {
    login({ commit }) {
      // 模拟登录逻辑
      setTimeout(() => {
        commit('setLoggedIn', true);
      }, 1000);
    }
  }
});

export default store;

在登录组件中:

<template>
  <div>
    <button @click="handleLogin">登录</button>
  </div>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  methods: {
   ...mapActions(['login']),
    handleLogin() {
      this.login();
    }
  }
};
</script>

在首页组件中:

<template>
  <div>
    <p v - if="isLoggedIn">欢迎用户登录</p>
    <p v - else>请登录</p>
  </div>
</template>

<script>
import { mapState } from 'vuex';

export default {
  computed: {
   ...mapState(['isLoggedIn'])
  }
};
</script>

通过这种方式,实现了多视图切换应用中不同组件之间基于共享状态的通信。

优化与注意事项

父子组件通信优化

减少不必要的重新渲染: 在父子组件通信中,如果父组件频繁地传递相同的数据给子组件,可能会导致子组件不必要的重新渲染。可以通过 Object.freeze 方法来冻结对象,使其不可变。这样当父组件传递的对象数据没有实际变化时,Vue 不会触发子组件的重新渲染。 例如,在父组件中:

data() {
  return {
    fixedData: Object.freeze({
      name: '固定数据',
      value: 100
    })
  };
}

然后将 fixedData 通过 props 传递给子组件。由于对象被冻结,只要对象的引用不变,即使对象内部属性没有变化,子组件也不会因为 props 的变化而重新渲染。

合理使用单向数据流: Vue 的父子组件通信遵循单向数据流原则,即父组件传递给子组件的数据是单向的,子组件不应直接修改父组件传递的 props。如果子组件需要修改数据,应该通过自定义事件通知父组件,让父组件来修改数据,然后再传递给子组件。这样可以保证数据流向的清晰和可维护性。

非父子组件通信优化

事件总线的管理: 在使用事件总线时,为了避免命名冲突和难以维护的问题,可以对事件名称进行统一的命名规范。例如,使用模块名作为前缀,如 product:add - to - cart。同时,在组件销毁时,要及时解绑监听的事件,防止内存泄漏。

<template>
  <div>
    <p>接收到的数据: {{ receivedData }}</p>
  </div>
</template>

<script>
import { eventBus } from './eventBus.js';

export default {
  data() {
    return {
      receivedData: ''
    };
  },
  created() {
    eventBus.$on('data - event', (data) => {
      this.receivedData = data;
    });
  },
  beforeDestroy() {
    eventBus.$off('data - event');
  }
};
</script>

Vuex 的性能优化: 在使用 Vuex 时,由于所有组件都可以访问 state,如果 state 中的数据过于庞大,可能会影响性能。可以对 state 进行合理的拆分,将不同功能模块的状态分开管理。同时,对于一些不需要响应式更新的静态数据,可以不放在 state 中,而是直接在组件内部定义。 另外,在 mutations 中尽量避免复杂的计算和异步操作,保持 mutations 的简洁性,以保证状态变化的可预测性。对于异步操作,应该在 actions 中处理。

跨层级组件通信

在一些复杂的组件嵌套结构中,可能会存在跨层级组件通信的需求,即祖先组件与后代组件之间,或者非直接父子关系的组件之间进行通信。虽然可以通过层层传递 props 或者使用事件总线来解决部分问题,但这些方法在组件层级较深时会变得繁琐和难以维护。Vue 提供了 provideinject 选项来更优雅地处理这种情况。

provide 和 inject

provide 选项允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。inject 选项则用于在子孙组件中接收 provide 提供的数据。

示例代码: 假设我们有一个 App 组件作为祖先组件,其内部嵌套了多层组件,如 Parent 组件、Child 组件和 GrandChild 组件。

<template>
  <div>
    <Parent></Parent>
  </div>
</template>

<script>
import Parent from './Parent.vue';

export default {
  components: {
    Parent
  },
  provide() {
    return {
      sharedValue: '这是祖先组件提供的值'
    };
  }
};
</script>

App 组件中,通过 provide 选项提供了一个 sharedValue

<template>
  <div>
    <Child></Child>
  </div>
</template>

<script>
import Child from './Child.vue';

export default {
  components: {
    Child
  }
};
</script>

Parent 组件只是作为中间层组件,没有对 provideinject 进行额外操作。

<template>
  <div>
    <GrandChild></GrandChild>
  </div>
</template>

<script>
import GrandChild from './GrandChild.vue';

export default {
  components: {
    GrandChild
  }
};
</script>

Child 组件同样作为中间层。

<template>
  <div>
    <p>{{ sharedValue }}</p>
  </div>
</template>

<script>
export default {
  inject: ['sharedValue']
};
</script>

GrandChild 组件中,通过 inject 选项接收了 App 组件提供的 sharedValue 并进行展示。

原理分析provideinject 主要是通过 Vue 的组件实例关系来实现数据传递。当一个组件定义了 provide 时,Vue 会在该组件的实例上创建一个 _provided 对象,存储提供的数据。子孙组件在初始化时,如果有 inject 选项,会查找其祖先组件的 _provided 对象,并将对应的属性注入到自身实例中。这种方式使得跨层级组件之间可以方便地共享数据,而不需要通过层层传递 props

注意事项

响应性问题: 需要注意的是,provideinject 所传递的数据默认不是响应式的。如果希望传递的数据具有响应性,可以传递一个可响应的对象或使用 Vuex 来管理共享状态。例如,在 App 组件中:

data() {
  return {
    reactiveValue: '初始响应值'
  };
},
provide() {
  return {
    reactiveValue: this.reactiveValue
  };
}

这样在子孙组件中通过 inject 接收的 reactiveValue 会随着 App 组件中 reactiveValue 的变化而变化。

作用域问题provideinject 所建立的依赖关系是基于组件树的父子关系,不会跨越多个 Vue 根实例。如果应用中有多个 Vue 根实例,不同根实例下的组件无法通过 provideinject 进行通信。同时,inject 只能接收来自祖先组件的 provide,不能接收来自兄弟组件或后代组件的 provide

通过合理使用 provideinject,可以有效地解决跨层级组件通信的问题,提高代码的可维护性和可读性。但在使用过程中要注意其特性和限制,以避免出现意外的问题。