JSON 格式化工具的设计取舍:一次 18 MB 文件、9 倍内存,和三条会改数据的「捷径」

举报
yd_263929376 发表于 2026/11/05 13:22:30 2026/11/05
【摘要】 JSON 格式化工具的设计取舍:一次 18 MB 文件、9 倍内存,和三条会改数据的「捷径」JSON.parse 加 JSON.stringify 两行代码就能"格式化"一段 JSON,这让很多人以为这类工具没有设计问题。真正把它做成给人用的工具时,有三处必须明确做决定:格式化到不到改数据的程度、缩进值多少体积、以及遇到大文件用哪条路径。下面所有数字来自本机实测,环境是 Python 3....

JSON 格式化工具的设计取舍:一次 18 MB 文件、9 倍内存,和三条会改数据的「捷径」

JSON.parse 加 JSON.stringify 两行代码就能"格式化"一段 JSON,这让很多人以为这类工具没有设计问题。真正把它做成给人用的工具时,有三处必须明确做决定:格式化到不到改数据的程度、缩进值多少体积、以及遇到大文件用哪条路径。下面所有数字来自本机实测,环境是 Python 3.13.14 与 Node 24.12.0。

一、先把契约定死:重排空白,还是重写语义

同一个输入 {"a":1.0,"b":1e2,"c":-0.0,"d":1.230,"e":"\u4e2d\u6587"},两条主流路径给出的结果并不一样。

JavaScript 的 JSON.parse 再 JSON.stringify:

JSON.stringify(JSON.parse('{"a":1.0,"b":1e2,"c":-0.0,"d":1.230}'))
// {"a":1,"b":100,"c":0,"d":1.23}

Python 的 json.loads 再 json.dumps:

json.dumps(json.loads('{"a":1.0,"b":1e2,"c":-0.0,"d":1.230}'))
# {"a": 1.0, "b": 100.0, "c": -0.0, "d": 1.23}

1.0 变 1、-0.0 变 0、1.230 变 1.23,这在多数场合无害,但足以让一次无意义的"格式化"在 Git 里产生满屏 diff。

真正伤人的是超出双精度范围的整数。{"id":9007199254740993} 经过 JS 的往返后变成 {"id":9007199254740992},1234567890123456789 变成 1234567890123456800。Python 侧两个值都原样保留。同理 1e400:JS 解析成 Infinity,序列化成 null,字段直接消失;Python 解析成 Infinity,输出 Infinity,而这个字面量本身不是合法 JSON,再喂给别的解析器就报错。

订单号、雪花 ID、long 型时间戳,都在这条线上。一个"格式化"按钮把 ID 改了 1,用户肉眼看不出来,工具却已经毁掉了数据。这条契约必须在 UI 上说清楚:是"只重排空白",还是"规范化语义"。

二、三条捷径,一条比一条危险

用正则补空格。 目标只是让 : 后面有个空格,写法通常是 re.sub(r':', ': ', s)。实测输入:

src = '{"url":"https://a.b/c:d","quote":"他说: 好","time":"12:30:00"}'

输出变成 "url": "https: //a.b/c: d"、"他说: 好"、"12: 30: 00"。关键在于这段结果 json.loads 依然成功,不报任何错,只有把解析结果对比才发现语义已经不同。URL 和时刻被从中间劈开,比抛异常糟糕得多。

按逗号硬换行。 s.replace(',', ',\n') 碰到 {"tags":["a,b","c"]} 会产出跨行的字符串字面量,解析报 Invalid control character at: line 1 column 13 (char 12)。这条至少会失败,能被发现。

按裸引号切分。 想统计"第几个引号是键的边界"时,{"s":"他说 \"停\" 然后走了"} 会被切成 7 段,而真实值只有一个键。转义反斜杠必须成对处理,正则在这里几乎必然写错。

正确做法是只做词法扫描:字符串内部一个字符都不碰,字符串外部只重排空白。核心不到三十行:

def reindent(text, indent=2):
    out, depth, in_str, esc = [], 0, False, False
    pad = ' ' * indent
    for ch in text:
        if in_str:
            out.append(ch)
            if esc:            esc = False
            elif ch == '\\':   esc = True
            elif ch == '"':    in_str = False
            continue
        if ch == '"':
            in_str = True; out.append(ch)
        elif ch in ' \t\r\n':
            continue                    # 字符串外的空白一律丢弃
        elif ch in '{[':
            depth += 1; out.append(ch + '\n' + pad * depth)
        elif ch in '}]':
            depth -= 1; out.append('\n' + pad * depth + ch)
        elif ch == ',':
            out.append(',\n' + pad * depth)
        elif ch == ':':
            out.append(': ')
        else:
            out.append(ch)
    return ''.join(out).rstrip() + '\n'

这里有个我自己踩的坑:第一版没写 elif ch in ' \t\r\n': continue,源文本里逗号后面那个空格被原样追加,叠加在缩进之上,于是 "b" 前面出现了三格缩进。这种错一眼看上去像"缩进设置错了",实际是空白没被吃掉。补上那两行后,输出对 1.0、1e2、-0.0 全部逐字保留,json.loads 结果与原文一致,且二次格式化字节完全相同。

三、缩进的价格

拿一份 200 个用户对象、含中文与嵌套结构的样本(minify 后 30,307 字节)测四档缩进:

缩进 Python 输出 膨胀 Node 输出 膨胀
无 30,307 B 1.00× 26,907 B 1.00×
2 空格 49,115 B 1.62× 48,915 B 1.82×
4 空格 66,719 B 2.20× 66,519 B 2.47×
Tab 40,313 B 1.33× 40,113 B 1.49×

四空格比两空格多 36%,而可读性提升极小;Tab 最省,但跨编辑器对齐不稳。两空格是性价比拐点,这也是 Prettier 和绝大多数 package.json 的现实选择。

中文还有一笔账。ensure_ascii=True 把这份样本撑到 32,707 字节,只多 8%,代价是键名变成 "\u6807\u9898"——在编辑器里搜"标题"搜不到,改配置的人只能对着一串码点猜。8% 换掉可检索性,不划算。所以默认关闭 ASCII 转义,把"转义非 ASCII"做成显式选项,只给那些下游不接受原始 UTF-8 的系统用。

四、大文件:解析路径的内存放大是 9 倍

取本机一个真实的 18,557,544 字节 trace 文件跑 Python 解析路径:

read   188 ms
parse  127 ms
dump   216 ms   (indent=2, ensure_ascii=False)
peak   169 MiB   ≈ 原文的 9.1 倍

输出 18,635,416 字节,10351 行,语义往返一致。也就是说,一个 18 MB 的文件在内存里要占 169 MB,浏览器标签页里再叠一份 DOM 或字符串副本,30 MB 以上的 JSON 就足以让页面卡死。

同一文件走词法重排:2.6 秒,但内存只需原文与输出两份。慢五倍,省下一个数量级的内存——这就是取舍点:5 MB 以下走解析路径(快、能校验、能报错),5 MB 以上走词法重排或分块,别让用户为一个 200 MB 的日志文件把标签页挂掉。

深度也得自己设上限。Node 侧 JSON.parse 解析 5 万层嵌套数组仍然成功,但 JSON.stringify 在 1 万层对象时抛 RangeError: Maximum call stack size exceeded。这意味着"能读进来"不等于"能写出去",校验深度要在解析后立刻做,否则错误会在导出时冒出来,那时候用户已经改了半天。

五、键序、重复键、幂等

"不排序就保留原顺序"这句话在 JS 里不成立。{"2":"b","1":"a","x":1,"0":"z"} 解析后 Object.keys 返回 ["0","1","2","x"]——整数样式的键被引擎强制升序前置,stringify 出来就是 {"0":"z","1":"a","2":"b","x":1}。Python 的 dict 保插入序,同样输入原样输出 {"b": 1, "2": 2, "a": 3, "1": 4}。做跨语言工具时,这条差异会让"没排序"的两次导出对不上。

主动排序也不便宜。2 万个键的对象,Python sort_keys=True 从 4.7 ms 涨到 10.8 ms(2.3 倍),JS 递归排序键再序列化从 6.4 ms 涨到 17.9 ms(2.8 倍)。所以排序应该是开关,不是默认。

重复键更隐蔽。{"a":1,"a":2,"b":3} 在两种运行时里都是后者覆盖,静默通过,最终只剩 {"a":2,"b":3}。Python 还能用 object_pairs_hook 在解析时抓到:

def hook(pairs):
    keys = [k for k, _ in pairs]
    dup = {k for k in keys if keys.count(k) > 1}
    if dup: print('重复键:', dup)   # 重复键: {'a'}
    return dict(pairs)

json.loads('{"a":1,"a":2,"b":3}', object_pairs_hook=hook)

JS 的原生 API 完全没有这个钩子。想让工具报"有 3 处重复键被覆盖",只能在解析前先做一遍词法扫描——这又回到第二节那套字符串状态机。

最后一条隐性契约是幂等。reindent(fmt) == fmt 必须成立,实测 141 字节输出连跑两次字节完全相同。不幂等的格式化器会让"保存"变成持续改文件:用户按一次保存文件变一次,Git 永远有未提交改动,最后没人再信任这个工具。

六、错误定位就是产品的一半

Node 24 的报错已经带行列:Expected ',' or '}' after property value in JSON at position 7 (line 1 column 8),这条可以直接展示。但另一类只给上下文片段:

Unexpected token ',', ..."2,
  "c": ,
  "d": 4"... is not valid JSON

没有行号,只有被截断的片段。所以不能把 e.message 原样丢进界面——要么按 position 自己算行列,要么在词法扫描阶段就记录每个 token 的位置。用户能接受的失败是"第 4 行第 8 列多了个逗号",不能接受的是"Unexpected token"。

结论

JSON 格式化工具的正确默认是词法重排:字符串外只动空白,一个字节的数据都不改;“规范化”(去重键、统一数字写法、排序)作为第二个显式按钮,且必须提示它会改变词法;缩进两空格封顶;超过 5 MB 换引擎。功能少一点没关系,悄悄改掉一个 ID 的格式化器没有第二次机会。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。