← 中文资源

简体中文版本

RFC 6902 JSON Patch 差异与应用

从两个 JSON 值生成 RFC 6902 的 add、remove、replace 操作,并用 JSON Pointer 安全应用。

阅读英文原文

功能说明

该示例提供 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;
}