JSON Pointer、Patch 与 Merge Patch
结论:三份短规范组成一套变更协议: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 项
三条规则:
- 空字符串指向根节点本身,不是「空节点」;
- 每个
/分隔一段引用令牌,数组用十进制下标(不允许前导零,01非法); - 令牌中的
~写作~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 的规则只有四条:
- 目标是对象、补丁也是对象 → 递归合并;
- 补丁里某键的值为
null→ 删除目标里的这个键; - 补丁里某键的值不是对象 → 直接替换;
- 补丁本身不是对象 → 整个目标被替换。
// 目标
{ "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);
四个容易忽略的点:
- 不要原地修改共享对象,补丁失败会留下半改状态(除非实现保证回滚);
- 路径校验:来自外部的补丁必须校验每个
path/from,拒绝__proto__、constructor这类键; - 大小限制:限制补丁条目数,防止一份补丁做几十万次操作;
- 审计:补丁天然适合写审计日志——它本身就是「改了什么」的完整描述。
六、错误语义
| 情况 | 建议状态码 | 说明 |
|---|---|---|
| 补丁不是数组 / 结构非法 | 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)应当报错,宽松实现会让同一份补丁在不同服务端行为不一致。
是否可以直接在原地修改原对象?
不建议。补丁失败会留下半改状态,除非实现保证回滚;应当在深拷贝或结构化克隆的副本上应用,成功后再替换引用。