← 中文资源

简体中文版本

按点路径深度剔除对象字段

在克隆嵌套普通对象的同时删除点路径字段,并拦截危险键以防原型污染。

阅读英文原文

功能说明

该示例在深拷贝普通对象或数组的同时,按点号分隔的路径删除敏感字段。例如同时删除顶层的 password、profile 下的 token 以及 items 数组中每个元素的 token。它返回全新结构,不修改原始对象,适合在日志输出、API 响应与审计事件前做白名单式脱敏。

关键代码

omitDeep 先把每条路径拆成段并校验,禁止 proto、constructor 与 prototype,随后插入一颗 Map 树。clone 递归处理数组与对象:数组元素若命中索引叶子则置为 undefined,否则用当前节点继续克隆;对象字段命中叶子则跳过,否则只带子节点继续递归。遇到 Date、Map 或类实例会抛 TypeError,避免把它们错误地展开成普通对象。

使用注意

该函数只支持普通对象、无原型对象与数组,不支持 Map、Set、Date、类实例与循环引用。数组按索引删除会留下 undefined 空洞,若需要紧凑数组应额外过滤。路径中的数字段用于数组下标,不适用于对象数字键的语义区分。克隆会打断共享引用,深层大对象有一定开销。脱敏后仍应做最终序列化检查。

验证方式

对包含 password、profile.token 与 items.token 的对象执行剔除,断言输出 JSON 不含这些字段,同时保留 profile.email 与 items.id。断言原始对象完全未变。测试单独删除数组下标、空路径、非法路径与危险键分别抛错,并传入 Date 实例断言 TypeError。

代码示例

type PathTree = Map<string, PathTree>;

const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
  if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
  if (paths.length === 0) return value;
  const tree: PathTree = new Map();
  for (const path of paths) {
    if (typeof path !== "string" || path.trim() === "") {
      throw new TypeError("paths must be non-empty strings");
    }
    const segments = path.split(".").map((segment) => segment.trim());
    if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
    for (const segment of segments) {
      if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
    }
    insert(tree, segments);
  }
  return clone(value, tree) as Value;
}

function insert(tree: PathTree, segments: readonly string[]): void {
  let node = tree;
  for (const segment of segments) {
    let child = node.get(segment);
    if (child === undefined) {
      child = new Map();
      node.set(segment, child);
    }
    node = child;
  }
}

function clone(value: unknown, tree: PathTree): unknown {
  if (Array.isArray(value)) {
    return value.map((item, index) => {
      const child = tree.get(String(index));
      if (child !== undefined && child.size === 0) return undefined;
      return clone(item, child ?? tree);
    });
  }
  if (value !== null && typeof value === "object") {
    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      throw new TypeError("omitDeep only supports plain objects and arrays");
    }
    const output: Record<string, unknown> = {};
    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
      const child = tree.get(key);
      if (child !== undefined && child.size === 0) continue;
      output[key] = clone(item, child ?? new Map());
    }
    return output;
  }
  return value;
}

const source = {
  id: 1,
  password: "secret",
  profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
  items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));