vue项目中关于JSON比对需求的分析与思路分享

171 阅读8分钟

前言

需求是这样子的:

项目中所有的编辑场景都需要进行比对,然后在编辑接口中增加一个字段,用来保存修改过的字段,并将修改过的字段翻译成表单的label,并同时保存 oldValue 和 newValue。

注意点:

  1. select 类型表单需要转换成label。
  2. 表格要逐行逐字段进行比对,并返回一个表格类型的数据结构,将发生改变的行按照 新增|编辑|删除 进行标识。

乍一眼看去,好像确实是一个后台的需求,但是看到注意点以后其实还是前端来做比较合理一点。经过多方battle,最终确定前端来实现。

因为涉及到的应用比较多,各自负责各自的应用,所以都有自己的设计思路,本篇就是简单记录一下自己对于这个需求的一些思考和开发记录。并反思一下现有项目中存在的问题,如何在以后的项目中有效的避免这些情况。

思路

项目现状

经过对项目中的表单和表格进行了简单的梳理以后,发现现在的项目中存在如下的几个问题:

  1. 没有独立的字典体系,即所有的代码中的 label 和 字段对应都是用到哪写到哪,没有维护字典,所以要整理一份字典出来。
  2. select 类型表单,大多数是从接口返回的,还存在级联的情况,即第一级下拉框的选择结果会刷新第二级的下拉框的选项,需要保证旧数据和新数据都能够正常转换。
  3. 因为项目没有独立的字典体系,所以存在一个字段对应多个中文对照的情况,或者存在一个多个场景共用一个字段的情况。(猜测应该是临时开发需求时发现一个字段在另一个场景用不上,后台又不想改表结构,所以就产生了这个情况)
  4. 表格对比中存在没有 id 的场景,这样就无法确认谁是新增的,谁是编辑的。尤其是串行的情况。
  5. 有些表格没有独立的字段,而是多个表格共用一个数组,通过数组中的某几项信息 filter 出来的。
  6. 下拉框还有级联的情况,级联的结果需要使用特定的连接符 > _ - 进行连接,最后上报成一个字符串。
  7. 存在一个接口对应多个表单的场景。

需求拆解

首先声明一下,这个需求开发的环境:vue2 + js。

众所周知,在这样的项目中,一个编辑的场景主要是经历如下几个交互:

  1. 点击编辑按钮,进入编辑场景(新页面、弹框、抽屉、遮罩层...)。
  2. 为编辑场景进行赋值(表格中传入row、从接口返回)。
  3. 赋值的同时,初始化下拉菜单(静态数据、接口数据、级联场景:computed、watched、@change ...)。
  4. 开始修改数据,注意下拉菜单情况。
  5. 点击提交按钮,请求编辑接口,保存数据。

所以,这个需求可以这么理解,就是比较旧数据与新数据之间的区别。关键点是两点:

  1. 增加编辑字段的时机,是在点击提交按钮的时候进行比对还是每修改一项,都往结果中增加一条记录?
  2. 编辑字段的生成方式,是对比整段json数据还是按照字典去比对需要关注的字段?

问题先抛出来,后面再解答,继续找问题。

可维护性:

  1. 如何避免对原有逻辑产生侵入式的修改?
  2. 对比字段比较多,如何能有效的关注对比进度?前端的日志如何管理,一键开启一键关闭?
  3. 如何在未来的某一天将这个需求迅速抽离出去?
  4. 扩展性,如何保证能够一边做,一边发现新的类型,能迅速扩展进去?

问题找的差不多了,那么开发思路也就慢慢的清晰了!

方案设计

只说一下我自己的方案设计思路吧!

通过对编辑场景的流程分析,我单独封装了一个 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;
  }

分享

没啥分享啦,都写上去了!

后记

这个需求其实不是特别难,但是很麻烦。不但需要梳理业务逻辑还要清楚老代码中每一段的具体含义,清晰初代目在干嘛。

所得并不多只是我对项目整体上的一些所思所得:

  1. 字典的配置会提高一个项目的健全性,将来面对开源以及国际化会友好很多。但是这个配置应该是在项目逐步健全以后再做,而且最好是重构阶段。
  2. 项目中的一些常用场景,如表格、表单等等,最好有一套统一的体系,这套体系应该满足几个条件:
    • 扩展性强:能够面对各种特殊场景,迅速的实现现有功能的扩展
    • 学习成本低:对于各种能力的开发人员比较友好,开箱即用
    • 标准化、通用性强:对于各个场景都能够有一套标准,并且去影响其他的产品
    • 高内聚、低耦合
  3. 新需求开发和原来屎山如何有效结合?是在屎山上镶一颗钻石,还是在屎山上另加沟壑呢?老项目既然能跑,就别动它是否真的合理?

END ~~~