掌握 Vue 3 组件:基础原理、通信机制与插槽技巧

185 阅读14分钟

在 Vue 3 的生态中,组件化开发是构建复杂应用的核心。无论是小型功能模块还是大型单页应用,合理的组件设计与高效的通信机制能大幅提升开发效率与代码可维护性。本文将从组件基础概念出发,逐步深入通信机制的核心原理与实战技巧。

一、Vue 3 组件基础

1. 什么是组件

组件是 Vue 3 中用于构建用户界面的基本单元,它是可复用的、独立的代码块,将界面划分为多个可管理的部分。每个组件都有自己的模板(template)、逻辑(script)和样式(style),可以看作是一个小型的 Vue 应用。

使用组件有诸多好处:

  • 可复用性:在不同的地方能够多次使用同一个组件,避免重复编写代码。例如,一个按钮组件在多个页面中都能使用。
  • 可维护性:将复杂的界面拆分成多个小的组件,每个组件的功能单一,便于理解和维护。要是某个组件出现问题,只需修改该组件即可。
  • 可组合性:组件可以相互嵌套,构成树形结构,数据与事件沿树状层级传递交互。通过将多个简单组件以树状方式组合,如基础按钮等组件嵌套为复杂表单、导航模块,可构建出功能丰富、界面复杂的应用程序。

2. 组件的定义与注册

单文件组件 ( SFC )

Vue 3 推荐使用 .vue 文件封装组件。这类单文件组件将模板(HTML)、逻辑(JavaScript)和样式(CSS)整合于同一文件,通过 <template><script><style> 三大标签划分结构。

<template> 标签(模板)

<template> 标签用于定义组件的 HTML 结构,也就是组件的视图部分。它只能包含一个根元素,可使用 Vue 的模板语法,如插值表达式 {{ }}、指令(v-bind等)。

<template>
  <div>
    <h1>{{ message }}</h1>
    <button @click="changeMessage">修改消息</button>
  </div>
</template>
<script> 标签(逻辑)

<script> 标签用于编写组件的 JavaScript 逻辑,涵盖数据、方法、生命周期钩子等。

Vue 3 推荐使用 <script setup> 语法糖简化开发,该语法无需显式导出 setup 函数,导入的变量及定义的函数可直接在模板中调用。

<script setup>
  // 导入创建响应式数据的 ref 函数
  import { ref } from 'vue';
  // 创建响应式数据 message
  const message = ref('初始消息');
  // 定义修改 message 的函数
  const changeMessage = () => {
    message.value = '新消息';
  };
</script>
<style> 标签(样式)

<style> 标签用于定义组件的 CSS 样式。可以通过 scoped 属性让样式只对当前组件生效,避免全局样式冲突。

<style scoped>
h1 {
  color: blue;
}
button {
  background-color: lightgray;
}
</style>

全局注册

Vue 3 通过createApp函数创建应用实例,注册组件需基于此实例进行。

全局注册是指在 Vue 应用中,将组件注册为全局可用的组件。一旦组件被全局注册,那么在该应用的任何组件模板中都可以直接使用这个组件,无需在每个使用它的组件中单独导入和注册。

  • 适用场景
    • 基础组件:像按钮、输入框、下拉框等这类基础的 UI 组件,在应用的多个页面和组件中都会频繁使用。
    • 通用功能组件:例如加载提示组件、消息提示框组件等,它们承担着通用的功能,在整个应用的不同部分都可能会用到。

在 Vue 3 里,要先使用 createApp 函数创建一个应用实例,接着使用这个实例的 component 方法来全局注册组件。该方法接收两个参数,第一个参数是组件的名称,第二个参数是组件的配置对象。

import { createApp } from 'vue';
// 定义一个简单的组件
const MyButton = {
    template: '<button>这是一个全局注册的按钮</button>'
};
// 创建应用实例
const app = createApp({});
// 全局注册组件
app.component('my-button', MyButton);
// 挂载应用到 DOM
app.mount('#app');

全局注册虽然使用方便,但它存在一些缺点:

  • 资源浪费:全局注册的组件在应用启动时全部加载,即便未被使用也会保留在打包后的 JS 文件中(无法 “tree - shaking”),导致初始加载时间和内存占用增加。
  • 命名冲突风险:不同开发者可能注册同名组件,引发命名冲突问题。
  • 依赖关系模糊:在大型项目中,父组件使用子组件时难以定位其实现,导致依赖关系不清晰,影响应用长期可维护性。

为了避免这些问题,Vue 提供了局部注册的方式👇

局部注册

局部注册是指仅在某个组件内部可用的组件注册方式。局部注册的组件只能在注册它的父组件及其子组件(若有传递)中使用,不会影响到其他组件。

  • 适用场景
    • 功能特定复用:当组件仅用于特定父组件的局部功能。
      • 例如复杂表单里的自定义验证提示组件、列表项内的操作按钮组,它们仅在对应表单或列表场景中复用。
    • 低频使用优化:组件仅在特定页面或业务流程中偶尔使用。
      • 如特定条件下显示的警告弹窗,使用局部注册能减少全局资源占用。

在 Vue 3 中,局部注册通常通过在组件内导入并使用组件实现。常见的方式是在<script setup>中直接导入组件,无需额外的注册语法。

<template>
    <!-- 使用局部注册的子组件 -->
    <ChildComponent />
</template>

<script setup>
// 导入子组件
import ChildComponent from './ChildComponent.vue';
</script>   

二、Vue 3 组件通信机制

1. 父子组件通信

props传递数据

props遵循单向数据流原则,也就是数据只能从父组件流向子组件,子组件不能直接修改从父组件接收到的props数据。

父组件通过属性绑定向子组件传递数据,同时子组件使用defineProps来声明接收的props,并且支持类型校验。

<!-- 父组件 -->
<ChildComponent :message="parentMsg" :count="parentCount" />

<!-- 子组件 -->
<script setup>
const props = defineProps({
  message: { type: String, required: true },
  count: { type: Number, default: 0 }
});
</script>

$emit触发自定义事件

在 Vue 应用里,遵循单向数据流原则,数据一般从父组件流向子组件。但在某些场景下,子组件需要向父组件反馈信息或通知其执行特定操作,此时就可借助自定义事件达成目的。通过 emits 声明组件可触发的自定义事件,不仅能让组件的事件接口更加清晰,还能助力 Vue 进行事件类型检查与性能优化。

子组件通过 defineEmits 声明事件,再使用 emit 方法来触发这些事件,进而通知父组件。

<!-- 子组件 -->
<script setup>
import { defineEmits } from 'vue';
// 声明可触发的自定义事件
const emit = defineEmits(['update', 'delete']);
// 定义一个函数,用于触发 update 事件并传递数据
function handleUpdate() {
  // 触发 update 事件,并传递一个对象数据给父组件
  emit('update', { name: 'John', age: 30 }); 
}
</script>

<!-- 父组件 -->
<template>
  // 监听子组件的名为 update 的自定义事件
  <ChildComponent @update="handleUpdateEvent" />
</template>

<script setup>
const handleUpdateEvent = (data) => {
  console.log('接收到子组件传递的数据:', data);
};
</script>

v-model双向数据绑定

v-model 是一个实用的语法糖,可以实现组件间的双向数据绑定。它本质上结合了 props 和 $emit,把数据从父组件传递到子组件,同时让子组件能够将数据的更新反馈给父组件。

默认情况下,v-model 会使用 modelValue 作为接收数据的 prop,使用 update:modelValue 作为触发数据更新的事件。`

<!-- 子组件 -->
<template>
  <!-- 绑定值,监听输入事件 -->
  <input :value="modelValue" @input="updateValue($event.target.value)" />
</template>

<script setup>
import { defineProps, defineEmits } from 'vue';
// 定义 props
const props = defineProps({ modelValue: String });
// 声明事件
const emits = defineEmits(['update:modelValue']);
// 触发更新事件
const updateValue = (value) => emits('update:modelValue', value);
</script>

<!-- 父组件 -->
<template>
  <!-- 使用 v-model 绑定 -->
  <ChildComponent v-model="parentValue" />
  <!-- 显示绑定值 -->
  <p>{{ parentValue }}</p>
</template>

<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
// 创建响应式变量
const parentValue = ref('');
</script>

上述代码中:

初始数据传递:父组件 -> 子组件

父组件使用 v-model 绑定 parentValue 到子组件,这相当于 <ChildComponent :modelValue="parentValue" />,即把 parentValue 作为 modelValue prop 传给子组件。子组件通过 defineProps 接收 modelValue,并在输入框中显示该值:<input :value="modelValue" />

数据更新反馈:子组件 -> 父组件

  • 事件触发:用户在子组件输入框输入内容时,触发 input 事件。

  • 事件处理:子组件通过 @input 监听此事件,调用 updateValue 方法触发 update:modelValue 事件并传递新值。

  • 父组件响应:父组件的 v-model 监听 update:modelValue 事件,触发时自动更新 parentValue(等价于 <ChildComponent @update:modelValue="parentValue = $event" />),页面上 <p>{{ parentValue }}</p> 也随之更新。

refdefineExpose

主要用于父组件访问子组件的特定方法和属性。 父组件可以通过ref引用子组件实例,子组件可以使用defineExpose来暴露特定的属性和方法供父组件访问。

<template>
  <div>
    <ChildComponent ref="childRef" />
    <button @click="callChildMethod">调用子组件方法</button>
    <button @click="logChildData">打印子组件数据</button>
  </div>
</template>

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

const childRef = ref(null);

const callChildMethod = () => {
  childRef.value?.childMethod();
};

const logChildData = () => {
  if (childRef.value) {
    console.log('子组件数据:', childRef.value.childData);
  }
};

const ChildComponent = {
  template: '<div>子组件</div>',
  setup() {
    // 增加子组件状态数据
    const childData = ref('这是子组件的状态数据');

    const childMethod = () => {
      console.log('子组件方法被调用');
    };

    // 导入 defineExpose 函数
    import { defineExpose } from 'vue';
    // 暴露 childMethod 方法和 childData 数据
    defineExpose({
      childMethod,
      childData: childData.value
    });

    return {};
  }
};
</script>  
  • 父组件借助 ref 创建引用 childRef 并绑定到子组件,分别定义 callChildMethod 和 logChildData 函数用于调用子组件方法和获取数据。
  • 子组件使用 ref 创建状态数据 childData,定义 childMethod 方法,通过 defineExpose 暴露该方法和数据,使得父组件能够通过 childRef.value 访问它们。

2. 非父子组件通信

事件总线(推荐mitt库)

事件总线是一种在软件系统中实现组件间通信的设计模式。它就像是一个 “中介”,各个组件可以通过它来发送和接收事件,从而实现彼此之间的信息传递和交互,而不需要直接依赖对方。

在这种模式中,有一个中心的事件总线对象,它负责管理事件的注册和触发。组件可以向事件总线注册自己感兴趣的事件,并提供相应的回调函数。当其他组件触发某个事件时,事件总线会遍历所有注册了该事件的回调函数,并依次执行它们,从而实现了组件之间的通信。

Vue 3 移除内置EventBus,推荐使用第三方库(如mitt)实现跨组件通信。

// 创建事件总线
import mitt from 'mitt';
const emitter = mitt();

// 组件A触发事件
emitter.emit('user-login', { userId: 123 });

// 组件B监听事件
emitter.on('user-login', (data) => {
  // 处理登录逻辑
});
  • on 方法是事件总线 emitter 提供的用于注册事件监听的方法。它也接受两个参数,第一个参数是要监听的事件名称('user-login'),第二个参数是一个回调函数。
  • 当事件总线接收到名称为 'user-login' 的事件时,就会遍历所有注册了该事件的回调函数,并依次执行它们。
  • 在这里,当组件 A 触发了 'user-login' 事件后,组件 B 注册的这个回调函数就会被执行,并且会接收到组件 A 传递过来的数据 data(即 { userId: 123 }),然后可以在回调函数内部进行处理登录逻辑等操作。

状态管理(pinia

针对大型应用,pinia是 Vue 3 推荐的状态管理方案,比 Vuex 更简洁且支持 TypeScript。

// 定义store
import { defineStore } from 'pinia';
const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: { increment() { this.count++; } }
});

// 使用store
const store = useCounterStore();
store.increment();

3. 跨层级组件通信

provide/inject依赖注入

祖先组件通过provide暴露数据,后代组件通过inject获取,穿透多层组件。

  • provide:在祖先组件中使用,用于将数据或方法提供给后代组件。
    • 它接受两个参数,第一个参数是要提供的依赖的键(字符串),第二个参数是要提供的数据或方法。
    • 可以将其理解为在组件树的顶层设置了一个 “共享资源”,供后代组件使用。
  • inject:在后代组件中使用,用于接收祖先组件提供的数据或方法。
    • 它接受一个参数,即要注入的依赖的键,与provide中设置的键相对应。
    • 通过inject,后代组件可以轻松获取到祖先组件提供的 “共享资源”,而无需知道这些资源是从哪里来的,也不需要在组件之间层层传递。
// 祖先组件
import { provide, ref } from 'vue';
const theme = ref('dark');
provide('theme', theme);

// 后代组件
import { inject } from 'vue';
const currentTheme = inject('theme');

$attrs

$attrs 是一个对象,它存放着父组件传递过来但未在子组件 props 选项中声明的那些属性。

  • 这些属性包括普通的 HTML 特性(如 classstyle 等)以及自定义的特性。

同时,在 Vue 3 中,$listeners 已合并至 $attrs 中,这意味着原本在 Vue 2 中 $listeners 所包含的事件监听器现在也包含在 $attrs 中了。

这样一来,通过 v-bind="$attrs" 就可以很方便地将这些未声明的属性和事件批量传递给子组件。

<!-- 父组件 -->
<template>
  <Child 
    username="John" 
    email="john@example.com" 
    phone="123456789" 
    address="123 Street" 
  />
</template>

<!-- 子组件 -->
<template>
  <div>
    <!-- 直接使用 $attrs 传递所有未声明属性 -->
    <GrandChild v-bind="$attrs" />
  </div>
</template>

4. 常见问题

父组件给子组件传值,子组件是否能修改?为什么?

一般情况下,子组件不应该直接修改父组件传入的值。因为 Vue 遵循单向数据流的原则,即数据只能从父组件流向子组件,目的是为了保持数据流向的清晰和可维护性,避免数据在多个组件之间随意修改导致的难以追踪的错误。

当父组件的状态发生变化时,会向下传递给子组件,子组件基于接收到的 props 进行渲染。如果子组件直接修改 props,会破坏这种单向数据流,使得数据的变化来源不明确,增加调试和维护的难度。此外,Vue 会对直接修改 props 的行为发出警告。

如何修改父组件传入的值?

虽然子组件不能直接修改父组件传入的值,但可以通过以下几种方式来实现类似的效果:

  • 使用事件与 emits 选项:子组件需在 emits 选项声明自定义事件,然后通过 emit 函数触发该事件,并传递要修改的值。父组件监听此事件,在回调里修改自身状态,从而间接改变传入子组件的值 。比如子组件想让父组件传入的数值加 1,就触发自定义事件并把加 1 后的值传出,父组件接收到后更新自身数据。
  • 使用计算属性:若只是要对传入值进行计算后展示或使用,可利用计算属性。计算属性基于响应式依赖缓存,当依赖的父组件值变动时会重新计算。像将父组件传入数值翻倍展示,就可用计算属性实现。
  • 针对对象或数组类型的 props:对于父组件传入的对象或数组(引用类型),虽然不能直接修改 props 本身,但能修改其内部属性或元素,子组件的这类修改会反映到父组件。例如子组件给父组件传入的数组添加元素,但这种方式要谨慎,防止数据流向混乱。

三. 组件插槽机制

1. 插槽基础概念

在 Vue 组件中,插槽(Slot)主要用于在组件的模板中预留内容插入的位置,使得父组件可以向子组件指定的位置插入自定义的 HTML 内容、组件或者文本。

可以把插槽想象成一个占位符,子组件定义插槽后,就像在自己的模板里预留了一个 “坑”,而父组件在使用子组件时,可以把自己的内容填到这个 “坑” 里。

2. 插槽类型与使用

默认插槽(Anonymous Slots)

默认插槽,也被称为匿名插槽,是在 Vue 组件里未指定名称的 <slot> 标签。

当父组件在使用子组件时,那些没有匹配到具名插槽的内容会自动填充到默认插槽的位置。

  • 语法
<!-- 子组件Card -->
<template>
  <div class="card">
    <h2>Card Title</h2>
    <slot>默认内容</slot> <!-- 父组件未传内容时显示 -->
  </div>
</template>

<!-- 父组件使用 -->
<Card>
  <p>自定义卡片内容</p> <!-- 填充默认插槽 -->
</Card>

当页面渲染时,最终呈现的 HTML 结构大致如下:

<div class="card">
  <h2>Card Title</h2>
  <p>自定义卡片内容</p>
</div>

具名插槽(Named Slots)

具名插槽是在 Vue 组件里指定了名称的 <slot> 标签。借助具名插槽,父组件能够把内容精准地填充到子组件里特定的位置。

当父组件使用子组件时,能够利用 v-slot 指令(简写为 #)把内容分配到对应的具名插槽。

  • 语法
<!-- 子组件Card -->
<template>
  <div class="card">
    <h2>Card Title</h2>
    <slot name="header">默认头部内容</slot> 
    <slot>默认主体内容</slot> 
    <slot name="footer">默认底部内容</slot> 
  </div>
</template>

<!-- 父组件使用 -->
<Card>
  <template #header>
    <h3>自定义头部</h3> 
  </template>
  <p>自定义主体内容</p> 
  <template #footer>
    <p>自定义底部</p> 
  </template>
</Card>

页面渲染时,最终呈现的 HTML 结构大致如下:

<div class="card">
  <h2>Card Title</h2>
  <h3>自定义头部</h3>
  <p>自定义主体内容</p>
  <p>自定义底部</p>
</div>

在上述示例中,子组件 Card 里有三个插槽,分别是具名插槽 headerfooter 以及默认插槽。父组件使用 v-slot 指令把不同的内容填充到对应的插槽中。要是父组件没有为某个具名插槽提供内容,就会显示该插槽的默认内容。

作用域插槽(Scoped Slots)

作用域插槽是一种特殊的插槽,它允许父组件在使用子组件时,能够访问子组件的数据。通过作用域插槽,子组件可以将自己的数据传递给父组件,让父组件根据这些数据来决定如何渲染内容。

当父组件使用子组件的作用域插槽时,可以在插槽标签上使用 v-slot 指令(简写为 #)来接收子组件传递的数据。

  • 语法
<!-- 子组件List -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot :item="item">
        {{ item.text }} <!-- 父组件未传内容时显示 -->
      </slot>
    </li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: 'Item 1' },
        { id: 2, text: 'Item 2' },
        { id: 3, text: 'Item 3' }
      ]
    };
  }
};
</script>

<!-- 父组件使用 -->
<List>
  <template #default="{ item }">
    <span>{{ item.text.toUpperCase() }}</span>
  </template>
</List>

当页面渲染时,最终呈现的 HTML 结构大致如下:

<ul>
  <li>
    <span>ITEM 1</span>
  </li>
  <li>
    <span>ITEM 2</span>
  </li>
  <li>
    <span>ITEM 3</span>
  </li>
</ul>
  • 示例中,子组件 List 有一个作用域插槽,它通过 :item="item" 将每个列表项的数据传递给父组件。
  • 父组件使用 v-slot:default="{ item }"接收子组件传递的数据,并在插槽内容中使用这些数据进行渲染。
  • 这里,父组件将列表项的文本转换为大写后显示。如果父组件没有为作用域插槽提供内容,就会显示插槽的默认内容。