Vue3三代(子父爷)/三层组件通信:使用 emit 实现刷新按钮加载状态的代码示例及讲解
应用效果:


子组件(组件层)实例代码:防抖按钮组件
src\components\base\BasePreventReClickButtonEmit.vue
<script setup lang="ts">
/**
* 防止重复点击按钮组件(防抖按钮组件),标准事件监听模式
*/
defineOptions({
name: "BasePreventReClickButtonEmit"
});
import { computed, ref } from "vue";
interface Props {
/**
* 延迟时间(毫秒),默认 0
*/
delay?: number;
/**
* 是否显示加载状态,默认 true
*/
showLoading?: boolean;
/**
* 显式声明 disabled 属性
*/
disabled?: boolean;
/**
* 超时时间(毫秒),超过此时间自动结束加载,0 表示不启用超时,默认 3000
*/
timeout?: number;
/*
* 阻止冒泡,适用于点击表格行按钮时,不会执行 row-click 行点击事件
*/
preventBubble?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
delay: 0,
showLoading: true,
disabled: false,
timeout: 3000,
preventBubble: false
});
const emit = defineEmits<{
// (e: "click", event: MouseEvent): void; // 定义点击事件
// 定义点击事件,传递一个 done 回调函数,父组件在异步操作完成后调用该回调,以通知子组件重置加载状态。
// (e: "click", param: { event: MouseEvent; done: () => void }): void;
// 第一个参数是原生事件,第二个参数是 done 回调,这样才能支持父组件使用 .stop 修饰符阻止冒泡
(e: "click", event: MouseEvent, done: () => void): void;
}>();
// 防抖状态标识
const isProcessing = ref(false);
// 计算最终的禁用状态 - 合并外部传递的disabled和内部防抖状态
const computedDisabled = computed(() => {
// 如果外部已经禁用了按钮,则保持禁用
if (props.disabled) return true;
// 否则,在防抖处理期间禁用按钮
return isProcessing.value;
});
// 点击事件,同时支持两种方式阻止冒泡(.stop 修饰符 + preventBubble prop)
const handleClick = (event: MouseEvent) => {
// 支持父组件使用 .stop 修饰符阻止冒泡
// 支持父组件使用 preventBubble prop,在子组件内部阻止冒泡
if (props.preventBubble) {
event.stopPropagation();
}
// 如果已禁用或正在处理,忽略本次点击,直接返回
if (computedDisabled.value || isProcessing.value) return;
isProcessing.value = true;
// 超时保护:如果父组件在规定时间内没有调用 done,则强制结束加载
let timeoutId: number | undefined;
if (props.timeout > 0) {
timeoutId = setTimeout(() => {
if (isProcessing.value) {
isProcessing.value = false;
}
}, props.timeout);
}
// 定义 done 回调函数,由父组件在异步操作完成后调用
const done = () => {
// 清除超时定时器
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
// 如果已经结束,则忽略
if (!isProcessing.value) return;
const delay = props.delay < 0 ? 0 : props.delay;
setTimeout(() => {
isProcessing.value = false;
}, delay);
};
// 触发事件,将 done 和原生事件对象一并传递
// emit("click", { event, done });
// 触发事件:传递原生事件和 done 回调
emit("click", event, done);
};
</script>
<template>
<!-- v-bind="$attrs" 绑定父组件传递的所有属性 -->
<!-- 设置当前组件的个性属性,可以覆盖父组件属性 :loading="loading" :disabled="loading" @click="handleClick" -->
<el-button
v-bind="$attrs"
:loading="showLoading ? isProcessing : false"
:disabled="computedDisabled"
@click="handleClick">
<!-- 插槽 -->
<slot></slot>
</el-button>
</template>
<style scoped lang="scss"></style>
父组件(中间层)实例代码:资金分配操作栏组件
src\views\capital\CapitalAllocate\comps\CapitalAllocateOperation.vue
const emit = defineEmits<{
/**
* 更新搜索关键字
* @param value 搜索关键字
*/
"update:keyword": [value: string];
/**
* 刷新数据
*/
refresh: [event: MouseEvent, done: () => void];
/**
* 搜索
*/
search: [];
/**
* 查询
*/
query: [];
}>();
<!-- 确保 $emit 在当前作用域可用(<script setup> 中 $emit 会自动暴露给模板) -->
<!-- 使用 @click="(...args) => $emit(`refresh`, ...args)" 简化替代 @click="(event, done) => emit(`refresh`, event, done)" -->
<BasePreventReClickButtonEmit
class="long-btn"
type="primary"
plain
@click="(...args) => $emit(`refresh`, ...args)"
:delay="500"
:timeout="0">
刷新数据
</BasePreventReClickButtonEmit>
爷组件(业务层)实例代码:资金分配
src\views\capital\CapitalAllocate.vue
// 刷新数据
const handleRefresh = async (event?: MouseEvent, done?: () => void) => {
try {
// store 初始化,重置数据状态
store.$reset();
// 设置刷新数据专属的查询条件
store.clearQueryObj();
store.queryObj.allocateStates = [WorkflowInstanceState.AUDITING, WorkflowInstanceState.AUDITED];
store.setCurrentPage(1);
await getData();
} finally {
// 操作完成后调用 done,通知子组件(CapitalAllocateOperation),再由子组件(CapitalAllocateOperation)通知孙组件(BasePreventReClickButtonEmit)结束加载状态(确保 done 存在才调用)
done?.();
}
};
<!-- 操作栏 -->
<CapitalAllocateOperation
v-model:keyword="store.keyword"
:current-capital-allocate="store.currentCapitalAllocate"
@refresh="handleRefresh"
@search="handleSearch"
@query="handleQuery" />
讲解:
这种逐级传递模式(子组件 emit 给父组件,父组件再 emit 给爷组件)在 Vue 中是完全合理的,尤其符合 单向数据流 和 事件冒泡 的设计原则。它保持了组件之间的低耦合,使得每个组件职责清晰:通用子组件只负责发射事件并提供 done 回调,中间容器组件只负责转发,顶层业务组件负责实际逻辑并最终调用 done。
合理性分析
-
解耦性:子组件(
BasePreventReClickButtonEmit)不需要知道父组件或爷组件的具体实现,它只定义事件接口((event, done))。父组件(CapitalAllocateOperation)同样不关心业务逻辑,仅作为事件中继,提高了组件的复用性。 -
可追踪性:事件流清晰,从子组件到爷组件每一步都显式传递,方便调试和理解。
-
符合 Vue 设计哲学:Vue 本身鼓励通过事件向上通信,逐层传递是常规做法,尤其适用于组件层级不深(2~3层)的场景。
潜在的简化
虽然模式合理,但父组件中每次写内联转发函数可能显得冗余。Vue 模板中可以使用 $emit 简化:
vue
<BasePreventReClickButtonEmit
class="long-btn"
type="primary"
plain
@click="(...args) => $emit('refresh', ...args)"
:delay="500">
刷新数据
</BasePreventReClickButtonEmit>
这样只需一行即可转发所有参数,但需要确保 $emit 在当前作用域可用(<script setup> 中 $emit 会自动暴露给模板)。
何时需要其他方案?
如果组件层级过深(例如 4~5 层),频繁的事件转发会导致代码冗长且难以维护,此时可以考虑:
-
Provide / Inject:爷组件提供
refresh方法,子组件直接调用,但需要额外处理done回调(例如爷组件调用后通过回调或 Promise 通知子组件结束 loading)。这会增加实现复杂度。 -
状态管理(Pinia):将刷新操作和 loading 状态放在 store 中,子组件直接调用 store 方法,但同样需要设计回调机制。
-
事件总线:但 Vue 3 已不推荐,因为会破坏数据流清晰度。
对于当前仅两层的传递,逐级转发是最简单且最符合 Vue 习惯的做法。
注意事项
-
必须确保
done被调用:每一层都要正确传递done,且最终业务组件在异步完成后必须调用它,否则子组件的 loading 状态可能无法结束(虽然有timeout后备,但主动调用更可靠)。 -
类型安全:在 TypeScript 中,每层的
emit类型定义要匹配,避免丢失类型信息。你当前的代码已经做到了这点。
综上,这种模式合理且推荐用于中等深度的组件通信。
扩展:
const handleTest = async () => {
await emit("refresh");
};
<BasePreventReClickButton class="long-btn" type="primary" plain @click="handleTest" :delay="500">
刷新数据
</BasePreventReClickButton>
emit不能用await,那如何解决异步任务
在 Vue 中,emit 是同步的,它不会返回 Promise,因此无法使用 await 等待事件处理函数的执行结果。要解决异步任务的等待问题,需要根据组件设计选择合适的模式。
当前代码的问题
typescript
const handleTest = async () => {
await emit("refresh"); // ❌ emit 不返回 Promise,await 无效
};
这里 emit("refresh") 会立即触发父组件的 refresh 事件,但不会等待事件处理函数中的异步操作完成,导致无法控制后续流程(如结束子组件的加载状态)。
解决方案
方案一:使用 BasePreventReClickButtonEmit 并传递 done 回调(推荐)
如果你需要等待异步操作完成,应采用之前设计的 BasePreventReClickButtonEmit 组件,它通过 (event, done) 参数将 done 回调交给父组件,父组件在异步完成后手动调用 done。这样无需 await,而是通过回调精确控制。
父组件(中间层):
vue
<BasePreventReClickButtonEmit
class="long-btn"
type="primary"
plain
@click="(event, done) => emit('refresh', event, done)"
:delay="500"
>
刷新数据
</BasePreventReClickButtonEmit>
爷组件(业务层):
typescript
const handleRefresh = async (event: MouseEvent, done: () => void) => {
try {
await fetchData(); // 异步操作
} finally {
done(); // 操作完成后调用 done
}
};
方案二:使用 BasePreventReClickButton 并传递返回 Promise 的函数
BasePreventReClickButton 组件通过 props.onClick 接收一个返回 Promise 的函数,并在内部 await 它。此时父组件不应使用 emit,而是直接传递一个异步函数。
父组件:
vue
<BasePreventReClickButton class="long-btn" type="primary" plain :on-click="handleRefresh" :delay="500" > 刷新数据 </BasePreventReClickButton>
定义异步函数:
typescript
const handleRefresh = async () => {
// 执行异步操作,例如调用 API
await fetchData();
// 不需要额外调用 done,组件内部会自动处理加载状态
};
注意:如果 handleRefresh 需要触发爷组件的事件,可以将爷组件的逻辑提取为可复用的异步函数,而不是通过事件传递。例如:
typescript
const refreshData = async () => {
// 刷新数据的核心逻辑
};
const handleRefresh = refreshData; // 直接传递
方案三:将事件处理函数改为返回 Promise 并通过 emit 传递(不推荐)
理论上可以通过自定义事件传递一个返回 Promise 的函数,但会破坏事件语义,且类型复杂,不建议使用。
总结
-
emit是同步的,不能await。 -
对于需要等待异步操作的场景,优先选择
done回调模式(BasePreventReClickButtonEmit)或props.onClick函数模式(BasePreventReClickButton)。 -
根据你的代码结构,推荐继续使用
BasePreventReClickButtonEmit并确保每一层都正确传递event和done,最终在业务组件中调用done()。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)