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 只有比对那一步会停,而且它会说出自己停了;结果框里仍是写手完整产出的那份。

微信
微信
支付宝
支付宝