Wenzi

js 对象中深层数据的key的扁平与展开之间的转换

蚊子前端博客
发布于 2022/07/21 15:50
如何实现将Object类型中的key进行扁平收缩和展开的操作

实在没想好这个标题怎么起,这是一个面试题,是编写两个函数,实现下面两个 Object 的对象的互相转换。

const obj = {
  a: {
    b: {
      d: ['d1', 'd2', 'd3'],
    },
    c: {
      e: 'e1',
    },
  },
  f: [{ g: 'g1' }, [1, 2, 3]],
  h: null,
};

const obj2 = {
  'a.b.d[0]': 'd1',
  'a.b.d[1]': 'd2',
  'a.b.d[2]': 'd3',
  'a.c.e': 'e1',
  'f[0].g': 'g1',
  'f[1][0]': 1,
  'f[1][1]': 2,
  'f[1][2]': 3,
  h: null,
};

可以看到,变量 obj 是每个单独的属性都是展开的;而变量 obj2 是把所有的属性都收缩起来,最后的 key 是最深的那个层级的值,数组类型的,将转为[0]这种格式的。

js中的Object的key

1. 收缩所有的 key 成字符串 #

把 obj 中所有的 key 收缩成一个字符串,像这种无限层级的结构,递归是最好的方式了。每次递归时,都把之前拼接好的 key 传递下去。

1.1 使用全局变量 #

最开始我想到的办法是使用全局变量 result,当值为基本类型时,说明递归结束,然后将 key 和最终的值,推送到 result 中。递归全部结束后,则返回这个 result。

这里还要注意的是 key 的拼接:

  1. .不能在最前面;
  2. 若当前的类型为数组,则使用中括号[]拼接;
  3. 若当前的类型是 object,则使用点.拼接;

代码如下:

const flat = (param) => {
  if (!param || typeof param !== 'object') {
    return param;
  }
  const isArray = Array.isArray(param);
  const result = isArray ? [] : {}; // 设置全局变量

  const find = (param, parentKey = '') => {
    for (let key in param) {
      // 判断param的类型
      const isArray = Array.isArray(param);
      // 根据类型来拼接key
      const curKey = isArray ? `${parentKey}[${key}]` : `${parentKey}${parentKey === '' ? '' : '.'}${key}`;

      const item = param[key];
      if (item && typeof item === 'object') {
        // 若值为array或object,则继续递归
        find(item, curKey);
      } else {
        // 若为普通类型,本次递归结束,推送数据
        result[curKey] = item;
      }
    }
  };
  find(param);

  return result;
};

我们在循环的时候,并没有区分 param 具体是什么类型,全部都用的for-in进行循环。

1.2 键值合并 #

还有一种不使用全局变量的方式,这里将递归后返回的结构与当前结构进行合并。

const flat = (param, parentKey = '') => {
  if (!param || typeof param !== 'object') {
    // 达到最深的一层
    result[parentKey] = param;
    return;
  }

  const isArray = Array.isArray(param);
  result = isArray ? [] : {}; // 每次的递归都重新生成一个result

  for (let key in param) {
    const curKey = isArray ? `${parentKey}[${key}]` : `${parentKey}${parentKey === '' ? '' : '.'}${key}`;
    const item = param[key];

    if (item && typeof item === 'object') {
      // 将当前的result与递归后返回的数据进行合并
      result = { ...result, ...flat(item, curKey) };
    } else {
      result[curKey] = item;
    }
  }
  return result;
};

调用:

console.log(flat(obj));

您可以查看样例obj 类型的深层合并与展开,并实际修改数据查看效果。

js中的Object中的key

2. 展开所有的 key #

这里我们进行反向的转换,将所有已合并的 key,拆解成展开的结构。

我之前用了一套很麻烦的解法才实现这个功能,不过在写完第 3 节的查找功能后,又有了新的灵感,这里重新实现下。

对于每个单独的 key 的操作:

  1. 拆分字符串 key,如d[0].e这种,先解析成d.[0].e,然后用split('.')把一个字符串 key 拆分到数组 keyArr 中;
  2. 循环这个 keyArr(只循环到倒数第 2 项),对数组中的每一项 item 进行处理;这里额外用一个临时变量(如 tempObj)来进行深层次的创建;
  3. 当前 tempObj[item] 是什么类型,需要通过下一个 item 才能知晓,如下一个 item 是[\d+]这种,表示当前是数组类型,否则是 object 类型;若该 tempObj[item] 对应的类型不存在,则创建;
  4. 进入到下一个循环之前,tempObj = tempObj[item];
  5. 达到数组 keyArr 的最后一项时,将 key 对应的那个 value 给到这一项;

具体实现如下:

const parse = (param) => {
  if (typeof param !== 'object') {
    return param;
  }
  const result = Array.isArray(param) ? [] : {};

  /**
   * 获取这个key,因数组的key是用中括号[1]包括的,这里我们要获取中间的数字
   * 若是纯英文字符串,则直接返回
   * */
  const getCurKey = (curKey) => {
    return curKey.startsWith('[') ? curKey.match(/\d+/)[0] : curKey;
  };

  /**
   * @param {string} key
   * @param {any} val
   * */
  const setKey = (key, val) => {
    const keyArr = key
      .replace(/\[(\d+)\]/g, ($1, $2) => {
        if ($1) {
          return `.[${$2}]`;
        }
        return $1;
      })
      .split('.');
    const { length } = keyArr;
    let i = 0;
    let tempObj = result;

    // 当前key是什么类型,还要依赖下一个key才能判断,
    // 如d.[0],说明d是一个数组;
    // 若想是d.e这种结构,说明d是一个object;
    while (i < length - 1) {
      const isArray = keyArr[i + 1].startsWith('['); // 若下一个key是以`[`开头的,我们认为当前key是一个数组
      const item = getCurKey(keyArr[i]);
      if (!tempObj[item]) {
        // 若这个key还没创建
        obj[item] = isArray ? [] : {};
      }
      tempObj = tempObj[item];
      i++;
    }

    // 最后的这个key不是用来创建结构的,仅用来赋值操作
    // 如d[1] = 1, d['e'] = 2等
    tempObj[getCurKey(keyArr[length - 1])] = val;
  };

  for (let key in param) {
    setKey(key, param[key]);
  }
  return result;
};

调用:

console.log(parse(obj2));

您可以查看样例 obj 类型的深层合并与展开,并实际修改数据查看效果。

我在上面的代码中,很多地方都用到了对象引用的特性,即对于数组和 object 类型这两种数据结构而言,当多个变量指向同一个地址时,改变其中变量的值,其他变量的值也会同步更新。

const result = Array.isArray(param) ? [] : {};
let tempObj = result;
while (i < length) {
  tempObj = tempObj[item];
}
tempObj[getCurKey(keyArr[length - 1])] = val;

如上面 ↑ 这段代码,变量 result 要么是数组类型,要么是 Object 类型,同时变量 tempObj 又指向到了 result,即变量 tempObj 和变量 result 都指向到了同一个内存,那么改变变量 tempObj 中的数值时,变量 result 中的值也会同步修改。变量 tempObj 随着 while 循环一步步递进,就把嵌套的每一层数组都关联起来了,在给最内层数组赋值时,其实就相当于在操作变量 result。

目前我是这样实现的,不过总感觉还有更好的解法,应该有可以不依赖对象引用的特性的解法,但还没想出来。

3. 查找字符串 key 对应的值 #

大致的意思,对一个 obj 类型的数据,如何获取字符串 key 对应的值。如给到一个变量 obj 和一个字符串 key:

const obj = {
  a: {
    b: {
      d: ['d1', 'd2', 'd3'],
    },
    c: {
      e: 'e1',
    },
  },
  f: [{ g: 'g1' }, [1, 2, 3]],
  h: null,
};
const key = 'a.b.d[1]';

这里循环和递归两种方式都可以实现,不过无论是循环还是递归,都是要把 key 拆解开的。但查找操作要比上面第 2 节中的创建操作简单的多,只要遇到不存在的 key,直接返回即可。

在字符串 key 中,a.b.d[1]这种的数组下标,其实我们直接转成a.b.d.1这样更方便一些。无论是纯字符串还是数字,对 object 和数组都可以进行查找,只是找到找不到的问题罢了。而且转换之后,格式还统一了,统一处理即可。

3.1 采用 reduce() 的方式 #

很多同学喜欢用 reduce()方法,可以提高下逼格,虽然返回的结果没问题,但 reduce()是无法终止循环的,即使中间某个属性为空了,还得把所有的属性都遍历完才能结束,如:

const findByReduce = (obj, key) => {
  if (!obj || typeof obj !== 'object') {
    return null;
  }

  return key
    .replace(/\[(\d+)\]/g, ($1, $2) => {
      if ($1) {
        // 将d[1]转为d.1格式
        return `.${$2}`;
      }
      return $1;
    })
    .split('.')
    .reduce((prev, curKey) => {
      if (prev && prev[curKey]) {
        return prev[curKey];
      }
      return null;
    }, obj);
};

调用 findByReduce()函数:

findByReduce(obj, 'a.b.d[2]'); // d3
findByReduce(obj, 'z.b.d[2]'); // null

在查找字符串z.b.d[2]中,第一个属性 z 就已经是 null 了,但我们还是要遍历完,才会有最终的返回。

3.2 普通循环方式 #

基于上面 reduce()方法无法中断的问题,这里我们可以改为 for 或 while 等循环。

const findByLoop = (obj, key) => {
  if (!obj || typeof obj !== 'object') {
    return null;
  }

  const arr = key
    .replace(/\[(\d+)\]/g, ($1, $2) => {
      if ($1) {
        return `.${$2}`;
      }
      return $1;
    })
    .split('.');

  for (let i = 0; i < arr.length; i++) {
    if (obj[arr[i]]) {
      // 若存在该key,则向内查找
      obj = obj[arr[i]];
    } else {
      // 不存在该key,直接返回null
      return null;
    }
  }
  // 循环完毕,返回最后一个值即可
  return obj;
};

调用方式与上面的一样:

find(obj, 'a.b.d[1]'); // d2

3.3 递归的方式 #

如果我们经历了第 1 节和第 2 节的洗礼,这里用递归的方式就简单很多了。

const findByDeep = (obj, key, curIndex = 0) => {
  if (!obj || typeof obj !== 'object') {
    return null;
  }

  const arr = key
    .replace(/\[(\d+)\]/g, ($1, $2) => {
      if ($1) {
        return `.${$2}`;
      }
      return $1;
    })
    .split('.');

  if (curIndex === arr.length - 1) {
    // 达到最后一个属性,返回该值
    return obj[arr[curIndex]];
  }

  if (obj[arr[curIndex]]) {
    return findByDeep(obj[arr[curIndex]], key, curIndex + 1);
  }
  return null;
};

调用:

findByDeep(obj, 'f[1][0]'); // 1

查找字符串 key 对应的值

4. 总结 #

这是一道很好的题目,涉及到的点很多,就我个人而言,一时半会儿没想出来解法。

标签:javascript
阅读(1094)
Simple Empty
No data