一种对JSON数据的压缩算法

415 阅读4分钟

最近一年都在做一个从java客户端迁移到web的项目,项目需要展示大量的航班条和节点数据。航班信息最大能达到5000+条,节点更是10w+,界面显示方面,由于数据量大,使用虚拟表格vxe-table展示,然而为了保证页面性能,在数据处理上仍然花了不少功夫。

由于是从java客户端迁移,在web端交互习惯上要尽量与之同步,需求方希望像客户端一样,将加载的数据保存在本地,下次打开时减少加载页面的时间,以增强用户体验。

然而,web端可以使用的资源就那么点,为了解决这个问题我真是煞费苦心。存在localStorage里面?它只能用5M;用indexDB如何?语句写起来跟SQL差不多,还得建表,读写太麻烦了,万一效果不好被废弃了,多费劲。经过了一些尝试,我一度摆烂,放弃了这个需求。

然而,作为一名资深的前端,是应当挑战一下极限的。有一天,我突然冒出一个念头:既然localStorage大小有限,能不能将json压缩一下再保存呢?

由于json数组里面,其实每一个对象里面的字段名都是重复的,值里面也有很多重复的字符串。如果我将这些字段名和值用一个较短的字符代替,然后再记下它们的映射关系,就能将数据进行压缩。在读取的时候再反向还原,就达到了解压的效果。

例如,有一个数组:

[{  name: 'mike',  sex: 'male',}...]

压缩之后,变为:

{
    data: [{A: A, B:B}],
    keyMap: {A:'name', B:'sex'},
    valueMap:{A:'mike',B:'male'},
}

在数据量大的情况下,重复的内容越多,压缩的效果就越明显。

我借助了一下AI生成工具Kimi 描述了需求,得出一段基础的代码。然后自己再花两小时改了一下,得到一个较为通用的json压缩算法:

export interface JSONZip {
  keyMap: Object;
  valueMap: Object;
  data: Array<any>;
}
// 压缩json数据
export function jsonZip(data, excludeKeys: any[] = []): JSONZip {
  const fieldSet = new Set();
  const valueSet = new Set();
  const compressedData: JSONZip = {
    keyMap: {},
    valueMap: {},
    data: [],
  };
  const keyMapReverse = new Map();
  const valueMapReverse = new Map();
  // 遍历数据,创建字段和值的映射表
  data.forEach((item) => {
    Object.entries(item).forEach(([field, value]) => {
      if (!fieldSet.has(field) && !excludeKeys.includes(field)) {
        const fieldKey = Object.keys(compressedData.keyMap).length + 1;
        compressedData.keyMap[fieldKey] = field;
        keyMapReverse.set(field, fieldKey);
        fieldSet.add(field);
      }
      if (!valueSet.has(value) && !excludeKeys.includes(field)) {
        const valueKey = Object.keys(compressedData.valueMap).length + 1;
        compressedData.valueMap[valueKey] = value;
        valueMapReverse.set(value, valueKey);
        valueSet.add(value);
      }
    });
  });

  // 使用映射表压缩数据
  compressedData.data = data.map((item) => {
    const compressedItem = {};
    Object.keys(item).forEach((field) => {
      if (!excludeKeys.includes(field)) {
        const compressedField = keyMapReverse.get(field);
        const value = valueMapReverse.get(item[field]);
        compressedItem[compressedField] = value;
      }
    });
    return compressedItem;
  });

  const originSize = new Blob([JSON.stringify(data)]).size;
  const zipSize = new Blob([JSON.stringify(compressedData)]).size;
  console.log(
    `数据原大小:${(originSize / 1024).toFixed(2)}KB, 去除节点后压缩大小${(zipSize / 1024).toFixed(
      2,
    )}KB`,
  );
  if (zipSize > 3 * 1024 * 1024) {
    throw new Error('压缩后数据大于3M,放弃缓存');
  }
  return compressedData;
}

// 解压json数据
export function jsonUnzip(jsonZipObj: JSONZip) {
  const { data, keyMap, valueMap } = jsonZipObj;
  // console.log('before unzip size', new Blob([JSON.stringify(data)]).size, data, keyMap, valueMap);
  const unZipData: any[] = [];
  data.forEach((record) => {
    const item = {};
    Object.entries(record).forEach(([key, value]) => {
      item[keyMap[key]] = valueMap[String(value)];
    });
    unZipData.push(item);
  });
  console.log('还原缓存数据大小:', new Blob([JSON.stringify(unZipData)]).size);
  return unZipData;
}

但其实这个算法有些问题。比如

1 如果值是数组类型,肯定是不重复的,也会被valueMap记录起来,占用一个位置很尴尬。如果是其它非字符串类型,还要避免一些坑。

2 字段是用数字来代替的,这显然不是最优解,想想,255占了三个字符,FF只占两位,谁更省空间?

3 值里面有很多是不重复的,如果不重复,却要记录到valueMap里面,还不如直接记录到data里。

一个个问题解决。

第一个,为了解决数组项内嵌数组的问题,可以用递归调用,内嵌数组里面的字段也记录到valueMap和keyMap中,而不是数组本身记录到valueMap,由于项目中的字段返回值都是字符串类型,我就不探讨了,留给各位同学自己解决。

第二个,可以使用ASCII码字符作为key,由于不知道控制字符是否会造成未知的问题,我将其跳过,使用字符编码从33开始的90个字符作为key(如果可以的话,其实可以试试用完128个字符,128进制比90进制更省,不是吗?), 如果超过了90个,则进位,例如:第91个记为AA 为此,我写了一个方法用于获取数字对应的字符组合。

function convertKey(charCode) {
  // 定义字符集,即ASCII码33到126的字符
  const base60Chars = `!#$%&'()*+-./0123456789;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~`;
  let result = '';
  let divisor = 1;
  const DIV = base60Chars.length;
  // 处理charCode为0的特殊情况
  if (charCode === 0) {
    return base60Chars[0];
  }
  // 转换过程
  while (charCode >= divisor) {
    const index = (charCode - 1) % DIV;
    result = base60Chars[index] + result;
    charCode = Math.floor((charCode - 1) / DIV);
    divisor *= DIV;
  }
  // 如果charCode小于divisor,直接转换为字符并添加到结果字符串
  if (charCode > 0) {
    result = base60Chars[charCode - 1] + result;
  }
  return result;
}

由于一些原因,我去掉了:和,后面会说到。

第三个,可以对值出现的次数进行预先统计,如果只出现一次的,就直接写在data中不做映射。

经过改造,20M的数据可以被压缩到8M左右,这样我只能截取一部分数据进行压缩了。对于这样的结果,我不太满意。

再观察一下,数据中起码有10个字段是时间格式的,形如: 20240820123000, 时间重复的几率比较低,但日期重复的几率高啊, 假如我把字符串进行拆分,然后用不同的字符代替: 20240820 12 30 日期我用一个dateMap记录起来:

{
    data: [],
    keyMap: {},
    valueMap: {},
    dateMap: {
        !:'20240820',
        #:'20240819'
    
}

时和分好办,60进制,不会超过ASCII码的范围,分别用一个字符代替,这样,20240820123000 就变成了!L^ ,由14个字符变成了三个,秒由于都是00,可以省略,到时解压时再添加上去。

然后我又发现航班唯一串号其实也有类似的规律,开头几个字符大部分是重复的,可以参照时间格式的压缩法变成三个字符。

算法修改后,20M的数据,变成了6M多。

本来想着就这样算了。某天起床,灵感突来,觉得它还能再压缩一下。

由于JSON数据转成字符串之后,key和value都会加上双引号,再加上对象的括号,这些都只是为了标记对象而已,但每个项就多出了4个字符。 如果去掉是不是能省更多空间呢?例如: JSON字符串

'{"name":"mike","sex":"male"}'

如果用字符串表示

'name:mike,sex:name'

这样能省10个字符,数量大的话压缩效果就可观了。

最后数据的格式大概是这样的:

{
    data: [
    {
      zipFields: '0:!A,1:!P3,2:$p',
      W: [{zipFields:'#:!Fn,X:6'}]
    }
    ]
}

当然,这样压缩,读写的时候是会消耗些时间,写入的时候用时长点,大概1.8秒,但读出来解压却很快,只用了200毫秒,这个影响几乎可以忽略。

至此20M的JSON,数据被压缩到4.5M, 这是2500条数据的情况。如果超过2500条数据,也只能进行截取,压缩部分数据。 对于一个优化性的需求来说,基本上已经达到效果了。