消化知识的过程,本质上就是把别人的知识重构成自己的逻辑的过程。——佚名
背景:过往项目中,看到许多项目的弹窗组件大多使用声明式,即父组件需要在本地维护一个 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>
原则:
- 子组件内部的显隐逻辑由自己维护 ,恒为false,只有父组件调用时才为true,处理完毕后,不管父组件是基于哪种需求(新建/编辑/详情)需要子组件展示, 都是通过调用子组件暴露的
init函数来实现。 - 子组件接收到的行数据须深拷贝一份后再使用,发送给父组件的数据也须深拷贝一份,杜绝发生任何可能的数据污染
- 子组件职责边界仅限于: 渲染 UI、收集输入、拦截表单校验。一旦校验通过,它只负责通过
$emit把整理好的干净数据抛出,而不参与任何直接发送API请求。发请求、刷新表格、决定是否调用close()、 关闭弹窗的控制权,统统交还给父组件统筹。 - 表单中必要的下拉枚举、字典列表等依赖项,不应在子组件内部发请求获取,而是由父组件通过
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>
实现组件时的几点优化:
- 子组件使用$nextTick 与 clearValidate/reset以规避初始化爆红问题
- 子组件使用深拷贝规避数据污染问题
- 使用时无需再考虑赋值处理,不管是什么类型都调用
init方法,只需要关注init函数的参数传值即可,心智负担显著降低,且大幅降低bug率
一点心得:
g端项目常用组件也不过几十个,如果日常开发中将其封装成高内聚低耦合公共组件,则往后的开发效率将会越来越快,大部分时间则可以用来攻坚较难或复杂的模块,攻克完毕后以此迭代,这就是一个程序员的心路历程。
以上。