资讯动态

JavaScript分块数组实现:边界条件、副作用与工程应用

发布时间:2026/10/16 4:54:29 来源:尧图企业网站定制
最近在刷数组相关题目时碰到一道题号 2677 的“分块数组”。题目本身不长给定一个数组和一个 size把数组切成若干连续小块每块长度不超过 size最后不足 size 的也单独成一块。按理说这是很基础的操作我一开始以为几分钟就能写完结果在边界条件和函数副作用上栽了跟头。后来把常用写法、踩坑点、复杂度分析和真实工程场景完整过了一遍才发现这种“简单题”恰恰最容易暴露基本功问题。这篇文章就当作一次复盘把我从读题、实现、踩坑到迁移到项目里的全过程整理出来适合正在刷题的人也适合需要在业务代码里做批量数据的开发者。1. 题目要求拆解分块数组到底在算什么1.1 输入输出与几个关键示例题目要求实现一个函数通常叫chunk(arr, size)。参数arr是任意数组size是每块的目标长度返回值是一个二维数组里面每个元素都是原数组的一个连续切片。看几个典型示例。输入数组size期望输出说明[1,2,3,4,5]1[[1],[2],[3],[4],[5]]每块只有一个元素[1,9,6,3,2]3[[1,9,6],[3,2]]最后一块不足 3 个[8,5,3,2,6]6[[8,5,3,2,6]]size 大于数组长度整体成一块[]3[]空数组返回空数组不是[[]]这些示例传递了两个关键信息。第一分块必须保持原数组的顺序不能打乱也不是按某种哈希规则做分组第二每一块都是原数组的一个连续子序列块与块之间不能重叠不能遗漏任何元素。换句话说这道题不是在考你“如何分组”而是在考你“如何沿着下标按固定步长切分”。1.2 三个容易被忽略的边界第一空数组。很多人一上来写循环空数组自然得到空结果没问题。但如果你用Array.from({ length: Math.ceil(arr.length / size) }, ...)这种写法空数组时length是 0结果也是空数组也没问题。问题是你要明确一件事空数组不应该返回[[]]因为那代表“有一个空块”而原始数组里没有任何元素。第二整除和不整除。当arr.length正好是size的整数倍时每一块长度都等于size不整除时最后一块长度小于size。有些实现会在最后一块上做特殊判断但实际上slice这类方法天然支持“结尾越界”可以省掉这个判断。第三size 的合法性问题。题目通常会声明size是正整数但真实工程里没人给你保证参数一定合法。size 为 0 时循环会死循环size 为负数时逻辑没有意义size 为小数时切片结果可能出现中间块超过 size 长度。这一块我放到后面专门展开。1.3 “分块”和“分组”是两回事很多人会把“分块数组”理解成“把数组里的元素按某些条件归类”比如偶数一组、奇数一组。但这里不是。分块强调的是位置连续性第一块取下标 0 到size-1第二块取size到2*size - 1依此类推。这种连续切分在数据处理里太常见了批量接口调用、分页展示、表格行分组、坐标点还原全是同一套逻辑。所以别看它简单只要理解到位写出来的代码可以直接复制到项目里复用。2. 最容易理解的循环加 slice 实现2.1 核心代码var chunk function (arr, size) { const result []; for (let i 0; i arr.length; i size) { result.push(arr.slice(i, i size)); } return result; };这是我最终提交的版本也是我认为最适合绝大多数场景的版本。逻辑非常直白用一个变量i记录当前块的起始位置每次向后移动size个位置然后用arr.slice(i, i size)取出这一块。for循环的步长直接用了size这是一种很自然的编码方式你要切多长每次就前进多远。整个过程相当于拿一把长度为size的尺子在数组上量过去每量一次切一刀。2.2 为什么 slice 是最稳的切割工具很多人第一反应会用splice因为splice(0, size)看起来能“切掉前 size 个”。但这里有个关键区别slice不修改原数组splice会修改原数组。算法题通常要求函数是纯的输入数组不能被破坏否则会影响后续测试用例。即使题目没有明确说主动选择无副作用的写法也是更安全的习惯。另外slice对越界处理非常宽容。如果i size超过了arr.length它会自动截到数组末尾不会报错也不会填充undefined。这意味着你不需要在循环里额外写“如果剩余不足 size 就单独处理”的分支代码自然就短了。还有一个容易被忽视的点slice是浅拷贝。如果数组元素是对象分块后的内层数组里仍然是原对象的引用而不是深拷贝。在 LeetCode 这类用基本类型做测试的地方这个细节不会影响判题但在实际项目里如果后续要修改分块内某个对象的属性要注意它会影响到原数组里的对象。这不算 bug但你需要清楚这个行为符合预期。2.3 复杂度其实不是 O(n^2)有人看到 for 循环里套了一个slice会担心复杂度变成 O(n^2)。其实不会。虽然每次slice都要拷贝一段连续元素但所有块拷贝的元素总数加起来正好等于原数组长度 n。所以时间复杂度是 O(n)空间复杂度也是 O(n)因为结果数组里最终保存了 n 个元素。这里的关键是理解“循环次数”和“拷贝总量”的区别。循环会执行ceil(n / size)次但不管切多少刀每个元素只会被复制一次。因此这是一个线性算法。3. 其它写法对比reduce、splice、Array.from 各有各的坑3.1 reduce 写法函数式但可读性一般const chunk (arr, size) arr.reduce((acc, _, i) { if (i % size 0) { acc.push(arr.slice(i, i size)); } return acc; }, []);这个写法利用了reduce的索引参数遍历数组时只要当前索引能被size整除说明这是一个新块的起点就切一块出来。字面上看起来简洁但实际上有两个问题。第一reduce是遍历数组的每个元素即使我们最终只关心索引它也会对每个元素执行一次回调白白多做 n 次判断。当然对于普通数组来说这个性能差异可以忽略。第二可读性并不好。你需要理解“_代表当前元素但我们根本不用它只关心i”还要理解“i % size 0代表块起点”。在团队代码里这种写法不如一个明确的 for 循环直观。我建议只在代码风格偏函数式的团队里使用。3.2 splice 写法顺手但容易埋雷var chunk function (arr, size) { const result []; while (arr.length 0) { result.push(arr.splice(0, size)); } return result; };这是我最初的写法看起来特别顺手。splice(0, size)一次就从数组头部切掉 size 个元素切到数组为空为止。但它有一个致命问题它把原数组arr给改了。在刷题平台上一个测试文件里通常连续跑多个用例如果第一个用例把传入的数组切空了第二个用例再拿同一个数组去测结果就全乱了。我第一次提交的时候就栽在这里某个隐藏用例一直过不了后来才意识到是副作用污染。另外splice的性能也比slice差一点。splice删除元素之后后续元素要整体前移虽然摊还下来还是 O(n)但常数更大。如果你真的喜欢这个写法至少要改成先拷贝再切var chunk function (arr, size) { const copy [...arr]; const result []; while (copy.length 0) { result.push(copy.splice(0, size)); } return result; };但既然都要拷贝了直接用slice不是更干净吗3.3 Array.from 写法先算块数再取切片const chunk (arr, size) Array.from({ length: Math.ceil(arr.length / size) }, (_, i) arr.slice(i * size, i * size size) );这种写法的思路是先算出总块数Math.ceil(arr.length / size)然后用Array.from的第二个参数按索引生成每一块。代码很紧凑逻辑也算清晰知道有几块第 i 块就从i * size开始切。它的最大风险在于size 0。Math.ceil(arr.length / 0)在 JavaScript 里得到Infinity然后Array.from({ length: Infinity })会直接抛RangeError: Invalid array length。这个错误我在真实项目里踩过一次后面会细说。3.4 四种写法横向对比实现方式是否修改原数组可读性主要风险推荐度循环 slice不修改高size 为 0 会死循环入口防御即可最推荐reduce slice不修改中遍历了每个元素性能略低可用while splice修改中副作用导致测试污染不推荐Array.from不修改高size 为 0 时抛异常中需防御我的结论很明确默认用“循环 slice”。它最简单副作用最小复杂度最好分析。其它写法可以作为脑力训练但不太适合作为生产代码的默认方案。4. 边界条件的根因size0 为什么会让程序爆炸4.1 步长为 0 必然死循环先看最经典的坑。假设你写了这样的代码for (let i 0; i arr.length; i size) { result.push(arr.slice(i, i size)); }当size 0时i 0让i永远停在 0循环条件i arr.length永远成立。于是程序无限循环每轮都往result里 push 一个空数组最后内存耗尽。这不是 JavaScript 的问题任何语言的步进循环里只要步长为 0 都会这样。C 语言里for (int i 0; i n; i 0)一样死循环。所以最直接的防御就是在函数入口判断var chunk function (arr, size) { if (size 0) return []; const result []; for (let i 0; i arr.length; i size) { result.push(arr.slice(i, i size)); } return result; };有人会觉得“题目都说是正整数了为什么要多此一举”。我自己的经验是算法题里可以省略但一旦你把这段逻辑抽成公共工具函数入口参数就不可信了。上层调用方可能传 0、传负数、传空字符串防御代码能避免一大堆线上事故。4.2 小数 size 会造成“中间块超长”size 是小数的情况更容易被忽略。举个例子const arr [1, 2, 3, 4]; const size 1.5; for (let i 0; i arr.length; i size) { console.log(arr.slice(i, i size)); }循环里i依次是 0、1.5、3、4.5。而slice的参数会做整数转换所以arr.slice(0, 1.5)实际是slice(0, 1)得到[1]arr.slice(1.5, 3)实际是slice(1, 3)得到[2, 3]。注意这一块长度是 2已经超过了 size 的 1.5这就不符合“每块长度不超过 size”的约束了。处理方式也很简单要么直接拒绝非整数要么先取整。如果你的产品需求是“每块最多 N 个元素”N 天然应该是整数。我的做法是if (!Number.isInteger(size) || size 0) { return []; }如果是真实业务可能还需要抛一个错误提示调用方而不是静默返回空数组。这取决于函数的设计契约。4.3 一次真实的排查过程Array.from 的 Invalid array length有一回我在业务代码里写了一个批量任务分块函数用的就是Array.from那个版本。某天线上突然上报了一个错误信息是RangeError: Invalid array length。我当时第一反应是“是不是某个数组太大了”。赶紧在本地复现却发现普通大小的数组根本没问题。后来我在函数入口打日志把arr.length / size的值打印出来结果看到Infinity。再往前查调用链才发现上层接口在一个配置项缺失时用了默认值 0而这个 0 被直接当成了 size 传进来。这次排查给我的教训很深永远不要在调用方假设参数是合法的公共函数必须有自我保护能力。从那以后我所有“尺寸类”的工具函数入口都会先做一层参数校验。这个习惯帮我避免过很多看起来八竿子打不着的线上问题。5. 从过题到工程分块数组在真实项目里能做什么5.1 前端分页展示最直接的应用就是前端分页。当一个接口一次性返回 500 条数据前端要按每页 20 条展示时你只需要const pageList chunk(allItems, 20);分页组件的“页”就是pageList的下标。相比每次手动维护一堆start和end变量用分块函数语义更清晰。当然真实项目里大多数时候后端会做分页但总有接口设计得不够好这时候前端自己分块就很实用。5.2 批量请求与平滑限流如果有一个列表需要逐条调用后端接口而列表长度可能上千直接全部并发会把下游服务打爆。更合理的做法是每次只发一批批内并发批与批之间串行async function runInBatches(tasks, batchSize 50) { for (const batch of chunk(tasks, batchSize)) { await Promise.all(batch.map((task) task())); } }这里的chunk负责把任务列表切成 50 个一组的小批次每一批用Promise.all并发执行等这一批全部结束再进入下一批。这种模式在很多导出、通知、数据处理脚本里非常常见。需要注意的是Promise.all本身是“谁先完成谁先返回”的如果任务之间没有依赖关系用这种分批并发的方式既不阻塞太久也不会瞬间打满连接数。5.3 扁平数据还原成坐标点假设你从某个底层设备拿到一段扁平数据格式是[x0, y0, x1, y1, x2, y2, ...]要把它们还原成坐标点数组const points chunk(flatCoordinates, 2).map(([x, y]) ({ x, y }));这里先按 2 个元素切一块再用解构语法取出 x 和 y。类似的需求在图像处理、协议解析、Excel 导入导出里很常见。分块函数在这里承担的是“数据整形”的角色比手写遍历简洁不少。5.4 大数据量下的惰性分块上面的实现都是把整个二维数组一次性建立出来。如果原数组非常大比如几十万元素一次性分块会额外占用接近同等规模的内存。这时候可以用生成器做惰性分块function* chunkIterable(arr, size) { if (size 0) return; for (let i 0; i arr.length; i size) { yield arr.slice(i, i size); } } for (const block of chunkIterable(hugeArray, 1000)) { // 每次只处理一个块 }生成器版本不会预先构建整个结果数组而是每次迭代到某个块时才切片产出。如果处理过程中提前 break后面没被消费的块就不会被创建内存占用会明显更友好。当然如果数据本身不是数组而是某个可迭代对象比如文件流或数据库游标你还可以用基于迭代器的方式做真正意义上的流式分块。那属于更进阶的玩法但底层思路和这里完全一致按固定步长取连续的一段。6. 复盘隐藏测试为什么会检验“原数组没有被修改”6.1 从一次失败用例说起我最初用while splice版本提交时示例全部通过但有一个隐藏用例一直失败。一开始我想不明白因为输出结果看起来都是对的。后来我在本地把同样的逻辑跑了一遍才发现传入的数组在函数执行之后已经被切空了。刷题平台通常会在一个测试文件里按顺序执行多个用例而且很多时候复用的是同一个数组变量。第一个用例调用了chunk(arr, size)如果你用的是splicearr就被改动了第二个用例继续用这个arr时数据和预期就不一致了。这属于非常典型的“副作用污染”。所以不要小看“函数是否修改入参”这个问题。很多隐藏测试点就是奔着这个来的。6.2 函数副作用在工程里的代价在业务代码里直接修改传入数组同样危险。数组在 JavaScript 里是引用类型如果你在模块 A 调用了某个分块函数而它悄悄把原数组改掉了模块 B 再读取同一个数组时就会拿到一个被切空或者被截断的秘密对象排查起来非常难。现代前端框架的状态管理尤其在意这一点。状态更新通常要求不可变性直接修改原数组可能导致界面不刷新、缓存失效、调试困难。所以工具函数最好都遵循“不修改输入返回新结果”的原则。这也是我为什么后来坚持用slice而不是splice的原因。6.3 一份可以搬到本地的自测清单刷完这道题之后我的习惯是写一份简单自测脚本至少覆盖这几个用例function testChunk(chunk) { const cases [ { arr: [1, 2, 3, 4, 5], size: 1, expected: [[1], [2], [3], [4], [5]] }, { arr: [1, 9, 6, 3, 2], size: 3, expected: [[1, 9, 6], [3, 2]] }, { arr: [8, 5, 3, 2, 6], size: 6, expected: [[8, 5, 3, 2, 6]] }, { arr: [], size: 3, expected: [] }, { arr: [1, 2, 3, 4], size: 2, expected: [[1, 2], [3, 4]] }, ]; for (const c of cases) { const result chunk(c.arr, c.size); if (JSON.stringify(result) ! JSON.stringify(c.expected)) { console.log(failed, c, result); return false; } } const original [1, 2, 3, 4]; const result chunk(original, 2); if (JSON.stringify(original) ! JSON.stringify([1, 2, 3, 4])) { console.log(failed: original array was modified); return false; } console.log(all passed); return true; }这段代码用JSON.stringify做对比因为嵌套数组直接比较引用是不相等的。把实现传入testChunk跑一遍就能初步确认正确性。如果你还想测试防御逻辑可以额外加size 0的用例确认函数返回空数组而不是死循环。6.4 我现在写这类题的习惯经历过几次边界踩坑之后我现在写任何数组切片类函数都会遵循同样的流程先在注释里列出边界情况包括空数组、整除、不整除、size 为 1、size 大于数组长度、size 为 0然后再写实现。写完用上面的自测清单跑一遍确认输出正确且原数组没被动过。这个方法看起来有点笨但确实能帮我省下大量调试时间。算法题最怕的不是思路不会而是你以为会了结果边界一碰就碎。就我自己来说这道题最终给我的收获并不是背下一个标准答案而是让我对“参数校验”和“函数副作用”这两个词的体会更深。后来我在公司工具函数库里写分块入口一定先防御内部一定用slice而不是splice。这个习惯后来还帮我避免过一次线上数据错乱的问题。分块数组看起来是个简单题但认真做完之后你带走的绝对不只是几行代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑