es-toolkit 的 Lodash 兼容版flatten:单层扁平化的行为细节与实现原理
【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit
本文围绕 es-toolkit 的 Lodash 兼容模块es-toolkit/compat中的flatten函数展开,说明它与核心es-toolkit版本flatten的差异、完整的参数语义(ArrayLike、Arguments、Symbol.isConcatSpreadable、null/undefined等),并结合 src/compat/array/flatten.ts、src/compat/array/flattenDepth.ts 与 src/compat/array/flatten.spec.ts 的源码与测试,讲透兼容版flatten为什么"慢一点"、在什么场景下值得使用,以及如何平滑迁移到核心 API。
先看结论:为什么文档建议优先使用es-toolkit的flatten
es-toolkit/compat是为了与 Lodash 接口、行为 1:1 对齐而存在的兼容层(见 docs/compat/intro.md)。官方文档在flatten的日语参考页(即 docs/ja/compat/reference/array/flatten.md)开头给出了明确警告:
请使用
es-toolkit的flatten。compat 版本的flatten因为要处理null/undefined、ArrayLike类型等,运行会更慢。
也就是说:
- 核心版(
es-toolkit/array的flatten):只接受真正的数组(readonly T[]),行为等价于 JavaScript 原生的Array#flat,但更快; - 兼容版(
es-toolkit/compat的flatten):为对齐 Lodash,额外支持Arguments对象、任意ArrayLike、Symbol.isConcatSpreadable以及null/undefined输入,因此多了一层类型判断逻辑,性能略低、包体略大。
两者共享同一个思想:把嵌套数组展开一层(depth = 1)。兼容版flatten的实现只有一行,本质上是把工作委托给了flattenDepth:
// src/compat/array/flatten.ts export function flatten<T>(array: ArrayLike<T | readonly T[]> | null | undefined): T[] { return flattenDepth(array as ListOfRecursiveArraysOrValues<T> | null | undefined, 1); }兼容版flatten的完整行为规范
基本用法:单层扁平化
flatten只展开一层嵌套,更深层的数组保持原样:
import { flatten } from 'es-toolkit/compat'; // 基本的扁平化(1 层) flatten([1, [2, [3, [4]], 5]]); // 结果: [1, 2, [3, [4]], 5][3, [4]]这一层只被解开一次,内层的[4]不会被继续展开。
支持Arguments对象
与 Lodash 一致,兼容版flatten可以把函数内部的arguments对象当作数组处理:
import { flatten } from 'es-toolkit/compat'; function example() { return flatten(arguments); } example(1, [2, 3], [[4]]); // 结果: [1, 2, 3, [4]]支持带Symbol.isConcatSpreadable的对象
JavaScript 的Array.prototype.concat会检查对象的Symbol.isConcatSpreadable属性来决定是否将其展开;Lodash 的flatten也复用了这一约定,兼容版同样支持:
import { flatten } from 'es-toolkit/compat'; const spreadable = { 0: 'a', 1: 'b', length: 2, [Symbol.isConcatSpreadable]: true }; flatten([1, spreadable, 3]); // 结果: [1, 'a', 'b', 3]null与undefined视为空数组
这是 Lodash 兼容行为中最常见的一个差异点:传入null或undefined不会抛错,而是返回空数组。
import { flatten } from 'es-toolkit/compat'; flatten(null); // [] flatten(undefined); // [] flatten([]); // []参数与返回值
array(ArrayLike<T> | null | undefined):需要扁平化的数组(或类数组对象)。- 返回值(
T[]):扁平化后的新数组,不会修改原数组。
注意返回值类型是T[]:由于只展平一层,元素类型在类型层面不会像flattenDeep那样被递归推导。
源码级原理:flattenDepth与isFlattenable
兼容版flatten内部委托给 src/compat/array/flattenDepth.ts,其中depth = 1。核心逻辑如下:
// src/compat/array/flattenDepth.ts(节选) export function flattenDepth<T>(array: ListOfRecursiveArraysOrValues<T> | null | undefined, depth = 1): T[] { if (!isArrayLike(array)) { return []; } const result: T[] = []; const flooredDepth = Math.floor(depth); const recursive = (arr: readonly T[], currentDepth: number) => { for (let i = 0; i < arr.length; i++) { const item = arr[i]; if (isFlattenable(item) && currentDepth < flooredDepth) { recursive(item as T[], currentDepth + 1); } else { result.push(item); } } }; recursive(Array.from(array) as T[], 0); return result; } function isFlattenable(value: unknown): boolean { return isArray(value) || isArguments(value) || Boolean(value && (value as any)[Symbol.isConcatSpreadable]); }由此可以归纳出兼容版flatten的几个关键实现事实:
- 入口先做
isArrayLike检查:非类数组对象(如{ 0: 'a' })直接返回[]。这是flatten(null)、flatten(undefined)返回[]的根源。 - "可扁平化"判定有三条:
isArray(真数组)、isArguments(arguments对象)、或对象上存在真值的Symbol.isConcatSpreadable属性。这也解释了文档示例中spreadable对象能被展开的原因。 - 深度受
Math.floor控制:depth会被向下取整,flatten传1即只递归一层。 - 稀疏数组会被"压实":测试 src/compat/array/flatten.spec.ts 验证了
flatten([[1, 2, 3], Array(3)])的结果是[1, 2, 3, undefined, undefined, undefined],即空洞位置会被补成undefined(这也是 Lodash 行为之一)。
isArrayLikeObject的逐项拷贝逻辑还可参考 src/compat/_internal/flattenArrayLike.ts,它展示了类数组元素如何被逐个push进结果数组。
与核心版flatten的对比
核心 API 的实现在 src/array/flatten.ts,两者差异集中体现在"接受什么输入、怎么判定嵌套元素"上:
| 维度 | 核心版es-toolkit/array | 兼容版es-toolkit/compat |
|---|---|---|
| 签名 | flatten<T, D>(arr: readonly T[], depth = 1) | flatten<T>(array: ArrayLike<T \| readonly T[]> \| null \| undefined) |
| 嵌套元素判定 | 仅Array.isArray(item) | isArray/isArguments/Symbol.isConcatSpreadable三者其一 |
是否接受null/undefined | 不接受(类型上不合法) | 接受,返回[] |
是否接受类数组 /arguments | 不接受 | 接受 |
| 深度参数 | 支持,泛型D会反映到返回类型Array<FlatArray<T[], D>> | 固定为 1(flatten层面),类型为T[] |
| 性能 | 判定路径最短,更快 | 多类型判断,略慢、包体略大 |
因此文档的建议非常明确:如果你的代码不依赖 Lodash 的null/undefined、arguments、类数组等兼容语义,直接用核心版:
import { flatten } from 'es-toolkit/array'; flatten([1, [2, 3], [4, [5, 6]]]); // [1, 2, 3, 4, [5, 6]] flatten([1, [2, 3], [4, [5, 6]]], 2); // [1, 2, 3, 4, 5, 6]核心版的行为与原生Array#flat一致,但由于内部用显式循环而非concat展开,实际运行更快。
迁移路径:从兼容版到核心版
按照 docs/compat/intro.md 推荐的迁移流程:
- 第一步:把
lodash/lodash-es的导入直接换成es-toolkit/compat,调用点一行不改,行为保持一致:import { flatten } from 'es-toolkit/compat'; - 第二步:逐步清理调用点,确认代码中没有依赖
arguments、类数组、null/undefined等 Lodash 特殊语义后,改用核心 API:import { flatten } from 'es-toolkit/array';换来的是更小的打包体积和更快的运行速度。
如果你确实需要"展平任意深度"的兼容行为,兼容层还提供flattenDeep与flattenDepth(见 src/compat/array/flattenDeep.ts,内部以Infinity为深度调用flattenDepth),可按需选用。
测试佐证:行为由测试用例锁定
src/compat/array/flatten.spec.ts 用 Vitest 覆盖了以下行为,可作为兼容性契约的参考:
- 扁平化
arguments对象:flatten([args, [args]])得到[1, 2, 3, args]; - 稀疏数组按稠密数组处理(空洞补
undefined); - 带真值
Symbol.isConcatSpreadable的对象被展开; - 空数组嵌套(
[[], [[]], [[], [[[]]]]])只展平一层; - 多层嵌套数组只展平一层;
- 非类数组对象(
{ 0: 'a' })返回[]; - 类数组支持:
flatten({ 0: [1, 2, 3], length: 1 })、flatten('123')、flatten(args)均按预期展开。
这些测试同时兼容 Lodash 自身的测试套件,是es-toolkit/compat实现 100% 兼容目标的直接证据。
小结
- 兼容版
flatten以1 层深度展开嵌套数组,完整支持Arguments、Symbol.isConcatSpreadable、类数组对象,并将null/undefined视为空数组; - 它内部委托给
flattenDepth,通过isFlattenable(isArray/isArguments/isConcatSpreadable三选一)判定可展开元素; - 这些额外语义带来了可感知的性能与包体开销,因此官方文档明确建议:新代码优先使用
es-toolkit/array的核心版flatten;只有迁移 Lodash 存量代码、且依赖其特殊行为时,才使用es-toolkit/compat版本,并在迁移完成后逐步切换回核心 API。
【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考