Vue 组件通信

Vue 组件通信

在 Vue 项目开发中,理解组件间的数据传递是构建可维护应用的基础。这个博客从项目结构出发,整理父子组件通信的原理、单向数据流的限制,以及如何使用 v-model 实现双向绑定。

项目结构

在标准的 Vue 项目(如基于 Vite 或 Webpack 构建)中,src 目录下的结构通常按照下面的方式组织:

  • src/views (或 pages):这里的每一个 .vue 文件通常对应路由系统中的一个页面。
  • src/components:这里存放的是通用组件。

类似于在 WPF 开发中,有 Page(页面),也有 UserControl(用户控件)。当需要一个通用的表单结构、一个数据展示卡片,或者一个自定义的按钮时,我们不应该把代码写死在页面里,而是将其封装在 src/components 中。核心的想法就是,组件不是页面,而是页面中的积木。我们希望组件能不知情地被放置在任何页面中复用。

数据驱动的组件控制

Vue 是基于 MVVM(Model-View-ViewModel)架构的,核心理念是数据驱动视图。在传统 jQuery 时代,要显示一个弹窗,可能会直接操作 DOM(如 $('#dialog').show())。但在数据驱动的架构中,我们要摒弃这种思维。我们控制的是变量值。比如我们要控制页面中某个组件的显示(建立)或者消失(销毁),比如当点击某个”添加“按钮的时候,弹出一个表单组件,那么就可以使用通过该百年 v-if 或者 v-show 的值来控制:

  • v-if:当变量为 true 时,组件被创建(DOM 插入);变量为 false 时,组件被销毁(DOM 移除)。
  • v-show:仅切换 CSS 的 display 属性,组件始终存在于 DOM 中。
1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { ref } from 'vue'
const drawerOpen = ref(false)
</script>

<template>
<!-- @click 是 v-on:click 的缩写,监听 click 事件,执行后面的操作 -->
<button @click="drawerOpen = true">打开抽屉</button>

<!-- 使用 v-if 控制组件的挂载与销毁 -->
<MyDrawer v-if="drawerOpen" />
</template>

这里引出了一个问题:父组件持有 drawerOpen 变量,但如果我想在子组件(抽屉内部)点击“关闭”按钮来改变这个变量,该怎么办? 这就涉及到了组件通信。

父传子:Props(属性传递)

为什么不能直接访问?

Vue 的组件设计遵循组件隔离原则。虽然 JavaScript 中有闭包的概念,但在 Vue 中,import 引入的子组件仅仅是一个组件的定义(对象或类)。在父组件模板中使用 <Child /> 时,Vue 会在底层实例化这个组件。父组件和子组件的作用域是完全独立的。子组件无法直接读取父组件的变量,除非父组件显式地“递”过去。

这里涉及到了词法作用域和动态作用域的区别,可以参考前一篇博客。

使用 defineProps 接收数据

在子组件中,需要使用 defineProps 宏来声明“我愿意接收哪些数据”。

父组件:

1
2
<!-- 使用 :title (即 v-bind:title) 传递变量,使用 title="..." 传递纯字符串 -->
<Child title="你好" :count="10" />

子组件 (Child.vue):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
// 定义 Props 接口
interface Props {
title?: string
count?: number
}

// 声明 props,Vue 会自动将其注入到当前实例
const props = defineProps<Props>()

console.log(props.title) // 在 script 中使用
</script>

<template>
<!-- 在 template 中直接使用 -->
<div>{{ title }}</div>
</template>

Attribute 透传 (Fallthrough Attributes)

如果在父组件传递了某个属性(例如 class="active"id="card-1"),但子组件没有通过 defineProps 声明它,Vue 会自动把这些属性“透传”并挂载到子组件的根元素上。

但这种透传仅限于 HTML 属性,无法在 <script setup> 逻辑中作为数据使用。

子传父:Emits(事件通知)

Vue 严格遵循单向数据流原则:数据应该从父组件流向子组件,子组件不应直接修改父组件的数据。这是因为如果在子组件内部执行 props.title = '新标题',由于对象引用的关系,父组件的数据可能也会变。但父组件对此毫不知情。如果父组件把这个数据同时传给了 5 个子组件,一旦出现数据异常,就很难追踪是哪个子组件“偷偷”修改了数据。

因此,在开发环境下,如果我们尝试修改 props,Vue 会在控制台抛出警告:

[Vue warn]: Set operation on key "title" failed: target is readonly.

既然不能直接改,子组件如果想改变数据,必须通知父组件,让父组件自己去改。这就是 Emit(发射事件) 机制。

基础写法:Prop + Emit

这是一个标准的“请求-响应”模式。

子组件 (MyChild.vue):

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
// 1. 声明接收的数据
defineProps<{ title: string }>()

// 2. 声明即将会触发的事件 'update:title'
// 这里的 update:title 只是一个事件名,你可以叫它 'change-title' 或任何名字
const emit = defineEmits<{'update:title': [value: string]}>()

const changeTitle = () => {
// 向外喊话:我要改 title,新值是 'New Title'
emit('update:title', 'New Title')
}
</script>

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup lang="ts">
import { ref } from 'vue'
import MyChild from './MyChild.vue'

const pageTitle = ref('初始值')

// 处理函数:接收子组件传来的 newValue
const handleUpdate = (newValue: string) => {
pageTitle.value = newValue
}
</script>

<template>
<MyChild
:title="pageTitle"
@update:title="handleUpdate"
/>
</template>

v-model 实现双向绑定

上面的写法虽然标准,但非常啰嗦:我们要写一个 prop,还要写一个事件监听,还要写一个处理函数。Vue 提供了 v-model 指令作为语法糖,完美解决了这个问题。

当我们写 <MyChild v-model:title="pageTitle" /> 时,Vue 编译器会自动帮我们展开成以下代码:

1
2
3
4
<MyChild 
:title="pageTitle"
@update:title="(newValue) => pageTitle = newValue"
/>

它自动完成了两件事: 1. 传递名为 title 的 prop。 2. 监听名为 update:title 的事件,并自动将事件参数赋值给 pageTitle

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">
import { ref } from 'vue'
import MyChild from './MyChild.vue'

const pageTitle = ref('初始标题')
</script>

<template>
<!-- 简洁的双向绑定 -->
<MyChild v-model:title="pageTitle" />

<p>父组件中的值:{{ pageTitle }}</p>
</template>

子组件 (MyChild.vue) - 保持不变:

只要子组件遵循 Prop 为 xxxEmit 事件为 update:xxx 的命名规范,v-model 就能自动生效。

如果我们省略参数,直接写 <MyChild v-model="pageTitle" />,Vue 会默认使用以下名称: * Prop 名称modelValue * 事件名称update:modelValue

子组件写法调整:

1
2
3
4
5
6
7
8
9
<script setup lang="ts">
// 接收 modelValue
defineProps<{ modelValue: string }>()
const emit = defineEmits<{'update:modelValue': [val: string]}>()

function update() {
emit('update:modelValue', '新值')
}
</script>