前言
需求是这样子的:
项目中所有的编辑场景都需要进行比对,然后在编辑接口中增加一个字段,用来保存修改过的字段,并将修改过的字段翻译成表单的label,并同时保存 oldValue 和 newValue。
注意点:
- select 类型表单需要转换成label。
- 表格要逐行逐字段进行比对,并返回一个表格类型的数据结构,将发生改变的行按照 新增|编辑|删除 进行标识。
乍一眼看去,好像确实是一个后台的需求,但是看到注意点以后其实还是前端来做比较合理一点。经过多方battle,最终确定前端来实现。
因为涉及到的应用比较多,各自负责各自的应用,所以都有自己的设计思路,本篇就是简单记录一下自己对于这个需求的一些思考和开发记录。并反思一下现有项目中存在的问题,如何在以后的项目中有效的避免这些情况。
思路
项目现状
经过对项目中的表单和表格进行了简单的梳理以后,发现现在的项目中存在如下的几个问题:
- 没有独立的字典体系,即所有的代码中的 label 和 字段对应都是用到哪写到哪,没有维护字典,所以要整理一份字典出来。
- select 类型表单,大多数是从接口返回的,还存在级联的情况,即第一级下拉框的选择结果会刷新第二级的下拉框的选项,需要保证旧数据和新数据都能够正常转换。
- 因为项目没有独立的字典体系,所以存在一个字段对应多个中文对照的情况,或者存在一个多个场景共用一个字段的情况。(猜测应该是临时开发需求时发现一个字段在另一个场景用不上,后台又不想改表结构,所以就产生了这个情况)
- 表格对比中存在没有 id 的场景,这样就无法确认谁是新增的,谁是编辑的。尤其是串行的情况。
- 有些表格没有独立的字段,而是多个表格共用一个数组,通过数组中的某几项信息 filter 出来的。
- 下拉框还有级联的情况,级联的结果需要使用特定的连接符
> _ -
进行连接,最后上报成一个字符串。 - 存在一个接口对应多个表单的场景。
需求拆解
首先声明一下,这个需求开发的环境:vue2 + js。
众所周知,在这样的项目中,一个编辑的场景主要是经历如下几个交互:
- 点击编辑按钮,进入编辑场景(新页面、弹框、抽屉、遮罩层...)。
- 为编辑场景进行赋值(表格中传入row、从接口返回)。
- 赋值的同时,初始化下拉菜单(静态数据、接口数据、级联场景:computed、watched、@change ...)。
- 开始修改数据,注意下拉菜单情况。
- 点击提交按钮,请求编辑接口,保存数据。
所以,这个需求可以这么理解,就是比较旧数据与新数据之间的区别。关键点是两点:
- 增加编辑字段的时机,是在点击提交按钮的时候进行比对还是每修改一项,都往结果中增加一条记录?
- 编辑字段的生成方式,是对比整段json数据还是按照字典去比对需要关注的字段?
问题先抛出来,后面再解答,继续找问题。
可维护性:
- 如何避免对原有逻辑产生侵入式的修改?
- 对比字段比较多,如何能有效的关注对比进度?前端的日志如何管理,一键开启一键关闭?
- 如何在未来的某一天将这个需求迅速抽离出去?
- 扩展性,如何保证能够一边做,一边发现新的类型,能迅速扩展进去?
问题找的差不多了,那么开发思路也就慢慢的清晰了!
方案设计
只说一下我自己的方案设计思路吧!
通过对编辑场景的流程分析,我单独封装了一个 Cpmpare 的 class。
这个 class 的结构是这样子的:
import { modelBook, books } from "./cn_en";
class CompareData {
constructor() {
this.oldData = {};
this.newData = {};
this.options = {};
this.books = {};
this.model = {};
Log.logInfo("初始化 CompareData");
}
// 根据动作 create
create(act) {
this.books = books[act];
this.model = modelBook[act];
}
// 设置旧数据
setOldVal(val) {}
// 设置新数据
setNewVal(val) {}
// 添加下拉菜单
addOption(prop, arr, { label, value, children, valueSplit, specialType } = { label: "label", value: "value" }) {}
// 设置下拉菜单
setOption(prop, arr, { label, value, children, valueSplit, specialType } = { label: "label", value: "value" }) {}
// 更新下拉菜单
updateOption(prop, arr, type) {}
// 对比
compare(type, keys) {}
// 高级对比
compareHigh(keyConf) {}
// 重写字典
resetBooks(type) {}
}
简单分析一下这个 class 的生命周期吧!
首先在 mounted 或者 init 的时候,将这个实例 new 出来。刚 new 出来的 Compare 是什么都没有的,包括字典都没有。
在new出来以后,根据操作调用 create,这时会将上报模板和字典进行初始化。
然后在初始化完成以后 setOldVal 并且 在点击按钮的时候 setNewVal。
在提交数据之前,将 compare 或 compareHigh 的结果赋值给 编辑字段。
使用 addOption、setOption、updateOption 灵活的将所有的 下拉菜单进行搜集。
使用 resetBooks 可以灵活的处理一个接口对应多个表单,或者一个字段对应多个label的场景。
如上,又简单的封装了一个 Log 类:
export default class Log {
constructor(type) {
this.type = type;
}
// 打印 key & value
logData(k, v) {
this.type && console.log("🕷", k, "=>", v);
}
// 打印节点
logInfo(str) {
this.type && console.log(`%c 👽 ${str}`, "color: blue; font-weight: bold;");
}
// 打印分支语句
logIf(str) {
this.type && console.log(`%c🚩 ${str}`, "color: red; font-weight: bold;");
}
}
这样就方便监控对比的进度,以及各个节点的信息了。并且通过修改 type 的值,从而控制日志是否显示。
上面遇到的问题应该都解决了!
开发
开发主要是几个工具函数的开发工作。
齐整化 options
传入的 options 各式各样的,将其归整一下。
const recurOptions = (arr, conf) => {
return arr.map((item) => ({
label: item[conf.labelKey],
value: item[conf.valueKey],
children: item[conf.childKey] ? recurOptions(item[conf.childKey], conf) : null,
}));
};
深度优先遍历
将级联 options 进行归整,添加 addr 和 pValue,方便调用。
/**
* 深度优先遍历
* @param {object || any[]} node 递归数据
* @param {any[]} nodeList 保存最终结果
* @returns any[] nodeList
*/
const deepForeach = (node, nodeList = [], pv = "", addr = "") => {
// 如果有node节点则执行主逻辑
if (node) {
// 导出结果填入节点数据
let _tmpObj = {
label: node.label,
value: node.value,
pValue: pv,
addr: addr ? `${addr}>${node.label}` : node.label,
};
nodeList.push(_tmpObj);
// 如果有数据且有数据长度则继续递归
node.children && node.children.length && node.children.forEach((item) => deepForeach(item, nodeList, node.value, _tmpObj.addr));
}
return nodeList;
};
diffTable
对比表格,根据两个表格的对比,取出差异项。
/**
* 对比表格的差异项,默认id为唯一值
* @param {any[]} newData 新数据中对应的表格数据
* @param {any[]} oldData 旧数据中对应的表格数据
* @param {string[]} keys 表格中需要比对的字段
* @returns 对比结果
*/
const diffTable = (newData, oldData, keys, importKey) => {
let result = [];
const onlyKey = importKey;
Log.logData("diffTable[oldData]", oldData);
Log.logData("diffTable[newData]", newData);
Log.logData("diffTable[keys]", keys);
Log.logData("diffTable[importKey]", importKey);
oldData.forEach((item) => {
// 删除场景 - 如果新值中没有 id 为旧的id 的数据,则删除
if (!newData.filter((i) => i[onlyKey] === item[onlyKey]).length) {
result.push({
oldObj: item,
newObj: undefined,
});
}
// 编辑场景 - 对比几个字段,如果有不一样的值,则增加
let newObj = newData.filter((i) => i[onlyKey] === item[onlyKey])[0];
for (let k of keys) {
if (newObj && item[k] !== newObj[k]) {
return result.push({
oldObj: item,
newObj,
});
}
}
});
// 插入新增的项,没有id字段则为新增
newData.forEach((item) => {
// 新增场景 - 如果旧值中没有 id 为旧的id 的数据,则新增
if (!oldData.filter((i) => i[onlyKey] === item[onlyKey]).length) {
result.push({
oldObj: undefined,
newObj: item,
});
}
});
return result;
};
对比对象
/**
* 比较对象,返回值是所有发生改变的的字段组成的数组
* @param {string[]} keys 参与比较的字段
* @returns {prop: string,oldValue: string,newValue: string,isUpdate: boolean}[]
*/
compareObjToArray(keys) {
const _set = keys || Object.keys(this.books);
const result = [];
_set.forEach((item) => {
let oldVal = Object.hasOwn(this.oldData, item) ? this.oldData[item] : null;
let newVal = Object.hasOwn(this.newData, item) ? this.newData[item] : null;
if (Array.isArray(oldVal)) {
oldVal = oldVal.sort().join(",");
}
if (Array.isArray(newVal)) {
newVal = newVal.sort().join(",");
}
if (oldVal !== newVal)
result.push({
key: item, // 字段
label: this.books[item],
oldVal: this._valueToLabel(item, oldVal), // 旧值
newVal: this._valueToLabel(item, newVal), // 新值
// isUpdate: oldVal !== newVal, // 是否更新
descTemplateId: this.model,
});
});
return result;
}
对比表格
compareTable(confs) {
let result = [];
confs.forEach((conf) => {
const k = conf.prop; // 对应表格的字段
const keys = conf.keys.map((item) => item.split(".").toReversed()[0]); // 表格中所有的字段
const importKey = conf.importKey || "id";
let _tmpObj = {
descTemplateId: "DI.API_2",
type: "table",
label: this.books[k],
key: k,
head: keys.map((_k) => {
return {
label: this.books[_k],
prop: _k,
};
}),
data: diffTable(
this.newData[k].map((item) => {
for (let k in item) {
item[k] = this._valueToLabel(k, item[k]);
}
return item;
}),
this.oldData[k].map((item) => {
for (let k in item) {
item[k] = this._valueToLabel(k, item[k]);
}
return item;
}),
keys,
importKey
),
};
Log.logData("compareTable[_tmpObj]", _tmpObj);
// 过滤掉没有改变的表格
_tmpObj.data.length > 0 && result.push(_tmpObj);
});
return result;
}
拍平复杂对象
复杂对象(高级对象)的展开。
_compressObject(obj) {
Log.logInfo("拍平数据");
Log.logData("_compressObject[obj]", obj);
const compressedObj = {};
const stack = [{ obj, prefix: "" }];
while (stack.length > 0) {
const { obj, prefix } = stack.pop();
for (const key in obj) {
if (typeof obj[key] === "object" && !Array.isArray(obj[key])) {
stack.push({ obj: obj[key], prefix: prefix + key + "." });
} else {
compressedObj[prefix + key] = obj[key];
Log.logData(prefix + key, obj[key]);
}
}
}
Log.logData("_compressObject[compressedObj]", compressedObj);
return compressedObj;
}
分享
没啥分享啦,都写上去了!
后记
这个需求其实不是特别难,但是很麻烦。不但需要梳理业务逻辑还要清楚老代码中每一段的具体含义,清晰初代目在干嘛。
所得并不多只是我对项目整体上的一些所思所得:
- 字典的配置会提高一个项目的健全性,将来面对开源以及国际化会友好很多。但是这个配置应该是在项目逐步健全以后再做,而且最好是重构阶段。
- 项目中的一些常用场景,如表格、表单等等,最好有一套统一的体系,这套体系应该满足几个条件:
- 扩展性强:能够面对各种特殊场景,迅速的实现现有功能的扩展
- 学习成本低:对于各种能力的开发人员比较友好,开箱即用
- 标准化、通用性强:对于各个场景都能够有一套标准,并且去影响其他的产品
- 高内聚、低耦合
- 新需求开发和原来屎山如何有效结合?是在屎山上镶一颗钻石,还是在屎山上另加沟壑呢?老项目既然能跑,就别动它是否真的合理?
END ~~~