面试题:数组扁平化怎么实现?

67 阅读9分钟

前端必会!数组扁平化的 N 种玩法

在前端开发中,我们经常会遇到多层嵌套的数组,比如后台返回的 [1, [2, [3, 4], 5]],直接处理这种 “套娃” 数据简直让人头大。这时候就需要数组扁平化来救场 —— 简单说,就是把多维数组 “拍平” 成一维数组,比如把刚才的例子变成 [1, 2, 3, 4, 5]。不管是处理表格数据、筛选列表,还是格式化接口返回值,数组扁平化都是必备技能。今天就带大家解锁实现数组扁平化的 5 种常用方法,从基础到进阶,手把手教你搞定 “套娃” 数组~

一、数组扁平化是什么?

先给新手朋友补个基础:数组扁平化就是将多层嵌套的数组转换为一维数组的过程。

举个直观的例子:

  • 原始多维数组:[1, [2, [3, [4]]], 5]

  • 扁平化后数组:[1, 2, 3, 4, 5]

它的核心作用是简化数据结构 —— 比如后台返回的树形结构数据,我们需要提取所有叶子节点时,扁平化就能帮我们快速把嵌套数据 “捋直”,避免写多层循环嵌套啦~

二、方法一:递归大法(最易理解版)

递归应该是大家接触最早的 “降维” 思路了,核心逻辑很简单:遇到数组就 “钻进去”,遇到普通元素就 “捞出来”,像剥洋葱一样一层一层处理。

(一)实现步骤

  1. 初始化一个空数组 res,用来存放最终的一维数组;

  2. 遍历需要扁平化的原数组 arr

  3. 对每个元素做判断:

    • 如果不是数组(比如数字、字符串),直接用 push 加入 res
    • 如果是数组,就递归调用自己(把当前子数组传进去),再用 concat 把返回的结果拼接到 res 里;
  4. 遍历结束后,返回 res

(二)代码实现

// 定义扁平化函数,接收需要处理的数组arr
const flatten = (arr) => {
  // 初始化空数组,用于存储最终的一维结果
  let res = [];
  // 遍历原数组的每一个元素
  for (let item of arr) {
    // 判断当前元素是否为数组(Array.isArray是ES5新增的数组判断方法,比typeof更准确)
    if (Array.isArray(item)) {
      // 若是数组,递归调用flatten处理,再把结果拼接到res中
      res = res.concat(flatten(item));
    } else {
      // 若不是数组,直接把元素加入res
      res.push(item);
    }
  }
  // 返回最终的一维数组
  return res;
};

// 测试一下:处理3层嵌套数组
console.log(flatten([1, [2, [3, 4], 5]])); // 输出 [1,2,3,4,5],完美~

(三)优缺点分析

  • 优点:逻辑直白,几乎不用想就能看懂,新手友好,处理任意嵌套深度都没问题;
  • 缺点:如果数组嵌套特别深(比如嵌套 1000 层),会触发浏览器的 “栈溢出” 错误(递归依赖调用栈,栈的容量有限),而且性能比非递归方法略差。

三、方法二:reduce 魔法(代码精简版)

如果你觉得递归的 for 循环不够 “优雅”,那 reduce 绝对是你的菜!reduce 本身就是用来 “合并” 数组的方法,刚好能用来做扁平化,一行代码就能搞定~

(一)reduce 方法简介

先快速回顾 reduce 的基础:它接收一个回调函数和初始值,遍历数组时会把 “上一次的结果” 和 “当前元素” 传给回调,最终返回一个合并后的值。比如用 reduce 求和:

[1,2,3].reduce((acc, cur) => acc + cur, 0); // 输出 6

这里的 acc 是 “累加器”(上一次的结果),cur 是 “当前元素”,0 是初始值。

(二)实现思路

把扁平化的逻辑融入 reduce 的回调:

  • 初始值设为 [](空数组,也就是最终要返回的一维数组);

  • 对每个元素 cur 判断:

    • 若是数组,就递归调用 flatten 处理,再用 concat 拼到 acc 里;
    • 若不是数组,直接把 cur 拼到 acc 里;
  • 每次回调返回更新后的 acc,最终就能得到一维数组。

(三)代码展示

// 用reduce实现,一行代码搞定(也可以拆成多行方便阅读)
const flatten = (arr) => 
  arr.reduce((acc, cur) => {
    // 判断当前元素是否为数组:是则递归处理,否则直接拼接到acc
    return acc.concat(Array.isArray(cur) ? flatten(cur) : cur);
  }, []); // 初始值设为空数组,作为累加器的起点

// 测试:处理混合嵌套的数组
console.log(flatten([1, [2, 'a', [true, [null]]], 3])); 
// 输出 [1,2,"a",true,null,3],连非数字元素都能搞定~

和递归方法比,reduce 版去掉了显式的 for 循环和 res 变量,代码更精简,但核心逻辑其实和递归一致,所以优缺点也基本相同(同样可能栈溢出)。

四、方法三:栈模拟递归(性能优化版)

刚才的两种方法都依赖递归,遇到深层嵌套会有栈溢出风险。那有没有非递归的方法呢?当然有!用 “栈” 这种数据结构就能模拟递归的过程,还能避免栈溢出~

(一)栈的概念引入

栈是一种 “后进先出”(LIFO)的数据结构,就像叠盘子:最后放上去的盘子,要先拿下来。我们可以用栈来存储 “待处理的数组元素”,每次从栈顶取元素处理,直到栈空为止。

(二)实现过程

  1. 把原数组复制一份存入栈(用扩展运算符 [...arr] 避免修改原数组);

  2. 初始化空数组 res 存放结果;

  3. 循环处理栈:

    • 从栈顶弹出一个元素(用 pop(),因为 pop 是 O (1) 操作,比 shift 高效);
    • 如果弹出的元素是数组,就把它的所有元素 “压回” 栈(用 push(...item),展开数组后压栈);
    • 如果不是数组,就把它加入 res
  4. 循环结束后,res 里的元素是 “倒序” 的(因为栈是后进先出),所以最后要 reverse() 反转一下;

  5. 返回反转后的 res

(三)代码实例

const flatten = (arr) => {
  // 1. 初始化栈:复制原数组,避免修改原数组
  const stack = [...arr];
  // 2. 初始化结果数组
  const res = [];
  
  // 3. 循环处理栈,直到栈为空
  while (stack.length > 0) {
    // 从栈顶弹出一个元素(pop()效率高,直接操作数组末尾)
    const item = stack.pop();
    
    if (Array.isArray(item)) {
      // 若是数组,把它的元素展开后压回栈(相当于“拆一层”)
      stack.push(...item);
    } else {
      // 若不是数组,加入结果数组
      res.push(item);
    }
  }
  
  // 4. 反转结果数组(因为栈是后进先出,结果是倒序的)
  return res.reverse();
};

// 测试:处理10层嵌套的数组(递归可能栈溢出,栈方法没问题)
const deepArr = [1, [2, [3, [4, [5, [6, [7, [8, [9, [10]]]]]]]]];
console.log(flatten(deepArr)); // 输出 [1,2,3,4,5,6,7,8,9,10],稳得一批~

(四)优缺点分析

  • 优点:非递归,不会出现栈溢出问题,处理深层嵌套数组更安全;pop 和 push 都是高效操作,性能比递归好;
  • 缺点:需要手动处理栈和反转结果,逻辑比递归稍复杂一点,新手可能要多琢磨两下。

五、方法四:ES6 flat 闪亮登场(官方偷懒版)

如果你用的是现代浏览器或打包工具(比如 Webpack),那根本不用自己写 ——ES6 已经内置了 flat() 方法,专门用来做数组扁平化,简直是 “偷懒神器”!

(一)flat 方法介绍

flat(depth) 接收一个可选参数 depth,表示 “要展开的层数”:

  • 默认值是 1,只展开一层数组(比如 [1, [2, [3]]].flat() 会变成 [1, 2, [3]]);
  • 传 Infinity(无限大)可以展开任意深度的数组;
  • 传负数(比如 -1)不会展开任何层,返回原数组。

(二)使用示例

// 1. 默认展开1层
const arr1 = [1, [2, [3, 4]]];
console.log(arr1.flat()); // 输出 [1, 2, [3, 4]]

// 2. 展开2层
console.log(arr1.flat(2)); // 输出 [1, 2, 3, 4]

// 3. 展开任意深度(推荐用Infinity,不用算嵌套层数)
const deepArr = [1, [2, [3, [4, [5]]]]];
console.log(deepArr.flat(Infinity)); // 输出 [1, 2, 3, 4, 5]

// 4. 处理空元素(小知识点:flat()会自动过滤数组中的空元素!)
const arrWithEmpty = [1, [], [2, [3], null]];
console.log(arrWithEmpty.flat(Infinity)); // 输出 [1, 2, 3, null](空数组[]被过滤了)

(三)兼容性说明

flat() 是 ES2019(ES10)的特性,所以:

  • 现代浏览器(Chrome 69+、Firefox 62+、Safari 12+)都支持;
  • IE 浏览器完全不支持(毕竟 IE 都被放弃了);
  • 如果需要兼容旧浏览器,要么用前面的递归 / 栈方法,要么给数组原型加个 flat 垫片(polyfill)。

六、方法五:some + 扩展运算符(循环拆解版)

还有一种 “暴力拆解” 的思路:用 some() 判断数组里是否还有嵌套数组,如果有,就用扩展运算符 ... 拆一层,循环直到没有嵌套为止。

(一)实现原理

  • some(Array.isArray) 可以快速判断数组中是否存在 “子数组”(只要有一个元素是数组,就返回 true);
  • 扩展运算符 ... 可以把数组 “展开一层”(比如 [1, [2, 3]] 用 ... 展开后是 1, [2, 3]);
  • 用 while 循环:只要数组里还有子数组,就用 concat(...arr) 拆一层,直到 some() 返回 false(没有子数组了)。

(二)代码实现

const flatten = (arr) => {
  // 循环:只要数组中还有子数组,就继续拆解
  while (arr.some(item => Array.isArray(item))) {
    // 用concat + 扩展运算符拆一层:[1, [2, [3]]] → [1, 2, [3]]
    arr = [].concat(...arr);
  }
  return arr;
};

// 测试:处理混合嵌套
console.log(flatten([1, [2, 'a', [true]]])); // 输出 [1, 2, "a", true]

(三)优缺点分析

  • 优点:代码简洁,不用递归,理解起来也不难;
  • 缺点:每次循环只拆一层,嵌套深的话循环次数多,性能不如栈方法;而且会修改原数组(如果不想修改,可以先复制一份,比如 let temp = [...arr]; 再循环处理 temp)。

七、方法大比拼(该选哪种?)

讲了 5 种方法,实际开发中该怎么选?这里整理了一张对比表,帮你快速决策:

方法优点缺点适用场景
递归逻辑简单,易理解深层嵌套会栈溢出,性能一般嵌套浅、数据量小的场景,新手练手
reduce + 递归代码精简,易理解同递归,可能栈溢出同上,追求代码优雅时
栈模拟无栈溢出,性能好逻辑稍复杂嵌套深、数据量大的场景,追求性能
ES6 flat()一行代码,自动过滤空元素兼容性差(不支持 IE)现代浏览器 / 打包项目,快速开发
some + 扩展运算符代码简洁,非递归性能一般,可能修改原数组嵌套不深,追求代码简洁时

八、总结

数组扁平化虽然是个小知识点,但背后藏着不同的编程思路:递归的 “分而治之”、reduce 的 “累加合并”、栈的 “迭代处理”、flat 的 “官方 API”。理解这些方法的原理,不仅能帮你在开发中灵活选型,还能在面试中轻松应对 “手写数组扁平化” 的问题~