freeCodeCamp 课程实战:用展开运算符复制数组(Copy an Array with the Spread Operator)全面解析
本篇文章基于 freeCodeCamp 开源课程仓库中的「基础数据结构(Basic Data Structures)」第 8 关挑战文档《Copy an Array with the Spread Operator》展开,系统讲解 ES6 展开运算符(spread operator)复制数组的语法、与 slice() 的能力差异,并逐步拆解 copyMachine 挑战的指令、全部自动化测试断言、种子代码与参考解法,帮助你既理解 JavaScript 语言层面的原理,也看清 freeCodeCamp 课程挑战的文档结构是如何驱动在线评测的。
挑战背景:它在课程中的位置与前后承接
freeCodeCamp 的整个课程被组织成 superblock → block → challenge 三级结构。在本文档中提到的这一个挑战,位于 JavaScript 基础课程的 Basic Data Structures 区块。从区块清单 curriculum/structure/blocks/basic-data-structures.json 可以看出,该区块共包含 20 个挑战,其中本挑战排在 第 8 位,且 helpCategory 标注为 JavaScript、blockLayout 为 legacy-challenge-list。
这个顺序并非随意排列,它形成了清晰的教学递进:
Use an Array to Store a Collection of Data(数组基础)Access an Array's Contents Using Bracket Notation(方括号访问)Add Items to an Array with push() and unshift()Remove Items from an Array with pop() and shift()Remove Items Using splice()Add Items Using splice()Copy Array Items Using slice()← 上一个挑战,讲述"选择性复制"Copy an Array with the Spread Operator← 本文主角Combine Arrays with the Spread Operator← 下一个挑战,讲述"合并数组"
也就是说,学习者会先掌握 slice() 的"按索引区间选择性复制",紧接着在此挑战中接触展开运算符的"整体复制"语法,最后在第 9 关把 spread 用于数组合并。挑战源文件为 curriculum/challenges/english/blocks/basic-data-structures/587d7b7b367417b2b2512b13.md。
展开运算符(Spread Operator)核心概念
挑战文档给出的英文定义值得逐句翻译理解:
While
slice()allows us to be selective about what elements of an array to copy, among several other useful tasks, ES6's new spread operator allows us to easily copy all of an array's elements, in order, with a simple and highly readable syntax. The spread syntax simply looks like this:...
展开运算符是 ES6 引入的新语法,写作三个连续的点 ...。与 slice() 最大的区别在于:slice() 需要你精确指定"从哪里开始、到哪里结束"两个索引参数来选择性提取一段元素;而展开运算符不做选择,它会把源数组中的全部元素按原有顺序展开到当前位置——语法极简、可读性极强。
官方给出的基本示例
挑战文档原文中的演示代码如下:
let thisArray = [true, true, undefined, false, null];
let thatArray = [...thisArray];
运行结果中:thatArray 的值等于 [true, true, undefined, false, null],与 thisArray 的元素完全一致;而 thisArray 本身保持不变。
这里有两个容易被忽略但很重要的语言事实:
- 产生的是新数组:
[...thisArray]在内存中创建了一个全新的数组对象并赋值给thatArray,而不是让thatArray与thisArray指向同一个引用。因此修改thatArray不会影响thisArray。 - 保持元素顺序:展开运算符按索引从
0开始逐个把元素取出来放入新数组,因此副本与源数组的元素顺序严格一致。即使源数组中存在undefined、null这类"空位值",它们也会被如实复制,上面的示例正是用一个含undefined和null的数组来证明这一点。
与 slice() 的对照
前一个挑战 Copy Array Items Using slice() 中,slice() 的用法是:
let weatherConditions = ['rain', 'snow', 'sleet', 'hail', 'clear'];
let todaysWeather = weatherConditions.slice(1, 3);
// todaysWeather === ['snow', 'sleet']
slice(start, end) 从索引 1 开始、到索引 3(不含)结束,属于"提取一段子区间"。而本挑战中的 [...thisArray] 不携带任何参数信息,直接整体复制。两者都不修改原数组,这是它们共同的核心特性。区别可以概括为:slice() 让你挑选"复制哪些元素",spread 让复制这件事本身零成本地写出来。
一个重要边界:浅拷贝
从语言规范层面需要强调:[...arr] 是浅拷贝(shallow copy)。如果数组的元素本身是对象或数组(引用类型),那么新数组里保存的仍然是这些内部对象的同一个引用。也就是说:
let matrix = [[1, 2], [3, 4]];
let copy = [...matrix];
copy[0].push(99);
console.log(matrix[0]); // [1, 2, 99] —— 内层数组被共享,影响了原数组!
在本挑战的数组中只包含布尔值、数字、字符串、null 与 undefined 这类基本值,因此不会触发这一陷阱。但在真实项目中如果你需要复制多维数组,就要意识到 spread 只复制了最外层容器。
挑战任务说明:实现 copyMachine
在理解了语法之后,挑战给出具体任务(instructions 原文要点):
We have defined a function,
copyMachinewhich takesarr(an array) andnum(a number) as arguments. The function is supposed to return a new array made up ofnumcopies ofarr. We have done most of the work for you, but it doesn't work quite right yet. Modify the function using spread syntax so that it works correctly.
翻译过来:已经定义好了一个 copyMachine(arr, num) 函数,它接收一个数组 arr 与一个数字 num,应当返回一个由 num 份 arr 副本组成的新数组(即一个二维数组)。大部分框架代码已就绪,但目前"跑得不对",需要你用 spread 语法修改它,让它正确工作。文档末尾还贴心提示了一句:"另一个已经学过的数组方法可能会派上用场"——这个 hint 指向的就是 push()。
种子代码(seed contents)逐行分析
挑战为你预先准备好的代码框架如下:
function copyMachine(arr, num) {
let newArr = [];
while (num >= 1) {
// Only change code below this line
// Only change code above this line
num--;
}
return newArr;
}
console.log(copyMachine([true, false, true], 2));
注意这套骨架刻意设计成"答案区清晰可见"的教学结构:
let newArr = [];声明用于存放结果的外层数组,初始为空。while (num >= 1) { ... num--; }是一个递减循环,只要num还大于等于 1,就执行一次循环体并把num减 1。这意味着循环体会被执行恰好num次(若num = 2则执行两次,num = 5则执行五次)。// Only change code below this line与// Only change code above this line是一对醒目的注释标记,框定出唯一允许你改动的区域,配合前端的代码编辑器做区域保护。- 最后的
console.log(...)便于在浏览器控制台即时观察运行结果,参考输出应为[[true, false, true], [true, false, true]]。
整个函数目前缺失的关键一步就是:在每次循环迭代中,把 arr 的一份展开副本放进 newArr。
参考解法与正确性推理
挑战文档的 # --solutions-- 段给出了官方参考实现:
function copyMachine(arr, num) {
let newArr = [];
while (num >= 1) {
newArr.push([...arr]);
num--;
}
return newArr;
}
console.log(copyMachine([true, false, true], 2));
对比种子代码,唯一改动是在 while 循环体内加入了这一行:
newArr.push([...arr]);
它的执行语义可以分三步理解:
[...arr]:每次迭代都基于arr展开生成一个全新的数组副本;newArr.push(...):把这份副本作为外层数组的一个新元素追加到newArr末尾;- 循环体每执行一次,
newArr就多一层元素,同时num--,直到num从 1 减为 0、循环条件num >= 1不成立,循环终止。
例如 copyMachine([true, false, true], 2):
- 第 1 轮:
num = 2,push 进[true, false, true](第 1 份),num变 1; - 第 2 轮:
num = 1,push 进[true, false, true](第 2 份),num变 0,循环结束; - 返回
[[true, false, true], [true, false, true]]。
值得玩味的一个细节是:为什么必须在循环体内部、逐轮地写 [...arr],而不是在循环外先 let copy = [...arr] 再重复 push 同一个 copy?因为如果所有外层元素都指向同一个数组对象,那么"num 份独立副本"就退化成"同一个数组的 num 个引用"。在循环内每轮重新展开,才能保证每一份都是各自独立的新数组——这也正好呼应了上文"spread 产生新数组"的核心特性。
自动化测试断言:五个 hints 详解
freeCodeCamp 在线评测器的判据(hints)会逐条执行并核对。本挑战共有 5 条断言,前 4 条用 assert.deepEqual 做严格的深比较,最后 1 条用正则检查源码是否真的使用了 spread 语法。全部证据都直接写在挑战源文件中。
断言 1:双份基本类型数组
assert.deepEqual(copyMachine([true, false, true], 2), [
[true, false, true],
[true, false, true]
]);
断言 2:五份数字数组
assert.deepEqual(copyMachine([1, 2, 3], 5), [
[1, 2, 3],
[1, 2, 3],
[1, 2, 3],
[1, 2, 3],
[1, 2, 3]
]);
断言 3:单份时返回单层包裹的二维数组
assert.deepEqual(copyMachine([true, true, null], 1), [[true, true, null]]);
这里验证了边界情形:num = 1 时函数应返回包含一层元素(即一份副本)的数组,而不是直接把 arr 原样返回。这取决于正确的位置书写:返回的是 [ [...arr] ] 而非 [...arr]。
断言 4:字符串元素同样适用
assert.deepEqual(copyMachine(['it works'], 3), [
['it works'],
['it works'],
['it works']
]);
断言 5:强制要求使用 spread 语法
assert(__helpers.removeJSComments(__helpers.removeJSComments(code)).match(/\.\.\.\s*arr/));
这条断言揭示了一个重要的评测机制:它先通过 __helpers.removeJSComments()(连续调用两次)把用户代码中的注释剔除,再检查剩余代码中是否存在匹配正则 /\.\.\.\s*arr/ 的片段——即"... 后跟零或多个空白再跟 arr"的写法。也就是说,即使运行结果完全正确,如果实现里用的是 arr.slice() 或 arr.concat() 之类的手段而没有直接对 arr 应用展开运算符,这道题依然判为不通过。可见挑战在验证"输出正确"的同时,也在强制验证"用上了本次要教的新语法"。
顺带一提,第 4 条断言使用 assert.deepEqual 做深比较而非 === 引用比较,也是教学上的关键设计——它不关心两个数组是否是同一个内存引用,只关心逐层元素是否完全相等。
挑战文档的元数据结构
这份 .md 文件能同时承担"教学文案"与"评测配置"两种角色,靠的是它规范化的结构。文档头部是被 --- 包裹的 YAML front-matter:
id: 587d7b7b367417b2b2512b13
title: Copy an Array with the Spread Operator
challengeType: 1
forumTopicId: 301157
dashedName: copy-an-array-with-the-spread-operator
各字段作用可归纳如下:
id:挑战的全局唯一标识(本挑战为587d7b7b367417b2b2512b13),区块 JSON 中的challengeOrder正是通过它引用并排序挑战;title:挑战标题,同时是页面上显示的名称;challengeType:标识挑战类型(这里为1),它会驱动前端的渲染组件选择与 schema 校验分支——从 curriculum/schema/challenge-schema.js 可以看到description、instructions等字段都通过Joi.when('challengeType', ...)做了条件化校验;forumTopicId:关联论坛讨论帖的 ID;dashedName:连字符形式的英文短名,用于 URL 与文件路由(copy-an-array-with-the-spread-operator)。
front-matter 之后,正文由若干个带 -- 边界的分节组成:--description--(知识点讲解)、--instructions--(任务要求)、--hints--(评测断言,通常由 assert 语句组成)、--seed--(含 --seed-contents-- 的初始代码)、--solutions--(官方参考实现)。整份文件因此既是"讲义"也是"测试用例的源码"。freeCodeCamp 的课程构建与 schema 校验工具链(见 curriculum/schema/challenge-schema.js 中的 seed、solutions 等 Joi 规则)会校验这些字段的存在与类型,确保每份挑战文档都结构完整、可以被测评环境正确加载。
知识延伸:从复制到合并
掌握了"复制"之后,紧接其后的下一个挑战 Combine Arrays with the Spread Operator 展示了 spread 的另一大优势——把整个数组"插"进另一个数组的任意位置:
let thisArray = ['sage', 'rosemary', 'parsley', 'thyme'];
let thatArray = ['basil', 'cilantro', ...thisArray, 'coriander'];
// thatArray === ['basil', 'cilantro', 'sage', 'rosemary', 'parsley', 'thyme', 'coriander']
用传统方式(如 concat)只能把数组合并到另一数组的头部或尾部,而 spread 语法让"在中间任意位置展开插入"变成一行直观代码。把两个挑战连起来看,你会得到一套完整的数组处理心法:需要整体复制用 [...arr],需要局部提取用 slice(start, end),需要任意位置拼接用展开语法,需要末尾追加用 push()。这正是 Basic Data Structures 区块希望学习者通过逐个挑战内化的能力组合。
小结
围绕 587d7b7b367417b2b2512b13.md 这份文档,我们完成了从语言知识到仓库工程实现的全链路梳理:... 展开运算符用一行代码整体复制数组且不触碰原数组;copyMachine 通过在循环内反复执行 newArr.push([...arr]),把"展开一份副本 + 推入外层数组"的动作重复 num 次,从而构造出 num 份互相独立的副本;而文档中的 5 条断言则从"结果正确"与"语法正确"两个维度共同约束实现。当你在阅读或编写同类挑战文档时,只要记住 front-matter 元数据、--description--、--instructions--、--hints--、--seed--、--solutions-- 这套固定骨架,就能快速判断一道题要教什么、怎么判、官方期望怎么写——这也是大规模、开源化维护在线编程课程的关键工程实践。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00