← 中文资源

简体中文版本

安全的对象路径读取与写入

用点号与方括号路径读取嵌套值,以写时复制方式设置,并拦截原型污染片段。

阅读英文原文

功能说明

该示例提供两条路径工具:getPath 安全读取深层字段并支持默认值,setPath 在复制链路上写入而不修改原对象。路径支持 a.b.c 与 a[0].b 两种写法,方括号只接受数字下标,避免解析引号与转义带来的歧义。适用于表单字段映射、配置读取、日志字段提取等需要动态路径的场景。

关键代码

parsePath 先把数字方括号统一成点号形式,再拒绝残留方括号、首尾点和连续点,确保不会产生空片段。读取时只接受自有属性,遇到 null、原始值或缺失键立即返回默认值。写入采用写时复制:根节点先浅拷贝,沿途每一层若已有对象或数组也复制,缺失则根据下一个片段是否为数字决定新建数组或对象。所有片段在读写前都经过 BLOCKED 集合检查。

使用注意

setPath 只复制路径经过的节点,未触及的兄弟节点仍与原对象共享引用;若需要完全隔离,应配合深拷贝。数字片段写入数组时不检查越界,会按 JavaScript 语义产生空洞或延长数组,业务层应自行校验范围。路径语法不支持带引号的键、转义字符或包含点的键名,这类键应改用数组形式传入或直接访问。读取被拦截键会抛错而不是返回默认值,便于尽早发现恶意输入。

验证方式

测试 a.b.c 与 a[0].b 的读取;修改返回值不影响原对象,且同层兄弟引用保持可观察;传入 null 或缺失路径时返回默认值;对 proto、constructor 路径断言抛 TypeError;用 a[0].b 在空对象上写入,验证自动创建数组与对象;测试连续点、首尾点与非数字方括号抛 SyntaxError。

代码示例

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

export function parsePath(path) {
  if (typeof path !== "string" || path.trim() === "") {
    throw new TypeError("path must be a non-empty string");
  }
  const normalized = path.replace(/\[(\d+)\]/g, ".$1");
  if (/[\[\]]/.test(normalized)) throw new SyntaxError("unsupported bracket syntax");
  if (normalized.startsWith(".") || normalized.endsWith(".") || normalized.includes("..")) {
    throw new SyntaxError("path contains an empty segment");
  }
  return normalized.split(".");
}

function assertSafe(token) {
  if (BLOCKED.has(token)) throw new TypeError("unsafe path segment: " + token);
}

function isPlainObject(value) {
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
  const prototype = Object.getPrototypeOf(value);
  return prototype === Object.prototype || prototype === null;
}

export function getPath(source, path, fallback) {
  let current = source;
  for (const token of parsePath(path)) {
    assertSafe(token);
    if (current === null || (typeof current !== "object" && typeof current !== "function")) {
      return fallback;
    }
    if (!Object.prototype.hasOwnProperty.call(current, token)) return fallback;
    current = current[token];
  }
  return current === undefined ? fallback : current;
}

export function setPath(source, path, value) {
  const tokens = parsePath(path);
  for (const token of tokens) assertSafe(token);
  const root = Array.isArray(source)
    ? [...source]
    : isPlainObject(source)
      ? { ...source }
      : {};
  let current = root;

  for (let index = 0; index < tokens.length - 1; index += 1) {
    const token = tokens[index];
    const nextToken = tokens[index + 1];
    if (Array.isArray(current) && !/^\d+$/.test(token)) {
      throw new TypeError("array path segments must be numeric");
    }
    const existing = Object.prototype.hasOwnProperty.call(current, token)
      ? current[token]
      : undefined;
    let next;
    if (Array.isArray(existing)) next = [...existing];
    else if (isPlainObject(existing)) next = { ...existing };
    else next = /^\d+$/.test(nextToken) ? [] : {};
    current[token] = next;
    current = next;
  }

  const last = tokens[tokens.length - 1];
  if (Array.isArray(current)) {
    if (!/^\d+$/.test(last)) throw new TypeError("array path segments must be numeric");
    current[Number(last)] = value;
  } else {
    current[last] = value;
  }
  return root;
}