JSON 格式化 — 美化、压缩,以及重新写一遍改掉了什么
把 JSON 粘进来,用浏览器自己的解析器重新写出来:美化、压缩,需要时把键名排成字母序。写完再把这一圈改掉的地方逐条列出来——超过 2 的 53 次方的整数、1.0 与 1e3、-0、重复的键名、被调换顺序的键名;读不过去的那一次会带着行号和列回来。
回来的时候不一样的地方
| 在哪一层 | 在原文里是怎么写的 | 写出来变成了什么 |
|---|
四组重新写一遍的对照
| 贴进去 | 按压缩之后结果框里的字 | 重新写一遍改掉了什么 |
|---|---|---|
{"a":1,"a":2} | {"a":2} | 键名 a 写了 2 次,只有最后那一个值留下来 |
{"hello":"world","id":9007199254740993,"ratio":1.0} | {"hello":"world","id":9007199254740992,"ratio":1} | 9007199254740993 回来的时候是 9007199254740992,1.0 成了 1 |
[1e21,-0,1e400] | [1e+21,0,null] | 三个数各自换了写法,最后那个连类型都变了 |
{"10":0,"2":0,"a":0,"B":0} | {"2":0,"10":0,"a":0,"B":0} | 10, 2, a, B 出来是 2, 10, a, B |
重新写一遍改掉了什么,在哪里停
读进来和写回去是两份工
一段 JSON 先被读成一棵值的树,再由这棵树重新写成一份新文本。前一半是 JSON.parse 干的,后一半是 JSON.stringify 干的,中间没有谁记得原文:换行记得没有,缩进记得没有,名字写在哪个位置记得没有,一个数字当时怎么拼的也记得没有。所以格式化不是把进去的东西重新排版,它是照着一份没有记忆的中间产物重新写了一篇。
那棵中间产物只装六种值——对象、数组、字符串、数、true/false、null——其中两种是意外的来源。字符串只保留解码出来的那些字符,于是你写的每一个转义写法都没了。数是一个 IEEE-754 双精度:64 个二进制位,不是你敲进去的那几个数字。有些数字回来时变了样,根子全在这句上。
前后两半用的都是浏览器自己的代码,所以结果框里的东西和你在控制台里 JSON.parse 再 JSON.stringify 得到的一字不差,没有第二套 JSON 实现在旁边悄悄做主。多出来的是比对:两份文本按名字、按路径一处一处对过去,写手改掉的每一处都列在框下那张表里。
数字是会悄悄变掉的那一半
双精度有 53 位有效位。整数到 9007199254740992(2 的 53 次方)还是精确的,再往上能表示的值彼此隔着不止 1——所以 9007199254740993 这个数并不存在,读它得到的是它的邻居。在一台当前的 Chrome 里量过:String(JSON.parse("9007199254740993")) 答 9007199254740992。雪花 id、纳秒时间戳、任何一个以 JSON 数字身份到达的 64 位整数,就是在这一位上丢掉末尾几位数字的,也是它们为什么应当以字符串的身份出门。
写手打印的是能读回同一个双精度的最短一串数字,于是凡是比数值本身更啰嗦的拼法都被削掉,凡是短到读不回来的定点写法又都换上指数:
| 进去时 | 出来时 | 发生了什么 |
|---|---|---|
1.0 |
1 |
末尾那个零对一个双精度来说无事可做 |
100.000 |
100 |
同上 |
1e3 |
1000 |
那个指数只是写法,不是值的一部分 |
1e20 |
100000000000000000000 |
还在用定点打印的范围里 |
1e21 |
1e+21 |
到 21 位起,短形式改用指数 |
0.000001 |
0.000001 |
正好在切换点上面一格 |
0.0000001 |
1e-7 |
低于 10 的负 6 次方,反过来切 |
-0 |
0 |
那个负号在双精度里活着,打印时不露出来 |
123456789012345678901234567890 |
1.2345678901234568e+29 |
双精度只留得下 17 位上下的有效数字 |
有一种情况改的是值的种类,不是拼法。大到装不下双精度的字面量是 Infinity,而 JSON 没有无穷这个写法,于是写手交出 null:-1e309 和 1e400 出来都是 null。小到装不下就成了 0,1e-328 出来是 0。一个计数溢出之后变成空指针,这和排版本身不是一类毛病,这里也照其他改写一样把它列出来。
同一个键名写了两遍
RFC 8259 说对象里的名字应当(should)唯一——那是建议,不是规矩——而 JavaScript 的解析器走的是宽松那条路:最后写的赢,先写的那个不留痕迹地被丢掉。{"a":1,"a":2} 是合法 JSON,读出来是 {"a":2}。
读出来的那棵树里没有任何东西显示某个名字被写过两遍,所以只看树的一方是看不见这次丢失的。只有数原文才看得见:表里点名是哪一层,那个名字出现了几次,活下来的又是哪一个值。
有一种尤其过不了人眼,因为两个名字看上去并不一样:转义写出来的名字和解码之后的名字是同一个名字。{"a":1,"\u0061":2} 有两项,解码后都叫 a,出来是 {"a":2}。计数发生在解码之后,所以这一对同样按重复键名被抓出来。
键名按什么顺序出来
JSON 的对象在定义上是没有顺序的,但 JavaScript 建出来的那棵树有顺序,写手就照着它走:看着像数组下标的名字先来,按数值升序,其余的名字按插入的次序跟在后面。{"10":0,"2":0,"a":0,"B":0} 读一遍,键名回来是 2, 10, a, B——既不是敲进去的顺序,也不是字母序。
于是两份文件可以装着同一份文档却对不上 diff,原因只在于其中一份最近是被 JavaScript 这一家的东西写出来的。要求按字母序排,确实会拿排过序的名字重建一棵树,可引擎照旧把下标样的那些名字提前:{"a":1,"10":2,"2":3} 排完出来是 2, 10, a。这不算选择失灵,这是脚本唯一定不了的那一部分键序。两种顺序对不上的对象会被计数,第一个还会被引出来,把要的顺序和出来的顺序并排写清楚。
读不过去的那一次,行列是从哪儿来的
引擎那句话原样打印,因为把错误转述一遍正是细节丢失的地方。当前的 Chrome 发回来两族:
Expected property name or '}' in JSON at position 1 (line 1 column 2)、Unexpected non-whitespace character after JSON at position 7 (line 1 column 8)——带一个文本内的偏移,旁边还给出行和列。Unexpected token '}', "…" is not valid JSON、Unexpected end of JSON input——根本不带位置。
有偏移的时候,行号和列号是照输入框里的文本重算的,并把尖角号放在出问题的那个字符底下:偏移从 0 开始数,而行和列从 1 开始数,粘进来的文档还带着它自己的换行。那句话里没有偏移,就直接说没有,把引擎引出来的那一段摆出来,不猜。第二族的引文是按设计截短的——一个 400 字符长行上的错误,回来的整句话只有 56 个字符——所以再长的行也淹不掉这句提示。
深到什么程度、大到什么程度会停
这道坎属于引擎,而几步各有各的坎。在一个 Chromium 标签页里量到的:JSON.parse 把一份 200000 层深的文档照读不误,不带换行也不带空格的那个写手把同一份文档照写了出来;带缩进的写手在 4000 层到 4400 层之间倒下,把键名按字母序排的那一步在 10000 层到 12000 层之间倒下。同一份文档在 Node 22.20 下让两个写手都停在 2235 层,可那边的解析器读 200000 层也没有意见。每一步花的是自己那一份栈帧,所以先拒绝的是哪一步,说的是引擎、它的版本,以及栈上已经占掉多少地方。
带缩进的写手还有一笔在栈之前就露出来的代价:它产出的文本按深度的平方在长,因为每一行都顶着它上面所有行的缩进。一份 4000 层深的文档摊平是 8001 字节,缩进成每层两个空格就是 32016001 个字符。
某一步放弃的时候不藏着:消息点名是哪一步放弃了,并把它自己的那句话带上;框里换成同一份文档的紧凑写法;如果连紧凑的写手也做不到,框就留着空的,而不是写一半成一个残缺文件。按字母序排的那一步先放弃时什么都没写,框是空的。粘进来的文本一路上不会被改动。
有两道限制是比对这一步自己的,都为可读性而设,不为时间:
- 这一遍走的是两份文本,逐字符,任一份超过 2097152 个字符(2 MB)就停。越线之后几个计数显示
—,哪里被改写过也不作声明。 - 表里最多列 40 行,真实总数写在表上方那句话里。
给个别参考:一份 8488891 个字符的文档,在活的标签页里读进去约 80 毫秒,写出来约 107 毫秒。这两道限制都不是因为算得慢。
哪一样东西不会离开这台机器
读、比对、写出,全在这个标签页里对着框中的文本做完。内容不会被拿去发请求,不会被存下来,也没有服务器被请来算这道题。你输入的内容不会离开这台设备。
问与答
问:我那个 id 末尾几位怎么变了? 它是以 JSON 数字的身份进来的,JSON 数字会变成双精度,而双精度只到 2 的 53 次方还是精确的。改用字符串传它,那几位数字就待着不动了。
问:出来的还是进去那份 JSON 吗? 同一份文档:再读一遍,树是一样的。同一串字节:不是——空白、数字的拼法、键名顺序,还有重复过的名字,现在都归写手做主了。任何按字节哈希、按字符 diff 的东西,都可能在一处语义都没动的地方报不一样。
问:缩进该选哪个? 选读这份文件的人期待的那个。两个空格是多数 JavaScript 规范要的,四个是 PEP 8 对 Python 的要求,制表符是三者里最省字节的。这个选择只改体积那一项,文档本身两个选择下一字不差。
问:把键名排序会改变意思吗? 不会——对象在定义上无序。它改的是 diff。它动不了下标样的那些名字,那部分无论怎么要求都排在最前面。
问:报错里没有行号,是坏了吗? 那一族消息本来就不带位置,它引的是出问题的那个 token。只要句子里有位置,行、列和尖角号都会填上。
问:多大的文件能走这一遍? 读和写都是浏览器自己的,上面量过的那份从头到尾不到五分之一秒。超过每份 2 MB 只有比对那一步会停,而且它会说出自己停了;结果框里仍是写手完整产出的那份。