news 2026/9/30 6:41:13

冴羽 JavaScript 专题:数组扁平化从递归手写到 underscore 源码解读

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
冴羽 JavaScript 专题:数组扁平化从递归手写到 underscore 源码解读
  • 技术博客
  • 文档
  • 教程

【免费下载链接】Blog

冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。

项目地址:https://gitcode.com/GitHub_Trending/blo/Blog
点击查看免费下载

本篇是冴羽「JavaScript 专题系列」中关于数组扁平化的完整技术指南,围绕 articles/专题系列文章/JavaScript专题之数组扁平化.md 展开。文章从扁平化的基本定义出发,依次讲解递归、toString、reduce、扩展运算符四种手写实现,并深入剖析 underscore 内部flatten函数的shallow、strict、output三个参数设计,最终带你读懂_.flatten、_.union、_.difference是如何复用同一个底层函数的。读完本文,你将能够独立写出适用于任意嵌套层数的扁平化工具函数,并理解 underscore 中"一个内部函数服务多个 API"的抽象思路。

什么是数组扁平化

数组的扁平化(flatten),就是将一个嵌套多层的数组 array(嵌套可以是任何层数)转换为只有一层的数组。

举个例子,假设有个名为flatten的函数可以做到数组扁平化,效果就会如下:

var arr = [1, [2, [3, 4]]]; console.log(flatten(arr)) // [1, 2, 3, 4]

知道了效果是什么样的了,我们就可以尝试着去写这个flatten函数了。下面按实现思路的演进,给出四种常见写法,每种写法都有其适用场景与局限性。

方法一:递归实现

我们最一开始能想到的莫过于循环数组元素,如果遍历到的元素还是一个数组,就递归调用该方法:

// 方法 1 var arr = [1, [2, [3, 4]]]; function flatten(arr) { var result = []; for (var i = 0, len = arr.length; i < len; i++) { if (Array.isArray(arr[i])) { result = result.concat(flatten(arr[i])) } else { result.push(arr[i]) } } return result; } console.log(flatten(arr))

实现要点:

  • 用Array.isArray判断当前元素是否是数组,这是比instanceof更可靠的判断方式(跨 iframe 环境下依然准确);
  • 是数组则递归调用flatten,并把返回结果concat到结果数组中;
  • 不是数组则直接push进结果数组;
  • 每一层递归都会创建新的result数组,最终逐层合并返回。

递归写法逻辑最直观、适用范围最广(不依赖元素类型),是后续所有写法的思想基础。

方法二:toString + split(仅限数字数组)

如果数组的元素都是数字,那么我们可以考虑使用toString方法,因为:

[1, [2, [3, 4]]].toString() // "1,2,3,4"

调用toString方法,返回了一个逗号分隔的扁平的字符串,这时候我们再split,然后转成数字不就可以实现扁平化了吗?

// 方法2 var arr = [1, [2, [3, 4]]]; function flatten(arr) { return arr.toString().split(',').map(function(item){ return +item }) } console.log(flatten(arr))

然而这种方法使用的场景却非常有限:如果数组是[1, '1', 2, '2']的话,这种方法就会产生错误的结果。原因在于:

  1. toString会把字符串元素'1'原样保留(不带引号),split后无法区分原始的数字1和字符串'1';
  2. 更严重的是,如果元素本身就是包含逗号的字符串(如'a,b'),split会把它拆成多个元素;
  3. +item一元运算符会把'1'强转回数字1,丢失类型信息;
  4. 对于null、undefined、对象等元素,结果同样不可控。

所以toString方案只适合"已知数组内全部为数字"的特定场景,一般不建议在生产代码中使用。

方法三:reduce 实现

既然是对数组进行处理,最终返回一个值,我们就可以考虑使用reduce来简化代码:

// 方法3 var arr = [1, [2, [3, 4]]]; function flatten(arr) { return arr.reduce(function(prev, next){ return prev.concat(Array.isArray(next) ? flatten(next) : next) }, []) } console.log(flatten(arr))

实现要点:

  • 以空数组[]作为reduce的初始值(即prev);
  • 遍历每个元素next:如果是数组,递归调用flatten(next)后concat;否则直接concat该元素;
  • 相比方法一,省去了显式的for循环和push,代码更简洁。

这个写法在本文关联的系列文章《JavaScript专题之递归》中也被作为"利用递归解决扁平化问题"的典型示例再次提及,可见它是递归思想的经典落地。

方法四:扩展运算符 ...

ES6 增加了扩展运算符(spread operator),用于取出参数对象的所有可遍历属性,拷贝到当前对象之中:

var arr = [1, [2, [3, 4]]]; console.log([].concat(...arr)); // [1, 2, [3, 4]]

注意看输出结果:[].concat(...arr)把arr的第一层元素展开后依次concat,因此只扁平了一层——[3, 4]依然保持着内层数组的形态。

我们用这种方法只可以扁平一层,但是顺着这个方法一直思考,我们可以写出这样的方法:

// 方法4 var arr = [1, [2, [3, 4]]]; function flatten(arr) { while (arr.some(item => Array.isArray(item))) { arr = [].concat(...arr); } return arr; } console.log(flatten(arr))

实现要点:

  • 用Array.prototype.some检测数组中是否还存在数组元素;
  • 只要存在,就用[].concat(...arr)展开一层并重新赋值给arr;
  • 循环直到数组中不再有任何数组元素为止,天然支持任意嵌套层数;
  • 全程原地迭代、无需递归,代码量最小,是手写扁平化中最推荐的一版。
// 等价于逐层展开的过程: // [1, [2, [3, 4]]] -> [1, 2, [3, 4]] -> [1, 2, 3, 4]

underscore 的内部 flatten 函数

那么如何写一个抽象的扁平函数,来方便我们的开发呢?又到了"研究(借鉴)underscore"的时候了。

这里直接给出带注释的源码。要注意:这里的flatten函数并不是最终的_.flatten,为了方便多个 API 进行调用,它对外暴露了更多配置项:

/** * 数组扁平化 * @param {Array} input 要处理的数组 * @param {boolean} shallow 是否只扁平一层 * @param {boolean} strict 是否严格处理元素,下面有解释 * @param {Array} output 这是为了方便递归而传递的参数 */ function flatten(input, shallow, strict, output) { // 递归使用的时候会用到output output = output || []; var idx = output.length; for (var i = 0, len = input.length; i < len; i++) { var value = input[i]; // 如果是数组,就进行处理 if (Array.isArray(value)) { // 如果是只扁平一层,遍历该数组,依此填入 output if (shallow) { var j = 0, len = value.length; while (j < len) output[idx++] = value[j++]; } // 如果是全部扁平就递归,传入已经处理的 output,递归中接着处理 output else { flatten(value, shallow, strict, output); idx = output.length; } } // 不是数组,根据 strict 的值判断是跳过不处理还是放入 output else if (!strict){ output[idx++] = value; } } return output; }

三个关键参数:shallow / strict / output

  • input:要处理的数组(可能是嵌套数组,也可能是arguments之类类数组);
  • shallow:布尔值,true表示只扁平一层,false表示递归扁平所有层;
  • strict:布尔值,true表示严格模式——跳过所有非数组元素,false表示把非数组元素正常放入结果;
  • output:递归时复用的结果数组。递归调用时把已经处理好的output传下去,内层递归直接向同一个数组追加元素,避免每次递归都新建数组、再逐层合并(这是与前面方法一"每层新建result再concat"的关键区别,性能更好)。

strict 到底有什么用

解释下strict:在代码里我们可以看出,当遍历数组元素时,如果元素不是数组,就会对strict取反的结果进行判断,如果设置strict为true,就会跳过不进行任何处理,这意味着可以过滤非数组的元素。举个例子:

var arr = [1, 2, [3, 4]]; console.log(flatten(arr, true, true)); // [3, 4]

那么设置strict到底有什么用呢?我们先看下shallow和strict各种值对应的结果:

shallowstrict行为示例([1, 2, [3, 4]])
truefalse正常扁平一层[1, 2, 3, 4]
falsefalse正常扁平所有层[1, 2, 3, 4]
truetrue去掉非数组元素[3, 4]
falsetrue返回一个[][]

最后一行的false + true组合值得注意:因为严格模式下非数组元素被全部跳过,而递归扁平所有层又意味着所有数组最终都会被拆成单个元素(单个元素不是数组,同样被跳过),所以结果恒为空数组[]。这个组合在 underscore 中不会被直接调用,但理解它可以加深对参数语义的把握。

下面我们看看 underscore 中哪些方法调用了flatten这个基本函数。

_.flatten

首先就是_.flatten:

_.flatten = function(array, shallow) { return flatten(array, shallow, false); };

在正常的扁平中,我们并不需要去掉非数组元素,所以strict固定传false;第二个参数shallow由调用方决定是"扁平一层"还是"扁平所有层"(默认undefined即递归全部扁平)。

_.union

接下来是_.union:该函数传入多个数组,然后返回传入的数组的并集(去重后的合集)。

举个例子:

_.union([1, 2, 3], [101, 2, 1, 10], [2, 1]); => [1, 2, 3, 101, 10]

如果传入的参数并不是数组,就会将该参数跳过:

_.union([1, 2, 3], [101, 2, 1, 10], 4, 5); => [1, 2, 3, 101, 10]

为了实现这个效果,我们可以将传入的所有数组扁平化,然后去重。因为_.union只接受数组作为有效参数,这时候我们直接设置strict为true,就可以跳过传入的非数组元素(如上例中的4和5)。

// 关于 unique 可以查看《JavaScript专题之数组去重》 function unique(array) { return Array.from(new Set(array)); } _.union = function() { return unique(flatten(arguments, true, true)); }

这里的flatten(arguments, true, true)语义是:把arguments视为一个数组(其中每个元素都是传入的数组),shallow = true只扁平一层(即把"数组的数组"变成"元素的数组"),strict = true过滤掉非数组的参数,最终交给unique去重。去重的详细实现可见系列文章《JavaScript专题之数组去重》。

_.difference

是不是感觉折腾strict有点用处了,我们再看一个_.difference:

语法为:

_.difference(array, *others)

效果是取出来自array数组,并且不存在于多个other数组的元素。跟_.union一样,都会排除掉不是数组的元素。

举个例子:

_.difference([1, 2, 3, 4, 5], [5, 2, 10], [4], 3); => [1, 3]

注意上例中array为[1, 2, 3, 4, 5],其余参数中3不是数组,被忽略;5、2、4分别出现在others数组中,被排除;最终只剩1和3。

实现方法也很简单,扁平others的数组,筛选出array中不在扁平化数组中的值:

function difference(array, ...rest) { rest = flatten(rest, true, true); return array.filter(function(item){ return rest.indexOf(item) === -1; }) }

这里的flatten(rest, true, true)与_.union中的用法一致:只扁平一层 + 严格模式过滤非数组参数。得到扁平的rest后,用array.filter配合indexOf剔除存在于rest中的元素即可。

仓库源码佐证:underscore 本地副本中的真实实现

本仓库的 demos/debounce/underscore.js 内置了一份 underscore 源码副本,其中包含真实的内部flatten实现,可以作为上述讲解的源码级印证:

  • 内部flatten函数定义位于 demos/debounce/underscore.js#L489-L507,注释明确写着 "Internal implementation of a recursiveflattenfunction";
  • _.flatten的定义位于 demos/debounce/underscore.js#L510-L512,与上文展示的flatten(array, shallow, false)完全一致;
  • _.union位于 demos/debounce/underscore.js#L551-L553,实现为_.uniq(flatten(arguments, true, true)),印证了"扁平一层 + 严格过滤 + 去重"的组合;
  • _.difference位于 demos/debounce/underscore.js#L573-L578,实现为flatten(arguments, true, true, 1)。

从源码结构看,还有两点值得注意:

  1. 真实副本的签名略有不同:仓库副本中的内部函数签名是flatten(input, shallow, strict, startIndex),用startIndex控制从第几个参数开始处理,而本文讲解的教学版本用output传递递归结果。两者思路等价,教学版本更容易理解递归时如何共享结果数组。这也说明"以上实现的细节并不是完全按照 underscore",真实细节以源码为准。

  2. flatten不止服务三个 API:在 demos/debounce/underscore.js#L1030(_.pick)和 demos/debounce/underscore.js#L1047(_.omit)中,同样以flatten(arguments, false, false, 1)的形式被调用,用来把多个键名参数展平成数组。这说明 underscore 把flatten设计成一个高度抽象的底层工具,多个公开 API 通过不同的shallow/strict/startIndex组合复用它——这正是"抽象扁平函数"的价值所在。

小结

回顾本篇,我们从"把嵌套数组变成一层数组"这一需求出发,走完了从朴素实现到源码级抽象的全过程:

  • 四种手写实现:递归(通用、直观)、toString(仅限纯数字数组、不推荐)、reduce(更简洁的递归)、while + some + 扩展运算符(非递归、任意层数、推荐);
  • underscore 的内部flatten:用shallow控制扁平深度、用strict控制是否过滤非数组元素、用output实现递归共享结果数组,四种参数组合的行为各不相同;
  • 三个复用场景:_.flatten(strict = false)、_.union(true, true+ 去重)、_.difference(true, true+ filter),以及_.pick/_.omit中的键名展平。

掌握flatten的内部参数设计,本质上就掌握了 underscore 中"一个内部函数通过参数组合服务多个 API"的抽象方法论,这对阅读其他工具库源码、设计自己的工具函数都很有帮助。本文所属的 JavaScript 专题系列还包含防抖、节流、去重、类型判断、深浅拷贝、柯里化、递归、乱序、排序等主题,全部文章位于 articles/专题系列文章 目录下,可以配合阅读。

  • 技术博客
  • 文档
  • 教程

【免费下载链接】Blog

冴羽写博客的地方,预计写四个系列:JavaScript深入系列、JavaScript专题系列、ES6系列、React系列。

项目地址:https://gitcode.com/GitHub_Trending/blo/Blog
点击查看免费下载
上一篇:Photoshop-CC2022-Linux替代方案:5款Linux图像编辑软件对比推荐
下一篇:osquery社区贡献终极指南:如何参与开源项目开发

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:39:58

FPGA跨时钟域设计:亚稳态原理、两级同步器与异步FIFO实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:38:09

手把手在Switch大气层装好wiliwili:4步跑起来的B站客户端

手把手在Switch大气层装好wiliwili&#xff1a;4步跑起来的B站客户端 【免费下载链接】wiliwili 第三方B站客户端&#xff0c;目前可以运行在PC全平台、PSVita、PS4 、Xbox 和 Nintendo Switch上 项目地址: https://gitcode.com/GitHub_Trending/wi/wiliwili Switch大气…

作者头像 李华
网站建设 2026/9/30 6:37:57

Windows下VT控制权争夺:Hyper-V与安卓模拟器冲突真相

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华