Lodash 源码阅读-isFlattenable
概述
isFlattenable
是 Lodash 内部的一个判断函数,用来检查一个值是否可以被"展平"。所谓"展平"就是把嵌套的数组拍扁成一维数组,比如把 [1, [2, 3], 4]
变成 [1, 2, 3, 4]
。这个函数在 Lodash 的 flatten
、flattenDeep
等数组扁平化函数中起着决定性作用 ------ 它决定哪些东西需要被拆开,哪些应该保持原样。
前置学习
依赖函数
- isArray:检查一个值是否为数组
- isArguments:检查一个值是否为函数的 arguments 对象
技术知识
- Symbol.isConcatSpreadable :ES6 引入的一个特殊标记,用来自定义对象在
concat
操作时是否应该被展开 - 短路逻辑 :使用
||
运算符进行多条件判断的技巧 - 类数组对象:长得像数组但不是真正数组的对象(比如 arguments)
源码实现
javascript
function isFlattenable(value) {
return (
isArray(value) ||
isArguments(value) ||
!!(spreadableSymbol && value && value[spreadableSymbol])
);
}
其中 spreadableSymbol
是这样定义的:
javascript
var spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined;
实现思路
isFlattenable
的判断逻辑很直接:
- 是数组就可以展平
- 是 arguments 对象也可以展平
- 如果对象自己设置了
Symbol.isConcatSpreadable
为 true,也可以展平
只要满足以上任意一个条件,函数就返回 true
,表示这个值可以被展平。
源码解析
数组和 arguments 检查
javascript
isArray(value) || isArguments(value);
这段代码检查值是否为数组或 arguments 对象:
- 数组是最常见的可展平类型,用
Array.isArray()
判断 - arguments 是函数内部的一个特殊对象,它不是真正的数组,但也可以被展平
Symbol.isConcatSpreadable 检查
javascript
!!(spreadableSymbol && value && value[spreadableSymbol]);
这段代码检查对象是否标记了自己应该被展平:
- 首先确保环境支持 Symbol 且值不是 null
- 然后看对象是否有
Symbol.isConcatSpreadable
属性为 true !!
把结果转为布尔值
这是 ES6 提供的一种机制,让开发者可以控制自定义对象在 concat
操作中的行为。
Symbol.isConcatSpreadable 的使用场景与展开规则
Symbol.isConcatSpreadable
允许自定义对象控制它们在扁平化过程中的行为。这个特性非常强大,但理解它的工作原理很重要:它不是指定展开哪个特定属性,而是决定是否按照类数组对象的方式展开对象。
扁平化时的展开规则:
当一个对象被标记为可扁平化(Symbol.isConcatSpreadable
为 true
)时,扁平化操作会:
- 按照从
0
到length-1
的数字索引,依次获取对象的属性值 - 仅展开这些数字索引属性,其他属性(如
items
或other
)不会被处理 - 必须有
length
属性来指定要展开的元素数量
具体应用场景如下:
-
类数组对象的自定义扁平化行为
javascriptconst myArrayLike = { 0: "a", // 会被展开 1: "b", // 会被展开 2: "c", // 会被展开 length: 3, other: "不会被展开", items: ["也不会被展开"], [Symbol.isConcatSpreadable]: true, }; // 在设置Symbol.isConcatSpreadable后 _.flatten([1, myArrayLike, 2]); // 结果: [1, "a", "b", "c", 2] // 只有数字索引属性被展平,other和items属性不会被展开
如果没有
length
属性,即使设置了Symbol.isConcatSpreadable
,也不会正确展开:javascriptconst badObj = { 0: "a", 1: "b", // 没有 length 属性 [Symbol.isConcatSpreadable]: true, }; // 由于没有 length 属性,展开无效 _.flatten([1, badObj, 2]); // [1, {0:"a", 1:"b", ...}, 2]
-
阻止数组被扁平化
反过来,我们也可以阻止一个真正的数组被扁平化:
javascriptconst arr = [1, 2, 3]; arr[Symbol.isConcatSpreadable] = false; // 设置Symbol.isConcatSpreadable为false后 _.flatten([0, arr, 4]); // 结果: [0, [1, 2, 3], 4] // 尽管arr是数组,但由于显式设置了不展开,所以保持原样
-
自定义对象集合的扁平化
当我们创建自定义集合类时,需要注意展开的是数字索引属性,不是其他普通属性:
javascriptclass MyCollection { constructor(items) { this.items = items; this.length = items.length; // 这段代码是关键 - 把items的元素复制到当前对象的数字索引属性上 for (let i = 0; i < items.length; i++) { this[i] = items[i]; } } // 控制扁平化行为的getter get [Symbol.isConcatSpreadable]() { return true; // 允许被扁平化 } } const collection = new MyCollection(["x", "y", "z"]); _.flatten([1, collection, 2]); // 结果: [1, "x", "y", "z", 2] // 展开的是collection的0,1,2属性,不是items属性
如果不把元素复制到数字索引上,扁平化会失败:
javascriptclass BadCollection { constructor(items) { this.items = items; // 有items数组 this.length = items.length; // 有正确的length // 但没有把items的元素复制到数字索引上 } get [Symbol.isConcatSpreadable]() { return true; } } const collection = new BadCollection(["x", "y", "z"]); _.flatten([1, collection, 2]); // 结果: [1, undefined, undefined, undefined, 2] // 因为对象有length=3,但0,1,2索引处的值都是undefined
-
条件性扁平化
我们可以基于某些条件动态决定是否扁平化:
javascriptconst specialObject = { 0: "a", 1: "b", length: 2, _shouldFlatten: true, get [Symbol.isConcatSpreadable]() { return this._shouldFlatten; }, }; // 可以动态切换扁平化行为 specialObject._shouldFlatten = true; _.flatten([1, specialObject, 2]); // [1, "a", "b", 2] specialObject._shouldFlatten = false; _.flatten([1, specialObject, 2]); // [1, {0:"a", 1:"b", length:2, ...}, 2]
-
与原生 Array.concat 保持一致
这个行为是为了保持与原生数组
concat
方法的一致性:javascriptconst arr = [1, 2]; const obj = { 0: "a", 1: "b", length: 2, [Symbol.isConcatSpreadable]: true }; // 原生 concat 方法也是按照数字索引展开 arr.concat(obj); // [1, 2, "a", "b"]
总结来说,Symbol.isConcatSpreadable
标记的是"这个对象的行为应该像数组一样",当一个对象被标记为可扁平化时,将按照类数组对象的约定(数字索引 + length)进行展开。这也是为什么在使用这个特性时,通常需要使对象符合类数组对象的结构。
环境兼容处理
javascript
var spreadableSymbol = Symbol ? Symbol.isConcatSpreadable : undefined;
这行代码是为了兼容旧环境:
- 检查全局是否有 Symbol
- 没有的话就用 undefined 代替,这样第三个条件永远不会满足
- 这样在老旧浏览器中也能正常工作,只是功能会受限
在 Lodash 源码中,isFlattenable
主要用在 baseFlatten
函数里:
javascript
function baseFlatten(array, depth, ...) {
// ...
for (const value of array) {
if (depth > 0 && isFlattenable(value)) {
// 需要展平的情况
// ...
} else {
// 不展平的情况
// ...
}
}
// ...
}
这个 baseFlatten
函数是很多高级函数的基础,比如:
_.flatten
:展平一层_.flattenDeep
:完全展平所有层级_.flattenDepth
:按指定深度展平
总结
isFlattenable
这个小函数展示了几个重要的编程思想:
-
关注点分离
- 把"判断是否可展平"这个逻辑独立出来
- 让代码更清晰,更容易维护
-
开放性设计
- 支持通过 Symbol 扩展自定义对象的行为
- 不需要修改源码就能改变函数的判断结果
-
兼容性思考
- 考虑到了不同环境的差异
- 在不支持新特性的环境中优雅降级