功能说明
该示例提供 JSON Patch 的生成与应用两端。diffJson 递归比较两个 JSON 值,对象按键生成增删改操作,数组与标量整体替换,路径使用 RFC 6901 转义;applyJsonPatch 在文档副本上依次执行操作,支持根替换、嵌套对象与数组插入。适用于变更日志、配置下发、协作编辑与增量同步。
关键代码
diff 先做深度相等判断,对象比较只遍历自有键,先输出被删除的键再处理新增与递归修改,保证补丁顺序可重放。路径拼接把波浪号与斜杠分别编码为 ~0 与 ~1。apply 每步先校验路径以斜杠开头,再解码各段;数组下标禁止前导零,add 允许等于长度以插入,remove 与 replace 要求元素存在;对象 replace 要求目标键存在。所有写入值先 structuredClone,避免补丁与结果共享引用。
使用注意
生成器不对数组做逐项差异,数组变化会退化为整段 replace,补丁体积可能较大;需要最小化数组补丁时应扩展算法。apply 会对整个文档做一次深拷贝,大文档有内存成本。路径段禁止 proto、constructor 与 prototype,防止原型污染。操作中的 move、copy、test 未实现,读取外部补丁前应校验 op 白名单或扩展类型。
验证方式
对嵌套对象生成补丁,断言包含预期的 add、remove、replace 路径;把补丁应用到原文档并深度比较目标值;测试键名含斜杠与波浪号时的转义与还原;验证数组 add 在中间插入、remove 导致下标前移、replace 越界抛错;根路径 replace 生效,根路径 remove 抛错;含 proto 的路径抛 TypeError。
代码示例
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonPatchOperation =
| { op: "add"; path: string; value: JsonValue }
| { op: "remove"; path: string }
| { op: "replace"; path: string; value: JsonValue };
function isPlainObject(value: unknown): value is Record<string, JsonValue> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function deepEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right)) {
return left.length === right.length &&
left.every((value, index) => deepEqual(value, right[index]));
}
if (isPlainObject(left) && isPlainObject(right)) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
return leftKeys.length === rightKeys.length &&
leftKeys.every((key) =>
Object.prototype.hasOwnProperty.call(right, key) && deepEqual(left[key], right[key]));
}
return false;
}
function encodeToken(token: string): string {
return token.replace(/~/g, "~0").replace(/\//g, "~1");
}
function decodeToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
function joinPath(base: string, key: string): string {
return base + "/" + encodeToken(key);
}
function cloneJson(value: JsonValue): JsonValue {
return structuredClone(value);
}
export function diffJson(
before: JsonValue,
after: JsonValue,
basePath: string = "",
): JsonPatchOperation[] {
if (deepEqual(before, after)) return [];
if (isPlainObject(before) && isPlainObject(after)) {
const operations: JsonPatchOperation[] = [];
for (const key of Object.keys(before)) {
if (!Object.prototype.hasOwnProperty.call(after, key)) {
operations.push({ op: "remove", path: joinPath(basePath, key) });
}
}
for (const [key, value] of Object.entries(after)) {
const path = joinPath(basePath, key);
if (!Object.prototype.hasOwnProperty.call(before, key)) {
operations.push({ op: "add", path, value: cloneJson(value) });
} else {
operations.push(...diffJson(before[key], value, path));
}
}
return operations;
}
return [{ op: "replace", path: basePath, value: cloneJson(after) }];
}
function arrayIndex(token: string, length: number, allowEnd: boolean): number {
if (token === "-") {
if (!allowEnd) throw new RangeError("append is only valid for add");
return length;
}
if (!/^(0|[1-9]\d*)$/.test(token)) throw new SyntaxError("invalid array index: " + token);
const index = Number(token);
const maximum = allowEnd ? length : length - 1;
if (index > maximum) throw new RangeError("array index out of range: " + token);
return index;
}
export function applyJsonPatch(
document: JsonValue,
operations: readonly JsonPatchOperation[],
): JsonValue {
let result = cloneJson(document);
for (const operation of operations) {
if (operation.path !== "" && !operation.path.startsWith("/")) {
throw new SyntaxError("invalid JSON Pointer: " + operation.path);
}
const tokens = operation.path === ""
? []
: operation.path.slice(1).split("/").map(decodeToken);
if (tokens.length === 0) {
if (operation.op === "remove") throw new TypeError("cannot remove the document root");
result = cloneJson(operation.value);
continue;
}
let parent: JsonValue = result;
for (let index = 0; index < tokens.length - 1; index += 1) {
const token = tokens[index];
if (token === "__proto__" || token === "constructor" || token === "prototype") {
throw new TypeError("unsafe patch path: " + operation.path);
}
if (Array.isArray(parent)) {
parent = parent[arrayIndex(token, parent.length, false)];
} else if (isPlainObject(parent)) {
if (!Object.prototype.hasOwnProperty.call(parent, token)) {
throw new RangeError("missing patch path: " + operation.path);
}
parent = parent[token];
} else {
throw new RangeError("patch parent is not a container");
}
}
const last = tokens[tokens.length - 1];
if (last === "__proto__" || last === "constructor" || last === "prototype") {
throw new TypeError("unsafe patch path: " + operation.path);
}
if (Array.isArray(parent)) {
const index = arrayIndex(last, parent.length, operation.op === "add");
if (operation.op === "add") parent.splice(index, 0, cloneJson(operation.value));
else if (operation.op === "remove") parent.splice(index, 1);
else parent[index] = cloneJson(operation.value);
} else if (isPlainObject(parent)) {
if (operation.op === "remove") {
if (!Object.prototype.hasOwnProperty.call(parent, last)) {
throw new RangeError("missing patch path: " + operation.path);
}
delete parent[last];
} else {
if (operation.op === "replace" &&
!Object.prototype.hasOwnProperty.call(parent, last)) {
throw new RangeError("replace target does not exist: " + operation.path);
}
parent[last] = cloneJson(operation.value);
}
} else {
throw new RangeError("patch parent is not a container");
}
}
return result;
}