JSON 工具箱

JSON Pointer、Patch 与 Merge Patch

结论:三份短规范组成一套变更协议:Pointer 定位、Patch 精确操作、Merge Patch 局部合并。选错一种就会在数组与并发上出事。

进阶约 12 分钟发布于 2026-10-30更新于 2026-10-30
JSON Pointer、Patch 与 Merge Patch 封面

结论:只按位置定位用 JSON Pointer,要筛选或递归用 JSONPath;数组增删、写入 null 与版本断言交给 JSON Patch,扁平配置和表单才用 Merge Patch。

定位和修改是两件事,规范把它们拆成了三份文档。搞混的代价通常是「整个数组被替换掉」并且没人发现。

一、JSON Pointer:最短的定位语法

RFC 6901 只做一件事:用一个字符串指向文档里的某个节点。

""             → 整个文档
"/store"       → 根下的 store 成员
"/store/book"  → 再下一层
"/store/book/0/title" → 数组第 0 项

三条规则:

  1. 空字符串指向根节点本身,不是「空节点」;
  2. 每个 / 分隔一段引用令牌,数组用十进制下标(不允许前导零,01 非法);
  3. 令牌中的 ~ 写作 ~0,/ 写作 ~1——先换 ~ 再换 /,否则二次转义。
/**
 * 把单个引用令牌转义为 JSON Pointer 片段。
 * @param token 原始键名或下标
 * @returns 转义后的令牌
 */
function escapePointerToken(token: string): string {
  return token.replace(/~/g, '~0').replace(/\//g, '~1');
}

/**
 * 把键名数组拼成 JSON Pointer。
 * @param tokens 键名或下标数组
 * @returns 以 / 开头的指针字符串
 */
function buildPointer(tokens: Array<string | number>): string {
  return tokens.map((token) => `/${escapePointerToken(String(token))}`).join('');
}

键名本身含 / 时最能体现价值:{"a/b": 1} 的指针是 /a~1b,而不是 /a/b(后者表示 a 下的 b)。

URL 里使用时还要做百分号编码,因为 # 与 ~ 在 fragment 中有特殊含义。

二、JSON Patch:六种操作的原子序列

RFC 6902 用操作数组描述变更,媒体类型 application/json-patch+json:

[
  { "op": "test", "path": "/version", "value": 7 },
  { "op": "replace", "path": "/name", "value": "新的名字" },
  { "op": "add", "path": "/tags/-", "value": "json" },
  { "op": "remove", "path": "/temp" },
  { "op": "copy", "from": "/name", "path": "/alias" },
  { "op": "move", "from": "/tags/0", "path": "/tags/1" }
]
操作 语义 边界
add 新增成员或插入数组元素 数组下标可等于长度(追加),- 表示末尾
remove 删除 路径必须存在
replace 替换已有值 路径必须存在(等价于 remove + add,但要求先存在)
move 移动 不能把节点移动到自己的子路径下
copy 复制 源必须存在
test 断言相等 失败则整个补丁不生效,用于乐观锁

关键性质是原子性:任一操作失败,整个补丁回滚,不会有「前三条生效、第四条失败」的中间态。 这让它非常适合做 PATCH 请求体。

2.1 用 test 做乐观并发

[
  { "op": "test", "path": "/etag", "value": "a3f9c1" },
  { "op": "replace", "path": "/status", "value": "paid" }
]

客户端先 GET 拿到 etag,再把它写进 test。并发写时第二个请求的 test 会失败,服务端返回 409 Conflict 或 412 Precondition Failed。这比「后写覆盖」安全得多,也不需要引入分布式锁。

2.2 数组操作最容易出错

// 想在 index 1 插入
{ "op": "add", "path": "/items/1", "value": {...} }   // 正确:插入,原 1 及之后后移

// 想替换 index 1
{ "op": "replace", "path": "/items/1", "value": {...} } // 正确:替换

add 到数组下标 n 是插入,不是覆盖;下标越界(n > length)必须报错。 一些实现宽松处理,导致同一份补丁在不同服务端行为不一致。

三、Merge Patch:长得像目标的片段

RFC 7386 的规则只有四条:

  1. 目标是对象、补丁也是对象 → 递归合并;
  2. 补丁里某键的值为 null → 删除目标里的这个键;
  3. 补丁里某键的值不是对象 → 直接替换;
  4. 补丁本身不是对象 → 整个目标被替换。
// 目标
{ "a": "b", "c": { "d": "e", "f": "g" } }

// 补丁
{ "a": "z", "c": { "f": null } }

// 结果
{ "a": "z", "c": { "d": "e" } }

媒体类型是 application/merge-patch+json。它读起来像「局部更新」,所以特别容易被误用:

  • 数组只能整体替换。想删掉 list[2],Merge Patch 做不到;
  • 无法写入 null。null 被占用为删除语义;
  • 无法表达「不改这个字段」与「删除这个字段」的第三种状态(比如显式置空)。

四、能力矩阵:选哪个

需求 JSON Patch Merge Patch
精确替换某个数组元素 ✓ ✗
删除数组中的一项 ✓ ✗
局部更新若干字段 ✓(多条 replace) ✓(最简洁)
写入 null 值 ✓ ✗
并发控制(版本断言) ✓(test) ✗(只能靠 If-Match)
补丁可读性 一般 好(像数据本身)
实现复杂度 高 低
适合的资源形态 深层嵌套、列表 扁平配置、表单

经验法则:

  • 表单、设置页、配置类资源 → Merge Patch,前端只需提交变化字段;
  • 列表增删、状态机流转、需要并发保护 → JSON Patch;
  • 两者可以共存:不同端点声明不同 Content-Type,服务端按类型分派。

五、实现要点

import { applyPatch, compare } from 'fast-json-patch';

const document = { version: 7, items: [{ id: 1 }, { id: 2 }] };
const patch = compare(document, { version: 8, items: [{ id: 1 }] });

// 默认会校验 test 操作;mutateDocument 置 true 会原地修改,慎用
const result = applyPatch(document, patch, true, false);
console.log(result.newDocument);

四个容易忽略的点:

  1. 不要原地修改共享对象,补丁失败会留下半改状态(除非实现保证回滚);
  2. 路径校验:来自外部的补丁必须校验每个 path/from,拒绝 __proto__、constructor 这类键;
  3. 大小限制:限制补丁条目数,防止一份补丁做几十万次操作;
  4. 审计:补丁天然适合写审计日志——它本身就是「改了什么」的完整描述。

六、错误语义

情况 建议状态码 说明
补丁不是数组 / 结构非法 400 Bad Request 请求体格式错误
path 指向的节点不存在 422 Unprocessable Entity 语法合法但语义不可执行
test 操作失败 409 或 412 表示版本冲突
无权限修改该路径 403 Forbidden 不要用 404 掩盖,除非刻意隐藏资源存在性

区分 400 与 422 很重要:前者是「我看不懂你的补丁」,后者是「我看懂了但这份数据不允许这样改」。

七、检查清单

  • [ ] 端点声明了正确的 Content-Type(json-patch+json / merge-patch+json);
  • [ ] 补丁应用是原子的,失败不留半成品;
  • [ ] 外部补丁做路径白名单与条目数限制;
  • [ ] 用 test 或 If-Match 做并发控制;
  • [ ] 补丁写入审计日志;
  • [ ] 数组场景显式选择 JSON Patch,别指望 Merge Patch。

常见问题

如何判断该用 JSON Patch 还是 Merge Patch?

需要精确替换或删除数组元素、写入 null、做版本断言时用 JSON Patch;只是局部更新若干字段的表单与配置类资源用 Merge Patch。Merge Patch 无法删除数组中的某一项,也无法写入 null,因为 null 已被占用为删除语义。

为什么 add 到数组下标 n 不是覆盖?

因为 add 在数组里是插入语义,原下标 n 及其之后的元素会整体后移。要替换已有元素必须写 replace,且路径先存在;下标越界(n > length)应当报错,宽松实现会让同一份补丁在不同服务端行为不一致。

是否可以直接在原地修改原对象?

不建议。补丁失败会留下半改状态,除非实现保证回滚;应当在深拷贝或结构化克隆的副本上应用,成功后再替换引用。