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