【论术】命令式调用弹窗/抽屉组件的探索与总结

11 阅读3分钟

消化知识的过程,本质上就是把别人的知识重构成自己的逻辑的过程。——佚名

背景:过往项目中,看到许多项目的弹窗组件大多使用声明式,即父组件需要在本地维护一个 visible 开关,结合用户动作传入 mode(新建/编辑/详情)和行数据,最后交由子组件内部去进行一系列的监听和处理。这种模式最大的痛点在于:为了驱动子组件的显示与回显,父组件被迫塞入了大量只服务于 UI 层面的状态变量。长此以往,父组件的 data 变得极度臃肿,充斥着对自身核心业务毫无用处的冗余数据。基于此,有了另一种解决方案——采用命令式来描述父子组件的关系。这一方式的技术底座是vue/react支持refs形式调用子组件的方法。

尝试理解下方这个例子:

// 子组件
<template>
  <t-dialog
    :header="title"
    :visible.sync="visible"
    :width="520"
    :confirm-btn="isView ? null : '确定'" 
    :cancel-btn="isView ? '关闭' : '取消'"
    @confirm="onConfirm"
    @cancel="close"
  >
    <t-form
      ref="form"
      :data="formData"
      :rules="rules"
      label-align="right"
      :label-width="100"
      :disabled="isView"
    >
      <t-form-item label="依据类型" name="dataName">
        <t-input
          v-model="formData.dataName"
          placeholder="请输入类型名称"
          clearable
        />
      </t-form-item>

      <t-form-item label="分类" name="dataSort">
        <t-select v-model="formData.dataSort" :options="dictsListProp" />
      </t-form-item>
      
      </t-form>
  </t-dialog>
</template>

<script>
import cloneDeep from "lodash/cloneDeep";

export default {
  name: "StandardDialog",
  props: {
    dictsListProp: {
      type: Array,
      default: () => [],
    },
  },
  data() {
    return {
      visible: false, // 由子组件自己控制弹窗
      mode: "add",    //当前表单  'add' | 'edit' | 'view'
      formData: {
        id: "",
        dataName: "",
        dataSort: "",
        parentId: "0",
      },
      rules: {
        dataName: [{ required: true, message: "请输入名称", trigger: "blur" }],
      },
    };
  },
  computed: {
    isView() {
      return this.mode === "view";
    },
    title() {
      const map = { add: "新建标准", edit: "编辑标准", view: "查看详情" };
      return map[this.mode];
    },
  },
  methods: {
    /**
     * @description 暴露给父组件的唯一入口 (命令式 API)
     * @param {String} mode 模式
     * @param {Object} row 行数据
     */
    init(mode, row = {}) {
      this.mode = mode;
      this.visible = true;

      // 等待 DOM 渲染后清空前置校验状态
      this.$nextTick(() => {
        this.$refs.form.reset(); 

        if (mode === "add") {
          this.formData = {
            id: "",
            dataName: "",
            dataSort: this.dictsListProp[0]?.value || "",
            parentId: "0",
          };
        } else if (mode === "edit" || mode === "view") {
          this.formData = cloneDeep(row);
        }
      });
    },

    async onConfirm() {
      // 详情模式下直接关闭,跳过校验和提交逻辑
      if (this.isView) {
        return this.close();
      }

      // 拦截校验
      const result = await this.$refs.form.validate();
      if (result === true) {
        // 再次 cloneDeep,防止父组件拿到数据后意外修改触发子组件 UI 变动
        this.$emit("submit", { 
          mode: this.mode, 
          data: cloneDeep(this.formData)  // 将编辑后的数据还给父组件
        });
      }
    },

    close() {
      this.visible = false;
    },
  },
};
</script>

原则:

  1. 子组件内部的显隐逻辑由自己维护 ,恒为false,只有父组件调用时才为true,处理完毕后,不管父组件是基于哪种需求(新建/编辑/详情)需要子组件展示, 都是通过调用子组件暴露的init函数来实现。
  2. 子组件接收到的行数据须深拷贝一份后再使用,发送给父组件的数据也须深拷贝一份,杜绝发生任何可能的数据污染
  3. 子组件职责边界仅限于: 渲染 UI、收集输入、拦截表单校验。一旦校验通过,它只负责通过 $emit 把整理好的干净数据抛出,而不参与任何直接发送API请求。发请求、刷新表格、决定是否调用 close()、 关闭弹窗的控制权,统统交还给父组件统筹。
  4. 表单中必要的下拉枚举、字典列表等依赖项,不应在子组件内部发请求获取,而是由父组件通过 props(init ?) 统一传入,传入前的格式化由父组件处理,以确保子组件直接使用

再来理解父组件:



<template>
  <div class="page-container">
    <t-button theme="primary" @click="handleAdd">新建标准</t-button>

    <t-table :data="tableData" row-key="id" style="margin-top: 16px;">
      <t-table-column colKey="operation" title="操作">
        <template #operation="{ row }">
          <t-button variant="text" theme="primary" @click="handleView(row)">详情</t-button>
          <t-button variant="text" theme="primary" @click="handleEdit(row)">编辑</t-button>
        </template>
      </t-table-column>
    </t-table>

    <StandardDialog
      ref="standardDialog"
      :dictsListProp="dictOptions"
      @submit="handleSubmit"
    />
  </div>
</template>

<script>
import StandardDialog from "./StandardDialog.vue";

export default {
  components: { StandardDialog },
  data() {
    return {
      tableData: [
        { id: "1", dataName: "测试标准A", dataSort: "1", parentId: "0" },
      ],
      dictOptions: [
        { label: "分类一", value: "1" },
        { label: "分类二", value: "2" },
      ],
    };
  },
  methods: {
    // === 命令式唤起子组件 ===
    handleAdd() {
      this.$refs.standardDialog.init("add");
    },
    handleEdit(row) {
      this.$refs.standardDialog.init("edit", row);
    },
    handleView(row) {
      this.$refs.standardDialog.init("view", row);
    },

    // === 统一处理业务逻辑流 ===
    async handleSubmit({ mode, data }) {
      try {
        // 根据不同模式分发 API 请求
        if (mode === "add") {
          // await this.$api.addStandard(data);
          this.$message.success("新增成功");
        } else if (mode === "edit") {
          // await this.$api.updateStandard(data);
          this.$message.success("修改成功");
        }

        // 业务闭环:刷新表格数据 -> 关闭弹窗
        this.fetchTableData();
        this.$refs.standardDialog.close();

      } catch (error) {
        this.$message.error("操作失败");
        // 如果 API 报错,不执行 close(),弹窗依然保留在前端,保留用户心血
      }
    },

    fetchTableData() {
      console.log("模拟拉取最新表格数据...");
    },
  },
};
</script>

实现组件时的几点优化:

  1. 子组件使用$nextTick 与 clearValidate/reset以规避初始化爆红问题
  2. 子组件使用深拷贝规避数据污染问题
  3. 使用时无需再考虑赋值处理,不管是什么类型都调用init方法,只需要关注init函数的参数传值即可,心智负担显著降低,且大幅降低bug率

一点心得: g端项目常用组件也不过几十个,如果日常开发中将其封装成高内聚低耦合公共组件,则往后的开发效率将会越来越快,大部分时间则可以用来攻坚较难或复杂的模块,攻克完毕后以此迭代,这就是一个程序员的心路历程。

以上。