Skip to content

GuardButton:与守护层联动的按钮

一个真实项目沉淀的实践:让按钮的 loading / disabled 自动跟随受守护请求的执行生命周期,业务代码从此不再手写 loading 标志位。

需求背景

一个后台系统里有几百个提交按钮,几乎每个提交处理函数都长这样:

js
async handleSubmit() {
  this.submitLoading = true;           // 手动开
  try {
    await submitOrder(this.form);
    this.$message.success('提交成功');
  } finally {
    this.submitLoading = false;        // 手动关,忘了就永久转圈
  }
}

问题很典型:

  • 重复代码繁多:每个按钮一个标志位、一套 try/finally,页面一多就是成片的模板代码
  • 容易漏finally 忘了复位、异常分支漏了复位,按钮就永久 loading
  • 只接入请求守护平台还不够:接入 request-guard 后,双击的重复请求确实被拦下了,但按钮本身没有任何状态流转——它不知道请求在途,既不转圈也不禁用,用户看到的还是一个"没反应"的按钮,只能靠 toast 提示兜底。请求层的防重解决了"数据不出错",没有解决"交互有反馈"

GuardButton 解决的就是最后一公里:把守护层已经掌握的"请求真实执行态"接到按钮上。

特点与特性

  • 零业务侵入:通过全局注册替换 <el-button>(也可作为独立组件按需使用),存量模板一行不用改
  • 行为等价guard 关闭时与原生按钮完全等价;根节点就是原按钮的 DOM,按钮组 CSS、指令、slot、属性透传全部不受影响
  • loading / disabled 双联动:请求在途时按钮自动转圈并禁用,从物理上消灭双击;外部显式传入的 loading / disabled 依然生效(取并集)
  • 全生命周期覆盖:状态由守护层驱动,包含重试等待、响应收尾——不存在"手写标志位提前复位"的问题
  • 实例级隔离:每个按钮实例一把 loadingKey,互不串扰;组件销毁自动退订,无泄漏

与守护平台的联动方式

GuardButton 只用了平台的三个公开 Loading API,没有任何私有依赖:

环节用到的公开能力
按钮挂载createLoadingKey() 为该实例创建唯一 key
状态订阅subscribeLoading(key, listener),订阅瞬间同步回调一次当前态,销毁时调用返回的退订函数
请求关联业务发请求时在请求配置带上 requestGuard: { loadingKey },守护层在请求开始/结束(含重试全程)驱动订阅回调

也就是说:按钮不感知请求,请求不感知按钮,两者只通过一把 key 在守护层会合。详细语义见 Loading 协作 API

可以让 AI 直接生成

本组件已收录进包内 AI 接入 Skill 的参考案例。装好 Skill 后直接说"给我的项目生成一个 GuardButton / 守护按钮",AI 会先分析你的技术栈(Vue 2 / Vue 3 / React / 原生小程序等)再产出对应实现,而不是照搬下面的案例代码。

完整代码(Vue 2 + Element UI 参考实现)

vue
<template>
  <!--
    根节点即 ElementButton,保证与原生 el-button DOM 完全一致:
    $el 就是 <button>,.el-button-group > .el-button、class/style 透传均不受影响。
  -->
  <ElementButton
    class="guard-button"
    v-bind="$attrs"
    :loading="finalLoading"
    :disabled="finalDisabled"
    v-on="$listeners"
  >
    <slot />
  </ElementButton>
</template>

<script>
import { Button as ElementButton } from 'element-ui';
import { createLoadingKey, subscribeLoading } from '@hydd/request-guard';

// 全局劫持 el-button 的守卫按钮:
// 在原生 el-button 之上叠加“请求守卫 loading 联动”,其余行为与 el-button 保持一致。
export default {
  name: 'GuardButton',
  components: { ElementButton },
  // 关闭属性自动继承,改为显式 v-bind="$attrs",避免属性被同时挂到根组件与内部按钮。
  inheritAttrs: false,
  props: {
    // 是否启用守卫 loading 联动;关闭后完全等价于原生 el-button。
    guard: {
      type: Boolean,
      default: true
    },
    // 显式声明 loading / disabled,交给 Vue 做 Boolean 归一化,
    // 修复裸属性写法 <el-button loading> 时 $attrs.loading === '' 被 Boolean('') 误判为 false 的问题。
    loading: {
      type: Boolean,
      default: false
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      // 对外暴露:业务通过 this.$refs.xxx.loadingKey 拿到 key 并随请求下发。
      loadingKey: '',
      guardLoading: false,
      unsubscribeLoading: null
    };
  },
  computed: {
    // 外部显式 loading 与守卫 loading 取并集,任一为真即处于 loading。
    finalLoading() {
      return Boolean(this.loading || (this.guard && this.guardLoading));
    },
    // 守卫 loading 期间禁用,防止在途请求被重复触发;外部 disabled 依然生效。
    finalDisabled() {
      return Boolean(this.disabled || (this.guard && this.guardLoading));
    }
  },
  watch: {
    guard: {
      immediate: false,
      handler(enabled) {
        if (enabled) {
          this.setupGuardLoading();
        } else {
          this.teardownGuardLoading();
        }
      }
    }
  },
  mounted() {
    if (this.guard) {
      this.setupGuardLoading();
    }
  },
  beforeDestroy() {
    this.teardownGuardLoading();
  },
  methods: {
    setupGuardLoading() {
      // 已订阅则跳过,避免 guard 反复切换或重复调用造成的多重订阅。
      if (this.unsubscribeLoading) {
        return;
      }

      if (!this.loadingKey) {
        this.loadingKey = createLoadingKey();
      }

      this.unsubscribeLoading = subscribeLoading(this.loadingKey, (loading) => {
        this.guardLoading = loading;
      });
    },
    teardownGuardLoading() {
      if (this.unsubscribeLoading) {
        this.unsubscribeLoading();
        this.unsubscribeLoading = null;
      }
      this.guardLoading = false;
    }
  }
};
</script>

全局注册(在 UI 库之后注册即可覆盖模板级 <el-button>):

js
Vue.use(ElementUI);
Vue.component('ElButton', GuardButton); // 劫持 ElButton

使用示例

业务侧只需把按钮的 loadingKey 随请求下发:

vue
<template>
  <el-button ref="submitBtn" type="primary" @click="handleSubmit">提交审批</el-button>
</template>

<script>
export default {
  methods: {
    handleSubmit() {
      // 不再写任何 loading 标志位,也不需要 try/finally
      auditSubmit(this.form, { loadingKey: this.$refs.submitBtn.loadingKey });
    }
  }
};
</script>
js
// api 层把 loadingKey 合入请求配置
export const auditSubmit = (data, { loadingKey } = {}) =>
  request({
    url: '/inspectionApproval/auditSubmit',
    method: 'POST',
    data,
    requestGuard: { duplicate: true, loadingKey }
  });

状态流转效果:

GuardButton 使用示例:点击后按钮自动进入 loading 并禁用,请求结束自动恢复

注意事项

  • UI 库内部按钮不受全局劫持影响:如 MessageBox、el-dialog 默认 footer 里由库内部直接渲染的按钮,不会经过 GuardButton;这些位置的防重仍由请求层防重(duplicate)兜底
  • ignore 策略与按钮联动是互补关系:请求层防重保证"数据不出错",GuardButton 保证"在途期间点不了",同时接入体验最佳
  • 想在 Vue 3 / React / 原生小程序里实现同款能力,核心只有"创建 key → 订阅 → 随请求下发 → 销毁退订"四步,见 Loading 协作 API,或直接用 AI 接入 Skill 生成

基于 MIT 许可发布