前端知库

怎样实现对象扁平化和反扁平化?数组下标和空对象怎么处理?

扁平化是递归遍历对象,把每个叶子值存到用路径拼成的键下,对象用点号连接,数组用 [下标];空对象和空数组没有叶子,要原样保留,否则会丢失。反扁平化把路径拆成段,逐层往下建对象或数组,下一段是数字就建数组。路径来自外部输入时,要跳过 __proto__ 这类键防止原型污染。

参考答案

简答

比如表单配置是 { user: { name: '小明', tags: ['a'] } },提交给后端要变成 { 'user.name': '小明', 'user.tags[0]': 'a' },这就是扁平化;反过来把带路径的键还原成嵌套对象,就是反扁平化。

扁平化用递归:遇到对象或数组就往下走,把当前路径传下去,对象的键用点号接上,数组的下标写成 [0];遇到其他值,就是叶子,把它存到当前路径下。反扁平化把每个键拆成路径段,从根开始一层层往下,某一层不存在就新建,下一段是数字下标就建数组,否则建对象。

有两个边界最常被问。空对象和空数组没有任何叶子,递归时什么也不会写进结果,所以要单独把它们原样存下来,不然还原回去就少了这个字段。null 不是对象,要当叶子处理,不能往下递归。

还要注意安全。反扁平化的键如果来自用户输入,'__proto__.isAdmin' 这样的路径会把属性写到所有对象共享的原型上,所以要跳过 __proto__、constructor、prototype 这几个段。

手写 flatten 和 unflatten

下面的代码分两半:flatten 递归遍历生成路径键,unflatten 拆开路径逐层还原。看 e: {} 和 f: [] 在两边是怎么处理的。

JavaScript
function isPlainObject(value) {
  if (value === null || typeof value !== 'object') return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

function flatten(source) {
  const result = {};

  function walk(value, path) {
    const isArray = Array.isArray(value);
    if (!isArray && !isPlainObject(value)) {
      result[path] = value;
      return;
    }
    const keys = Object.keys(value);
    // 空对象和空数组没有子键,原样保留,否则反扁平化时会丢掉它们。
    if (keys.length === 0 && path !== '') {
      result[path] = isArray ? [] : {};
      return;
    }
    for (const key of keys) {
      const next = isArray ? `${path}[${key}]` : path ? `${path}.${key}` : key;
      walk(value[key], next);
    }
  }

  walk(source, '');
  return result;
}

const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);

function parsePath(path) {
  const segments = [];
  for (const match of path.matchAll(/([^.[\]]+)|\[(\d+)\]/g)) {
    segments.push(match[2] === undefined ? match[1] : Number(match[2]));
  }
  return segments;
}

function unflatten(flat) {
  const root = {};
  for (const [path, value] of Object.entries(flat)) {
    const segments = parsePath(path);
    if (segments.some((s) => FORBIDDEN.has(s))) continue;
    let node = root;
    segments.forEach((segment, i) => {
      if (i === segments.length - 1) {
        node[segment] = value;
        return;
      }
      if (node[segment] === undefined) {
        node[segment] = typeof segments[i + 1] === 'number' ? [] : {};
      }
      node = node[segment];
    });
  }
  return root;
}

const data = { a: { b: 1, c: [10, { d: 2 }] }, e: {}, f: [], g: null };
const flat = flatten(data);

console.log(JSON.stringify(flat)); // {"a.b":1,"a.c[0]":10,"a.c[1].d":2,"e":{},"f":[],"g":null}
console.log(JSON.stringify(unflatten(flat)) === JSON.stringify(data) ? 'same' : 'diff'); // same
console.log(JSON.stringify(unflatten({ 'list[1]': 'b', 'list[0]': 'a' }))); // {"list":["a","b"]}
console.log(JSON.stringify(unflatten({ '__proto__.isAdmin': true }))); // {}
console.log(String({}.isAdmin)); // undefined

flatten 的结果里,a.c[0] 和 a.c[1].d 说明数组用的是下标写法,对象用的是点号。e 和 f 被原样存成了 {} 和 [],g 的 null 也作为叶子留了下来。再用 unflatten 还原,序列化后和原数据完全一样。

为什么空对象要单独判断?因为递归只在遇到叶子时才往结果里写。{} 没有任何键,for 循环一次都不执行,什么也不会留下,还原时这个字段就消失了。所以代码在“键为空且不是根”时,直接把一个新的空对象或空数组存进结果。根对象本身是空的时候,返回空结果就对了,所以要排除 path === ''。

为什么用 isPlainObject 判断,而不是 typeof value === 'object'?因为 typeof null 也是 'object',往下递归会报错。Date、Map 这类对象也是 'object',可它们的数据不在可枚举属性里,Object.keys(new Date()) 是空数组,按普通对象拆开就丢了。只有原型是 Object.prototype 或 null 的普通对象才往下拆,其他都当叶子。

反扁平化时怎样决定建对象还是数组

parsePath 把 'a.c[1].d' 拆成 ['a', 'c', 1, 'd'],方括号里的下标转成数字,点号分隔的键保留字符串。往下走时,如果下一层不存在,就看下一段的类型:是数字就建数组,是字符串就建对象。

第三个输出里,list[1] 比 list[0] 先出现。第一次处理 list[1] 时,下一段是数字 1,所以 list 建成数组,再写 list[1] = 'b',这时数组下标 0 还是空位。处理到 list[0] 时把空位补上,最后结果是 ["a","b"]。如果有的下标一直没有出现,数组里就会留下空位,这一点要和面试官确认需求。

这里约定只有方括号里的才算下标,'a.0' 里的 0 当作对象键。lodash 的 set 会把点号后的纯数字也当成下标,见《怎样手写 lodash 的 get 和 set?》。两种约定都可以,重要的是 flatten 和 unflatten 用同一种,才能互相还原。

为什么要过滤 proto

第四个输出里,键是 '__proto__.isAdmin'。如果不拦,第一段 __proto__ 读到的是 Object.prototype,接着往上面写 isAdmin = true,以后所有普通对象都会“继承”到这个属性,这就是原型污染。代码里只要路径中有 __proto__、constructor、prototype 任何一段,就跳过整条,所以最后一行 {}.isAdmin 还是 undefined。原型污染的成因和更多防御办法见《原型污染为什么常出现在动态属性赋值和深合并中?》。

常见追问

键名本身含有点号怎么办,比如 { 'a.b': 1 }?

扁平化后得到的键也是 'a.b',和 { a: { b: 1 } } 扁平化的结果撞在一起,还原时没法区分。要支持这种键,就得约定转义规则,比如把键里的点号写成 \.,或者改用数组表示路径,比如 [['a.b'], 1]。

对象里有循环引用会怎样?

递归会一直走下去,最后栈溢出。可以用一个 WeakSet 记录当前路径上已经访问过的对象,再次遇到时抛错或者跳过。处理循环引用的思路和深拷贝相同,见《怎样手写深拷贝?和 structuredClone 有什么区别?》。