美文网首页
一种JSON压缩算法

一种JSON压缩算法

作者: 小飞牛牛 | 来源:发表于2024-08-19 17:13 被阅读0次

    最近一年都在做一个从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条数据,也只能进行截取,压缩部分数据。 对于一个优化性的需求来说,基本上已经达到效果了。

    相关文章

      网友评论

          本文标题:一种JSON压缩算法

          本文链接:https://www.haomeiwen.com/subject/yvgmkjtx.html