Skip to content
pemakoPublic

About

Json Yaml formatter and diff

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

JSON / YAML Formatter

JSON 与 YAML 的格式化、校验、压缩、树形浏览,以及两种格式互转。

https://f.mk4.top

纯静态,无运行时依赖。所有解析与渲染都在浏览器本地完成,不向外发任何请求 —— 贴进去的数据不会离开你的机器。

功能

双格式互转 — 输入可以是 JSON 或 YAML(也可自动识别),输出格式独立选择。所以四种组合都成立:JSON→JSON 格式化、YAML→YAML 重排、JSON→YAML、YAML→JSON。两者共用同一棵节点树,转换不经过中间文本。

格式化 / 压缩 — 缩进可选 2/3/4 空格或 Tab(YAML 不允许 Tab 缩进,会退回 2 空格)。压缩后在状态栏显示体积变化(如 524 B → 423 B,减少 19%)。YAML 的「压缩」用流式写法:{a: 1, b: [1, 2]}。

校验 — 报错精确到行列,并标明是哪种格式,例如 YAML 第 3 行 第 5 列:缩进异常:映射的键应对齐到第 1 列。出错行在行号槽高亮,点「跳转到出错位置」会定位并选中该字符。

修复 — JSON 走容错解析,修常见手写问题;YAML 本身容错性强,等于「规范化重排」(统一缩进与引号策略、去掉注释)。结果写回输入框时用的是输入格式,不会把输入框换成另一种语言。

输入 修复为
{'a': 'b'} {"a": "b"}
{a: 1} {"a": 1}
{"a":1,} / [1,2,] 去掉尾随逗号
// 注释、/* 注释 */ 删除
True / None / NaN / undefined true / null / null / null
+1 / .5 / 01 / 2. 1 / 0.5 / 1 / 2
{"a":1 "b":2} 补上缺失的逗号
字符串里的裸换行 转义为 \n

结构性错误(括号不闭合等)仍会正常报错,不会硬猜。

树形视图 — 可折叠,折叠时显示内容预览与子项数量;悬停任意行可复制它的 JSONPath(如 $.users[0].name)。YAML 输入同样能用,因为树建立在共用的节点树上。

搜索 — 键和值都参与匹配,大小写不敏感。折叠着的分支同样能搜到,命中后自动展开祖先并滚动定位。

对比两份内容 — 顶栏切到「对比」,上下贴两份内容。比的是结构而不是文本行:键序不同、缩进不同、1.0 与 1 这类写法差异都不会被算成改动,JSON 与 YAML 还能混着比。差异分四类,状态栏分别计数:

类别 例子
新增 只在新内容里出现的键或数组项
删除 只在原内容里出现的键或数组项
值变化 8080 → 9090、"1" → 1(类型也变了)
仅写法不同 1.0↔1、1e3↔1000、键序不同、JSON↔YAML

「仅写法不同」单独成一类,是因为解析器保留了数字的原始字面量。反过来说, 9007199254740993 与 9007199254740992 在双精度下是同一个数,基于 JSON.parse 的对比工具会漏报这处差异,而这里会如实报成值变化。

数组按三级策略配对:元素是记录且能找到某个两侧都唯一的字段(id、sku、 email 之类,字段名自动探测不硬编码)就按它配对,这样中间插一条不会让后面 全部标成改动;否则用 LCS;超大数组(n×m 超过 400 万单元)退回按位置比对, 并在状态栏如实说明已降级。

差异有两个视图:

  • 差异树 —— 默认只展开有差异的路径,其余折叠。格式无关,JSON 与 YAML 都能比
  • 文本 —— 默认是 git diff 那种单列 -/+,另用 ~ 表示「仅写法不同」(同一行写出 旧 → 新);勾「分栏」切成左右两列,左边改动前、右边改动后,值变化压成一行更好对照。以 JSON 呈现
  {
-   "version": "1.0.0",
+   "version": "1.1.0",
    "name": "JSON Formatter",
~   "settings": {
-     "theme": "auto",
+     "theme": "dark",
      "sortKeys": false
    },
~   "rating": 4.8 → 4.80,
+   "license": "MIT",
-   "deprecated": ["oldApi"]
  }

注意文本视图不是对两份文本做行 diff,而是把同一棵合并树渲染成文本 —— 所以它和差异树报的判定完全一致(name 换了位置仍算未变,settings 只标 键序)。输出同时含 - 与 + 行,不是合法 JSON,这和 git diff 一样是预期的。

两种呈现共用同一次遍历:遍历只产出「行块」,记下这一处在两侧各占哪几行。 对齐问题就此消解 —— 合并树的一个位置天然对应一块,不需要再比对两份文本、 插空行凑齐。

分栏的两列各占面板一半,超出的折行而不是把列撑宽。一开始是按「最长那行」 定列宽的,结果一条长 URL 就能把两列一起顶到面板之外:短行旁边空出一大片, 还得一直左右滑才能看到右列。折行的代价是行高不再一致,而虚拟滚动恰恰依赖 行高一致算位置 —— 两者只能取一个。取折行,因为另一个选择是把长行裁掉, 那会把差异本身藏起来(长 URL 的区别往往就在中段)。

所以分栏走自然文档流、不做虚拟滚动,行数另有上限(4000 行,超出如实告知, 用「只看差异」收窄)。分栏的行结构很简单,实测 3000 行注入加布局约 70 ms、 6000 行约 140 ms,比整份语法高亮那种量级(每 MB 14 万个元素)轻得多, 所以这个上限可以给得比较宽。统一视图仍然是虚拟滚动的。

默认单列是因为窄屏上两列偏挤;开分栏时输入区会自动让出宽度(两种状态各记 一个分栏比例,所以你手动拖过的宽度不会被覆盖)。

配合「上一个/下一个差异」跳转与「只看差异」过滤,几 MB 的文件里找那两三处改动 也不用自己翻。两个视图共用同一套差异序号,所以「第 3 / 9 处」在哪个视图都指同 一处。「只看差异」在文本视图下会缩掉大段未变内容(实测 202 行、一处改动的 diff 从 4110px 缩到 221px),折叠处用 ⋯ 标出。

手机与平板 — 窄屏或矮屏下改成一次显示一个面板(顶部切换条), 桌面端仍是并排双栏。对比模式下切换条是三档(原内容 / 新内容 / 差异)。 工具栏横向滑动而不是堆成四行,行号槽与输入格式 下拉在小屏隐藏(状态栏本来就会报出错的行列)。横屏时工具栏与搜索栏 并成一行,把高度让给内容 —— 那里高度只有 375~430px。

中英切换 — 顶栏语言按钮在中文与英文之间切换,跟随浏览器语言初始化, 选择记在 localStorage。树形视图里运行时生成的文案(3 个键、显示更多) 也跟着切 —— 那些是渲染时写进 DOM 的,所以换语言会重建一次树。 解析器的错误信息目前仍是中文。

其他 — 暗色/亮色主题、拖放或打开文件(上限 25 MB,支持 .json/.yaml/.yml)、复制、下载(扩展名跟随输出格式)、可拖动的分栏、设置持久化到 localStorage。

快捷键

键 作用
Ctrl/Cmd + Enter 格式化(对比模式下是「对比」)
Ctrl/Cmd + M 压缩
Ctrl/Cmd + F 聚焦搜索框(树形视图下)
Ctrl/Cmd + S 下载
Enter / Shift + Enter 搜索框内跳下一个 / 上一个

实现要点

为什么手写解析器

parser.js 是手写的递归下降,没有用 JSON.parse,为了三件事:

  1. 精确的错误位置 — 报出行、列、字符偏移,才能在编辑器里定位并高亮。
  2. 数字不失真 — 节点保留原始字面量,所以 9007199254740993 和 1e400 原样输出;JSON.parse 会把前者变成 ...992。
  3. 重复键不丢 — 对象用有序的 entries 数组表示,{"a":1,"a":2} 两个成员都保留,并作为警告报出。

共用节点树

yaml.js 产出的节点结构与 parser.js 完全一致(object/array/string/number/bool/null),所以树形视图、统计、两种序列化器全都能直接复用。互转就是「用 A 解析,用 B 序列化」,没有额外的转换层。

YAML 支持范围

支持块映射与块序列(含 - key: value 紧凑写法)、流式集合 {}/[](可跨行)、三种标量风格、块标量 | 与 >(含 -/+ 截断与显式缩进指示)、注释、多文档 ---、锚点 &/别名 */合并键 <<、!!str 等标准标签。

明确不支持的会给清楚的报错而不是猜:显式复杂键 ? key、用集合或别名当映射键。

标量按 YAML 1.2 core schema 解析,所以 yes/no/on/off 是字符串而非布尔 —— 这正是有名的 "Norway problem"(country: NO 被当成 false)。遇到这些词会给一条警告说明分歧。序列化时也会给它们加引号,避免被 1.1 的解析器读成布尔。

正确性用 PyYAML 做差分验证:3500 份随机生成的 YAML,除「1.1 与 1.2 的已知分歧」外零差异。

移动端断点

触发条件是 (max-width: 640px), (max-height: 500px) —— 不能只看宽度: 手机横屏宽度有 667–932px,按宽度判断会落回桌面双栏布局,可高度只剩 375–430px,上下分栏后每个面板不到 150px,比竖屏更没法用。

小屏做的几件事:

  • 一次只显示一个面板,靠 .workspace[data-pane] 控制,顶部给切换条
  • 格式化/压缩后自动跳到输出面板 —— 否则点了按钮看不到结果
  • 工具栏横向滚动(overflow-x: auto + 隐藏滚动条),常用按钮在最左边
  • 隐藏行号槽与输入格式下拉,把宽度留给内容
  • 输入框强制 font-size: 16px —— iOS 在字号小于 16px 时会自动放大页面, 放大后布局全乱且不会自动还原
  • env(safe-area-inset-*) 避让刘海与底部手势条
  • 横屏额外把所有横向条带压紧,工具栏与搜索栏用 grid 并成一行

性能

瓶颈不在解析,在 DOM。实测(本机 Chromium):

输入 JSON 解析 YAML 解析 高亮后的 DOM
2.7 MB 48 ms 98 ms 40 万个元素,布局 ~1 s
13.7 MB 227 ms 476 ms —
27.5 MB 456 ms 839 ms —
41.3 MB 590 ms 1276 ms —

解析是线性的,2 MB 只要几十毫秒。真正的开销是语法高亮:每个 token 包一层 <span>,每 MB 文本约产生 14 万个元素,浏览器要为每个都算布局。

所以文本视图是虚拟滚动的 —— DOM 里永远只有视口内那几十行,代价与文件大小无关。1 MB 文件实测:

虚拟滚动前 虚拟滚动后
DOM 里的 span 14 万 125
布局耗时 1039 ms 1 ms
是否有高亮 ❌ 超过 512 KB 就关掉 ✅ 一直有

之前的做法是整份塞进 innerHTML,于是不得不划一条 512 KB 的线(那时实测 1 MB 在 Firefox 上就滑不动了,Safari 能扛住 —— 引擎差异比想象的大)。虚拟滚动之后这条线没有存在的必要,一并删掉了。

能这么切分的前提:换行符只出现在 <span> 之外,每行的标记自闭合。tests/parser.test.js 与 tests/yaml.test.js 里各有用例锁住这一点 —— YAML 的块标量(|)尤其要当心,那里的换行是内容本身。

输入框(左侧)没有语法高亮,这是有意的:原生 <textarea> 只能显示纯文本。 要上色得把透明 textarea 叠在彩色 <pre> 上,两层的字体、行高、换行、滚动都要 严格对齐,差一像素就看得出来;而且大文件还要再给这一层做一套虚拟滚动。 输入框的作用是「贴进去」,真正要读的是右侧输出 —— 不值得为此换掉 textarea 或引入 CodeMirror(那会让包体积从 50 KB 涨到 250 KB)。

行号槽用的是同一套办法(paintGutter),20 MB 输入有 100 万行,全量渲染要 3.6 秒,只画可见的几十行则与文件大小无关。

「全部展开 / 全部折叠」 是最容易写出平方级复杂度的地方,两处要当心:

  • expandAll 用显式工作队列,不要反复 querySelectorAll 整个容器 —— 展开会不断长出新节点,每轮全量扫是 O(n) × 轮数,1 MB 就要 5.6 秒、2 MB 要 17 秒。
  • collapseAll 重建一棵最小的树,而不是给几万个节点加 class —— 后者 DOM 全留着,之后每次查询都要从中筛。

配合节点预算(单次最多新建 5000 个 DOM 节点,单个分支不超过剩余预算的一半,广度优先推进),两个操作的耗时都与文件大小无关:

输入 全部展开 全部折叠 展开后 DOM
2 MB 471 ms 104 ms 5,215
10 MB 641 ms 117 ms 5,213
15 MB 595 ms 113 ms 5,213

超出预算时状态栏会如实说明「只展开了前 N 个分支」,而不是假装全展开了 —— 要定位具体内容用搜索。

其他措施:

  • 递归解析设 1000 层深度上限,超出给正常错误而不是 RangeError(实测浏览器栈在 3000~3500 层之间崩,Safari 更低)。
  • 自动识别格式时复用解析结果,不再为了判断格式白解析一遍(2 MB 省 30 ms 以上)。
  • 树形视图懒渲染:只有展开过的节点才创建 DOM,超长数组每批 200 条。2 MB 文件首屏只建 204 个节点。
  • 搜索走数据模型而非 DOM,因此折叠的分支也能命中。
  • 输入超过 200 000 字符时,自动格式化的防抖从 300 ms 放宽到 800 ms。

换现成的库或用 WASM 重写解析器? 都不值得。实测对比(2 MB 输入):

JSON 解析 大整数保真 重复键保留 精确行列
JSON.parse 4 ms ❌ ❌ ❌
本项目 29 ms ✅ ✅ ✅
jsonc-parser 24 ms ❌ ❌ ⚠️ 只给字节偏移

YAML 侧:js-yaml 解析快 28 ms 但序列化慢 53 ms(一次往返净亏),yaml 库慢 5.8×(它保留完整 CST,做的事更多)。而 9007199254740993 在所有库里都会变成 ...992。

换库最多省十几毫秒,却要放弃三个核心功能。至于 WASM:解析本来就只占总耗时的零头,瓶颈从来在 DOM —— 那部分已经用虚拟滚动解决了。

项目结构

index.html            页面结构(Vite 的入口)
src/
  app.js              事件绑定、格式识别与分派、状态栏、行号、分栏、持久化
  styles.css          样式(亮/暗双主题)
  lib/
    parser.js         手写 JSON 解析器
    formatter.js      JSON 序列化(格式化 / 压缩 / 高亮)+ 统计
    yaml.js           YAML 解析与序列化
    diff.js           结构 diff:合并树 + 统一 diff 文本渲染
    tree.js           可折叠树形视图 + 搜索 + 差异渲染
    i18n.js           界面文案的中英词条
tests/
  helpers.js          断言辅助(test / eq / ok / throws)
  parser.test.js      JSON 解析与序列化
  yaml.test.js        YAML 解析、序列化、互转
  diff.test.js        结构 diff(数组三级策略、大整数精度、统一 diff 文本)
  source.test.js      源码卫生(不许有字面控制字符、必须是合法 UTF-8)
  tree.test.js        树形视图 DOM 层 + 差异渲染
  app.test.js         应用层集成(双格式 UI、对比模式)
scripts/
  perf-scale.js       分级性能测试(解析 / 序列化 / DOM)
  tree-perf.js        树形视图展开与折叠的性能测试
wrangler.json         Cloudflare Workers 部署配置
docker/nginx.conf     容器里的静态服务配置

src/lib/ 下除 tree.js(视图层)外都是纯逻辑,不碰 DOM, 依赖关系没有环:

parser, formatter, i18n   无依赖
yaml   → parser(Node、JsonError)+ formatter(escapeHtml)
diff   → formatter(serialize,用于数组对齐时的语义比较)
tree   → formatter(quote)+ i18n(t)
app    → 以上全部

diff.js 产出的合并树仍是合法的节点树,只是每个节点多挂一个 _diff, 所以能直接交给同一个 TreeView 渲染,不需要第二套视图。

开发

npm install
npm run dev        # 本地开发服务,改完即时生效
npm run build      # 产出 dist/
npm run preview    # 预览 dist/
npm run format     # Prettier 格式化

vite.config.js 里 base 设为 './',所以 dist/ 放在任何子目录都能跑, 直接双击 dist/index.html 也能打开。部署到固定路径(比如 GitHub Pages 的 /formatter/)时把它改成对应前缀即可。

部署

线上:https://f.mk4.top

Cloudflare Workers

npm run deploy      # build + wrangler deploy

配置全在 wrangler.json 里:assets.directory 指向 dist/, routes 声明自定义域名。静态站点不需要写 Worker 代码。

首次配置:

npx wrangler login
npm run deploy

域名由 wrangler.json 的 routes 带 custom_domain: true 自动接管, Cloudflare 会自己建 DNS 记录 —— 前提是那个 hostname 上不能有已存在的 A/CNAME 记录,否则报 code: 100117,得先去 DNS 面板删掉。这一点和 Pages 相反:Pages 要求你手动加 CNAME,Workers 要求你别加。

绑定后几分钟内可能返回 522,那是证书签发中的过渡状态,不用动配置。

用 Workers 而不是 Pages:Cloudflare 已把 Pages 转为维护模式,新功能 都在 Workers 上。对纯静态站点两者性能没有区别,迁过来只是为了以后 想加服务端能力(分享链接、API)时不用再搬一次。

看流量

npm run traffic          # f.mk4.top 近 24 小时
node scripts/traffic.js 7 # 整个域近 7 天(按天)

数据来自 Cloudflare 的边缘统计,在代理层就算好了,不需要往页面注入任何 脚本 —— 所以「贴进去的数据不离开浏览器」这个承诺不受影响。这也是不接 Google Analytics 之类的原因:那些要注入第三方脚本,会自己发请求。

网页上看:Cloudflare → mk4.top → Analytics & Logs → Traffic, 按 Hostname 筛 f.mk4.top。

两个接口的粒度不同:近 24 小时能按 hostname 拆到子域;按天汇总的那个 只到域级别,拿不到单个子域,只能看趋势。

Docker

docker build -t json-yaml-formatter .
docker run -p 8080:80 json-yaml-formatter
# 或
docker compose up --build

多阶段构建:node 打包,nginx 托管,最终镜像里没有 node 也没有 node_modules。 nginx 配置见 docker/nginx.conf —— 带哈希的资源长缓存、index.html 不缓存、 开 gzip。

其他静态托管

npm run build 之后把 dist/ 扔上去就行,没有服务端依赖。

测试

npm test           # 326 项,一条命令跑完
npm run test:watch # 改代码即时重跑

用 Vitest,DOM 相关的用例跑在 jsdom 环境下(vite.config.js 里配的)。 断言辅助 test / eq / ok / throws 放在 tests/helpers.js —— 从原本的 迷你框架保留下来的,这样迁移时不用逐条改写断言,报错信息也是为这个项目 定制的(直接打印实际值与期望值)。需要 expect 时从 vitest 引入即可, 两者可以混用。

性能测试单独跑。先造样本,再跑:

# 造样本(默认写到 ./test-data,已在 .gitignore 里)
node scripts/gen-test-data.js 1 2 5 10 25
node scripts/gen-test-data.js --out /tmp/data 25    # 或指定目录

# 解析 / 序列化 / DOM 各环节耗时(自己造数据,不读样本)
node scripts/perf-scale.js              # 默认 2 / 10 / 20 / 30 MB
node scripts/perf-scale.js 2 50         # 指定档位

# 树形视图的展开 / 折叠
node scripts/tree-perf.js test-2mb.json test-10mb.json
node scripts/tree-perf.js --dir /tmp/data test-25mb.json
node scripts/tree-perf.js /abs/path/to/some.json     # 绝对路径也认

数据目录都可以配:--out / --dir 参数优先,其次 FORMATTER_TEST_DATA 环境变量,都没给就用 ./test-data。

注意应用的「打开文件」上限是 25 MB,比这更大的样本只能靠粘贴进去。

About

Json Yaml formatter and diff

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages