一种JSON压缩算法

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容