14
最后更新:2022-7-7

今天看到有人点赞,回顾了一下,进行了一些补充和修改。现在看来,2016 年的时候对 JS 的理解果然还不够深刻。


今天回答了 @j_bleach 的问题:JS生产嵌套数组(也就是对数组分组)更好的写法。回答的过程中对 lodash _.chunk() 产生了好奇,所以分析了一下它的源码,再加上我自己的解决方案,收集了如下一些方案,分享给大家

按惯例,我还是使用 es6 语法,在 Node7 中实验通过

1. 数据和参数和期望结果

const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const groupByNum = 3;

clipboard.png

2. RxJS

因为最近在学习 RxJs,所以顺手做了个 RxJs 解决方案。但这不是重点。不了解或者很了解 RxJs 的请忽略。

RxJava 很火,其实 ReactiveX 有很多种语言的实现,JavaScript 的实例就是 RxJs,建议学习的同学直接上 5.0 Beta。不过 RxJs 主要用于异步流处理,所以需要通过回调函数来使用结果。

const Rx = require("rxjs");
const out = Rx.Observable.from(data)
    .bufferCount(groupByNum)
    .toArray()
    .do(result => console.log(result))
    .subscribe();

3. _.chunk() 实现

lodash 提供了大量数据处理的方法,_.chunk() 专为分组而生

const _ = require("lodash");
const result = _.chunk(data, groupByNum);

3.1. _.chunk() 的源码

在 node 环境也,通过 npm 安装 lodash 之后就能在 node_modules/lodash 目录下找到源码文件 chunk.js

npm install lodash

所以完整的源码不贴了,只看下关键的那一句

  while (index < length) {
    result[resIndex++] = baseSlice(array, index, (index += size));
  }

baseSlice() 是 lodash 对 Array.prototype.slice() 的兼容性实现,可以直接当 slice() 来看。看了这个源码,我有了函数式写法的思路,后面通过 slice() + map() 实现部分详述。

4. reduce() 实现

补充 2022-7-7

可以直接看 4.2 的简单实现。

像这种,目标数组长度和原数组长度不一致的情况,函数式写法很容易想到 reduce() 函数。只可惜单纯的 reduce() 做不出来(在 data.length 不能被 groupByNum 整除的时候)

function groupArray(data, cols) {
    const r = data.reduce((r, t) => {
        r.current.push(t);
        if (r.current.length === cols) {
            r.list.push(r.current);
            r.current = [];
        }
        return r;
    }, { list: [], current: [] });

    if (r.current.length) {
        r.list.push(r.current);
    }

    return r.list;
}

const result = groupArray(data, groupByNum);

reduce() 的初始化对象是 { list: [], current: [] },其中 list 是要得计算出来的结果,而 current 是中间变量,用于生成每个组。

最后由于不有保证 data.length 一定被 groupByNum 整除,所以可能会有一个未完成的 current 没被 push 到 list 当中,所以专门进行了一个判断和处理。因此不能写成函数式写法,有些遗憾。

4.1. 补充 @2020-02-24

现在回想了一下,可以用 reduce 来实现,虽然不是纯函数式

function groupArray(data, cols) {
    return data
        .reduce(
            ([groups, subIndex], d) => {
                // subIndex 根据 cols 在 [0, cols) 区间循环,可通过取余来更新 subIndex
                // 所以,subIndex === 0 表示产生了一个新的分组
                if (subIndex === 0) {
                    // 为了方便访问最后加入的分组,用 unshift 倒插
                    // 这样后面就可以用 groups[0] 获取最新的分组
                    groups.unshift([]);
                    // 当然这里用 push,后面用 groups[groups.length - 1] 也是可以的
                }

                // 将数据加入到最近的一个分组中
                groups[0].push(d);

                return [groups, (subIndex + 1) % cols];
            },
            [[], 0] // 初始值,groups = [], subIndex = 0
        )[0]        // reduce 的结果是 [groups, subIndex],所以用 [0] 把 groups 取出来
        .reverse(); // 前面 unshift 加的组,所以要反个向
}

去掉注释之后的代码还是比较清晰

function groupArray(data, cols) {
    return data
        .reduce(
            ([groups, subIndex], d) => {
                if (subIndex === 0) {
                    groups.unshift([]);
                }
                groups[0].push(d);
                return [groups, (subIndex + 1) % cols];
            },
            [[], 0]
        )[0]
        .reverse();
}

4.2. 补充 @2022-7-7

之前不知道怎么想的,用了这么复杂的 reduce 实现,现在补充一个简单的

function chunk(data, size) {
    return data.reduce((chunks, it, i) => {
        (chunks[i / size | 0] ??= []).push(it);
        return chunks;
    }, []);
}

如果不能用 ??= 运算符,可以把主要的那一句换成

(chunks[i / size | 0] = chunks[i / size | 0] || []).push(it);

5. forEach() 实现

既然不能用函数式写法,那 forEach() 或者 for ... of 实现就会更容易理解一些。

补充 2022-7-7

其实有了上面 4.2 的写法,下面这个 for each 的实现已经不重要了,把 reduce 展开都比这简单,不看也罢。

function groupArray(data, cols) {
    const list = [];
    let current = [];

    // for (t of data) {
    data.forEach(t => {
        current.push(t);
        if (current.length === cols) {
            list.push(current);
            current = [];
        }
    });
    // }    // for (t of data)

    if (current.length) {
        list.push(current);
    }
    return list;
}

6. slice() + map() 实现

看到了 _.chunk() 的源码,让我产生了函数式写法的灵感,相比上面的解决方案,更难于理解,不过语法看起来很酷

const result = Array
    .apply(null, { length: Math.ceil(data.length / groupByNum) })
    .map((_, i) => data.slice(i * groupByNum, (i + 1) * groupByNum));
@2022-7-7 上面的代码没变,但是重新排了个版,比较符合我现在的代码风格。

Array.apply() 是为了生成一个长度为 Math.ceil(data.length / groupByNum) 的数组作为 map() 的源,map() 不需要这个源的数据,只需要这个源每个数组的 index

Math.ceil() 用于保证在除法计算有余数的时候对商 +1,即循环次数 +1。

补充 @2022-7-7

除法向上取整还有一种算法:(length + size - 1) / size | 0,其中 | 0 的作用是向下取整,和 Math.floor() 效果一样。这个算法可能不是那么好理解,但只需要拿两个用例来试试就清楚了:一个是 size 刚好能整除 length 的情况;另一个是 size 差一点能整除 length 的情况,也就是刚好整除 length + 1 的情况。

举例:比如要除以 5 向上取整,找 10,11 和 14 来测试,答案应该是 2、3 和 3。5 - 1 = 4(10 + 4) / 5 商 2 余 4,(11 + 4) / 5 商 3 余 0,(14 + 4) / 5 商 3 余 3,完全符合预期。

然后在算得的循环次数中,通过 slice 返回每一段结果,通过 map() 映射出来,最终生成需要的结果。

6.1. 补充 @2020-02-24

上面的 Array.apply(null, { length: n } 是为了生成一个指定长度的空数组。这里有两个问题要注意:

  1. Array(n) 可以生成长度为 n 的空数组,但是里面的元素是虚填的,调用其 .map() 或者 .forEach() 结果会出乎意料。可以实验一下:

    // 期望输出 10 个 hello,实际并没有
    Array(10).forEach(_ => console.log("hello"));
  2. ES2015 以后可以使用 Array.from(Array(n)) 来生成空数组,而不再需要使用之前那个复杂的方法

    所以上面的代码可以改为:

    const n = groupByNum;   // 偷懒少写几个字,顺便缩减下面代码的长度
    const result = Array
        .from(Array(Math.ceil(data.length / n)))
        .map((_, i) => data.slice(i * n, (i + 1) * n));

    补充 @2022-7-7

    Array.from() 的第 2 个参数就是 mapper,所以可以把上面的 map 过程放到 Array.from() 里面去

    function chunk(data, size) {
        return Array.from(
            Array((data.length + size - 1) / size | 0),
            (_, i) => data.slice(i * size, i * size + size)
        );
    }

7. 小结

数组分组是一个很简单的问题,有很多种方法来处理。本文并不是想告诉大家如何处理这个问题,而是将我处理问题的思路分享出来,希望能对处理各种数据问题带来一点点启发。


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!