Vue3父子组件相互调用方法深度剖析
Vue3 父子组件相互调用方法深度剖析
目录
- 前言
- 父子组件调用基本概念回顾
- 3.1 传统 Options API 中使用
$ref
- 3.2 Vue3 Composition API 中使用
ref
+expose
- 3.3 图解:父调子流程示意
- 3.1 传统 Options API 中使用
- 4.1 通过
$emit
触发自定义事件(Options / Composition) - 4.2 使用
props
传回调函数 - 4.3 使用
provide
/inject
共享方法 - 4.4 图解:子调父流程示意
- 4.1 通过
- 5.1 全局事件总线(Event Bus)
- 5.2 状态管理(Pinia/Vuex)
- 6.1 目录结构与功能需求
- 6.2 代码实现与图解
- 常见误区与注意事项
- 总结
前言
在 Vue3 开发中,父组件与子组件之间的双向调用几乎是最常见的需求之一:父组件需要主动调用子组件的内置方法或更新子组件状态;同时,子组件也常常需要告诉父组件“我这边发生了一件事”,触发父组件做出反应。Vue2 时期我们主要靠 $ref
、$emit
和 props
来完成这类交互,而 Vue3 推出了 Composition API、defineExpose
、setup
中可注入 emit
等机制,使得父子之间的方法调用更灵活、更类型安全。
本文将从底层原理和多种实战场景出发,深度剖析父组件调用子组件方法、子组件调用父组件方法 的所有常见套路,并配上代码示例与ASCII 图解,帮助你彻底搞懂 Vue3 中父子组件相互调用的各种姿势。
父子组件调用基本概念回顾
组件实例与 DOM 节点区分
- 在 Vue 中,一个组件的“实例”与它对应的 DOM 元素是分离的。要调用组件内部的方法,必须先拿到那个组件实例——在模板里就是用
ref="compRef"
,然后在父组件脚本里通过const comp = ref(null)
拿到。 comp.value
指向的是组件实例,而非它最外层的 DOM 节点。内部定义在methods
(Options)或setup
返回的函数才能被调用。
- 在 Vue 中,一个组件的“实例”与它对应的 DOM 元素是分离的。要调用组件内部的方法,必须先拿到那个组件实例——在模板里就是用
Vue2 vs Vue3 的差异
- 在 Vue2 中,父组件用
$refs.compRef.someMethod()
调用子组件中定义的someMethod
; 而子组件则$emit('eventName', payload)
触发自定义事件,父组件在模板上写<child @eventName="handleEvent"/>
监听。 - 在 Vue3 Composition API 中,建议在子组件内使用
defineExpose({ someMethod })
明确暴露给父组件;父组件依旧用ref
引用组件实例并调用;子组件触发事件时仍可用emit('eventName', payload)
或直接通过props
回调方式调用。
- 在 Vue2 中,父组件用
父组件调用子组件方法
3.1 传统 Options API 中使用 $ref
<!-- ChildOptions.vue -->
<template>
<div>
<p>子组件计数:{{ count }}</p>
<button @click="increment">子组件 +1</button>
</div>
</template>
<script>
export default {
data() {
return { count: 0 };
},
methods: {
increment() {
this.count++;
},
reset(val) {
this.count = val;
}
}
};
</script>
<!-- ParentOptions.vue -->
<template>
<div>
<ChildOptions ref="childComp" />
<button @click="callChildIncrement">父调用子 increment()</button>
<button @click="callChildReset">父调用子 reset(100)</button>
</div>
</template>
<script>
import ChildOptions from './ChildOptions.vue';
export default {
components: { ChildOptions },
methods: {
callChildIncrement() {
this.$refs.childComp.increment();
},
callChildReset() {
this.$refs.childComp.reset(100);
}
}
};
</script>
- 流程:父模板上用
ref="childComp"
,父实例的this.$refs.childComp
就是子组件实例对象,直接调用其内部methods
。 - 局限:如果子组件使用 Composition API 或 script setup,这种方式依然可以,但在
<script setup>
中需配合defineExpose
(见下文)。
3.2 Vue3 Composition API 中使用 ref
+ expose
在 Vue3 <script setup>
里,子组件的内部函数默认对外是“私有”的。若要父组件调用,需要显式暴露:
<!-- ChildComposition.vue -->
<template>
<div>
<p>子组件计数:{{ count }}</p>
<button @click="increment">子组件 +1</button>
</div>
</template>
<script setup>
import { ref, defineExpose } from 'vue';
const count = ref(0);
function increment() {
count.value++;
}
function reset(val) {
count.value = val;
}
// 暴露给父组件:允许父组件通过 ref 调用这两个方法
defineExpose({ increment, reset });
</script>
<!-- ParentComposition.vue -->
<template>
<div>
<ChildComposition ref="childComp" />
<button @click="callChildIncrement">父调用子 increment()</button>
<button @click="callChildReset">父调用子 reset(200)</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import ChildComposition from './ChildComposition.vue';
const childComp = ref(null);
function callChildIncrement() {
childComp.value.increment();
}
function callChildReset() {
childComp.value.reset(200);
}
</script>
说明:
defineExpose({ ... })
:将需要被父组件访问的方法暴露给外部,否则父组件拿到的 ref.value 并不会包含这些内部函数;childComp.value
就是子组件实例,已包含increment
、reset
两个可调用方法;
3.3 图解:父调子流程示意
[ ParentComposition.vue ]
─────────────────────────────────────────────────
1. 模板:<ChildComposition ref="childComp" />
2. 脚本: const childComp = ref(null)
3. 用户点击 “父调用子 increment()” 按钮
│
▼
4. 执行 callChildIncrement() { childComp.value.increment() }
│
▼
[ 子组件实例 ]
─────────────────────────────────────────────────
5. increment() 方法被触发,count++
6. 子组件视图更新,显示新 count
ASCII 图示:
Parent (button click)
↓
parentRef.childComp.increment()
↓
Child.increment() → count.value++
↓
Child 重新渲染
子组件调用父组件方法
4.1 通过 $emit
触发自定义事件
这是最常见的方式,无论 Options API 还是 Composition API,都遵循同样的思路。子组件通过 emit('eventName', payload)
向上触发事件,父组件在模板里以 @eventName="handler"
监听。
Options API 示例
<!-- ChildEmitOptions.vue -->
<template>
<button @click="notifyParent">子组件通知父组件</button>
</template>
<script>
export default {
methods: {
notifyParent() {
// 子组件向父组件发送 'childClicked' 事件,携带 payload
this.$emit('childClicked', { msg: '你好,父组件!' });
}
}
};
</script>
<!-- ParentEmitOptions.vue -->
<template>
<div>
<ChildEmitOptions @childClicked="handleChildClick" />
</div>
</template>
<script>
import ChildEmitOptions from './ChildEmitOptions.vue';
export default {
components: { ChildEmitOptions },
methods: {
handleChildClick(payload) {
console.log('父组件收到子组件消息:', payload.msg);
// 父组件执行其他逻辑…
}
}
};
</script>
Composition API 示例
<!-- ChildEmitComposition.vue -->
<template>
<button @click="notify">子组件发射事件</button>
</template>
<script setup>
import { defineEmits } from 'vue';
const emit = defineEmits(['childClicked']);
function notify() {
emit('childClicked', { msg: 'Hello from Child!' });
}
</script>
<!-- ParentEmitComposition.vue -->
<template>
<ChildEmitComposition @childClicked="onChildClicked" />
</template>
<script setup>
import ChildEmitComposition from './ChildEmitComposition.vue';
function onChildClicked(payload) {
console.log('父组件监听到子事件:', payload.msg);
}
</script>
说明:
- 在
<script setup>
中,使用const emit = defineEmits(['childClicked'])
来声明可触发的事件;- 父组件在模板上直接用
@childClicked="onChildClicked"
来监听;
4.2 使用 props
传回调函数
有时子组件也可以接收一个函数类型的 prop
,父组件将自己的方法以 prop 传入,子组件直接调用。
<!-- ChildPropCallback.vue -->
<template>
<button @click="handleClick">调用父传入的回调</button>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
onNotify: {
type: Function,
required: true
}
});
function handleClick() {
props.onNotify('来自子组件的消息');
}
</script>
<!-- ParentPropCallback.vue -->
<template>
<ChildPropCallback :onNotify="parentMethod" />
</template>
<script setup>
import ChildPropCallback from './ChildPropCallback.vue';
function parentMethod(message) {
console.log('父组件通过 props 接收:', message);
}
</script>
要点:
ChildPropCallback
定义了一个函数类型的prop
,名为onNotify
;- 父组件以
:onNotify="parentMethod"
传入自身的方法;- 子组件在内部直接调用
props.onNotify(...)
;- 这种方式在 TypeScript 环境下更易于类型推导,但语义略显“耦合”。
4.3 使用 provide
/ inject
共享方法
当父组件与子组件之间有多层嵌套时,逐层传递 props
或事件可能显得繁琐。可利用 Vue3 的依赖注入机制,让父组件将某个方法“提供”出去,任意后代组件都可“注入”并调用。
<!-- ParentProvide.vue -->
<template>
<div>
<h2>父组件</h2>
<Intermediate />
</div>
</template>
<script setup>
import { provide } from 'vue';
import Intermediate from './Intermediate.vue';
function parentMethod(msg) {
console.log('父组件收到:', msg);
}
// 父组件提供方法给后代
provide('parentMethod', parentMethod);
</script>
<!-- Intermediate.vue -->
<template>
<div>
<h3>中间层组件</h3>
<ChildInject />
</div>
</template>
<script setup>
import ChildInject from './ChildInject.vue';
</script>
<!-- ChildInject.vue -->
<template>
<button @click="callParent">注入调用父方法</button>
</template>
<script setup>
import { inject } from 'vue';
const parentMethod = inject('parentMethod');
function callParent() {
parentMethod && parentMethod('Hello via provide/inject');
}
</script>
说明:
ParentProvide
通过provide('parentMethod', parentMethod)
给整棵组件树中提供一个名为parentMethod
的方法;ChildInject
可以在任意深度中通过inject('parentMethod')
拿到这个函数,直接调用;- 这种做法适合多层嵌套,避免 props/emit “层层传递”;但要注意依赖隐式性,代码可读性稍差。
4.4 图解:子调父流程示意
[ ParentProvide.vue ]
────────────────────────────────────────────
1. provide('parentMethod', parentMethod)
[ Intermediate.vue ]
────────────────────────────────────────────
2. 渲染子组件 <ChildInject />
[ ChildInject.vue ]
────────────────────────────────────────────
3. inject('parentMethod') → 拿到 parentMethod 引用
4. 用户点击按钮 → callParent() → parentMethod('msg')
↓
5. 执行 ParentProvide 中的 parentMethod,输出“Hello via provide/inject”
ASCII 图示:
ParentProvide
│ provide('parentMethod')
▼
Intermediate
│
▼
ChildInject (inject parentMethod)
│ click → parentMethod(...)
▼
ParentProvide.parentMethod 执行
非嵌套组件间的通信方式简述
本文聚焦 父子组件 间的调用,但若两个组件并非父子关系,也可通过以下方式通信:
5.1 全局事件总线(Event Bus)
// bus.js
import mitt from 'mitt';
export const bus = mitt();
<!-- ComponentA.vue -->
<script setup>
import { bus } from '@/bus.js';
function notify() {
bus.emit('someEvent', { data: 123 });
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { bus } from '@/bus.js';
import { onMounted, onUnmounted } from 'vue';
function onSomeEvent(payload) {
console.log('收到事件:', payload);
}
onMounted(() => {
bus.on('someEvent', onSomeEvent);
});
onUnmounted(() => {
bus.off('someEvent', onSomeEvent);
});
</script>
注:Vue2 时代常用 Vue.prototype.$bus = new Vue() 做事件总线;Vue3 推荐使用轻量的 mitt。
5.2 状态管理(Pinia/Vuex)
将共享数据或方法放到全局 Store 中,组件通过挂载到 Store 上的 action 或 mutation 来“调用”它。
// store/useCounter.js (Pinia)
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++;
}
}
});
<!-- ComponentA.vue -->
<script setup>
import { useCounterStore } from '@/store/useCounter';
const counter = useCounterStore();
function doIncrement() {
counter.increment();
}
</script>
<!-- ComponentB.vue -->
<script setup>
import { useCounterStore } from '@/store/useCounter';
import { computed } from 'vue';
const counter = useCounterStore();
const countValue = computed(() => counter.count);
</script>
<template>
<div>当前 count:{{ countValue }}</div>
</template>
这种方式适合跨层级、无嵌套限制、业务逻辑更复杂的场景,但会稍微增加项目耦合度。
综合示例:聊天室场景
为了将上述原理集中演示,下面以一个简单的“聊天室”举例。父组件管理登录与连接状态,子组件分别展示消息列表并发送新消息。
6.1 目录结构与功能需求
src/
├─ services/
│ └─ stompService.js (前文封装的 STOMP 服务)
├─ components/
│ ├─ ChatRoom.vue (父组件:登录/订阅/断开)
│ ├─ MessageList.vue (子组件:显示消息列表)
│ └─ MessageInput.vue (子组件:输入并发送消息)
└─ main.js
功能:
- 登录后建立 STOMP 连接并订阅
/topic/chat
; MessageList
监听父组件传入的messages
数组并渲染;MessageInput
输入内容后向父组件发出send
事件,父组件通过stompService.send()
通知后端;- 后端广播到
/topic/chat
,父组件通过stompService.subscribe()
接收并将消息推入messages
;
6.2 代码实现与图解
6.2.1 ChatRoom.vue (合并前面的示例,略作精简)
<template>
<div class="chat-room">
<div class="header">
<span v-if="!loggedIn">未登录</span>
<span v-else>用户:{{ username }}</span>
<button v-if="!loggedIn" @click="login">登录</button>
<button v-else @click="logout">退出</button>
<span class="status">状态:{{ connectionStatus }}</span>
</div>
<MessageList v-if="loggedIn" :messages="messages" />
<MessageInput v-if="loggedIn" @send="handleSend" />
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue';
import * as stompService from '@/services/stompService';
import MessageList from './MessageList.vue';
import MessageInput from './MessageInput.vue';
const loggedIn = ref(false);
const username = ref('');
const messages = ref([]);
// 监听消息
function onMsg(payload) {
messages.value.push(payload);
}
const connectionStatus = computed(() => {
if (!stompService.stompClient) return 'DISCONNECTED';
return stompService.stompClient.connected ? 'CONNECTED' : 'CONNECTING';
});
function login() {
const name = prompt('请输入用户名:', '用户_' + Date.now());
if (!name) return;
username.value = name;
stompService.connect(
() => {
stompService.subscribe('/topic/chat', onMsg);
stompService.send('/app/chat', { user: name, content: `${name} 加入群聊` });
loggedIn.value = true;
},
(err) => console.error('连接失败', err)
);
}
function logout() {
stompService.send('/app/chat', { user: username.value, content: `${username.value} 离开群聊` });
stompService.unsubscribe('/topic/chat', onMsg);
stompService.disconnect();
loggedIn.value = false;
username.value = '';
messages.value = [];
}
function handleSend(text) {
const msg = { user: username.value, content: text };
messages.value.push(msg); // 本地回显
stompService.send('/app/chat', msg);
}
onUnmounted(() => {
if (loggedIn.value) logout();
});
</script>
<style scoped>
.chat-room { max-width: 600px; margin: 0 auto; padding: 16px; }
.header { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.status { margin-left: auto; font-weight: bold; }
button { padding: 4px 12px; background: #409eff; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #66b1ff; }
</style>
6.2.2 MessageList.vue
<template>
<div class="message-list" ref="listRef">
<div v-for="(m, i) in messages" :key="i" class="message-item">
<strong>{{ m.user }}:</strong>{{ m.content }}
</div>
</div>
</template>
<script setup>
import { watch, nextTick, ref } from 'vue';
const props = defineProps({ messages: Array });
const listRef = ref(null);
watch(
() => props.messages.length,
async () => {
await nextTick();
const el = listRef.value;
if (el) el.scrollTop = el.scrollHeight;
}
);
</script>
<style scoped>
.message-list { height: 300px; overflow-y: auto; border: 1px solid #ccc; padding: 8px; background: #fafafa; }
.message-item { margin-bottom: 6px; }
</style>
6.2.3 MessageInput.vue
<template>
<div class="message-input">
<input v-model="text" placeholder="输入消息,按回车发送" @keydown.enter.prevent="send" />
<button @click="send">发送</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['send']);
const text = ref('');
function send() {
const t = text.value.trim();
if (!t) return;
emit('send', t);
text.value = '';
}
</script>
<style scoped>
.message-input { display: flex; margin-top: 12px; }
.message-input input { flex: 1; padding: 6px; font-size: 14px; border: 1px solid #ccc; border-radius: 4px; }
.message-input button { margin-left: 8px; padding: 6px 12px; background: #67c23a; color: white; border: none; border-radius: 4px; cursor: pointer; }
button:hover { background: #85ce61; }
</style>
常见误区与注意事项
未使用
defineExpose
导致父拿不到子方法- Vue3
<script setup>
下,若没在子组件调用defineExpose
,父组件通过ref
拿到的实例并不包含内部方法,调用会报错。
- Vue3
子组件
$emit
事件拼写错误或未声明- 在
<script setup>
必须使用const emit = defineEmits(['eventName'])
声明可触发的事件;若直接调用emit('unknownEvent')
而父未监听,则无效果。
- 在
过度使用
provide
/inject
导致依赖隐式- 虽然能跨层级传递方法,但可读性和维护性下降,建议只在层级较深且数据流复杂时使用。
循环引用导致内存泄漏
- 若在父组件
onUnmounted
未正确unsubscribe
或disconnect
,可能导致后台持久订阅,内存不释放。
- 若在父组件
使用
$refs
太早(组件尚未挂载)- 在
mounted
之前调用ref.value.someMethod()
会拿到null
,建议在onMounted
或点击事件回调中再调用。
- 在
总结
本文系统深入地剖析了 Vue3 父子组件相互调用方法 的多种方案与实践,包括:
父组件调用子组件:
- Vue2 的
$refs
; - Vue3 Composition API 中的
ref
+defineExpose
;
- Vue2 的
子组件调用父组件:
$emit
触发自定义事件;props
传回调函数;provide/inject
跨层级注入;
- 还简要介绍了 非嵌套 组件间的全局事件总线和状态管理两种辅助方式;
- 最后通过一个“实时聊天室”示例,结合 StompJS + WebSocket,演示了实际应用中如何在父子组件间高效调用方法、交互数据。
希望通过丰富的代码示例、ASCII 流程图解和注意事项,能帮助你快速掌握 Vue3 中父子组件调用方法的全方位技巧。在开发过程中,灵活选择 $emit
、props
、expose
、provide/inject
等机制,将让你的组件通信更清晰、可维护性更高。
评论已关闭