应用效果:

子组件(组件层)实例代码:防抖按钮组件

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()

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐