资讯动态

数组重复的两种语义:fill 引用共享与深拷贝方案详解

发布时间:2026/10/13 16:47:52 来源:尧图企业网站定制
看到让一个数组对象重复多次这个标题估计不少人的第一反应是数组重复不就是 fill 吗一行代码的事有什么好说的。我最初也是这么想的。直到有一次给某个数据看板写周视图需要把一周七天的数据模板重复成六周顺手写了new Array(6).fill(weekData)然后被测试同学疯狂打回——每一周的数据全被改成了同一周的内容。那一刻我才意识到数组重复这个需求看起来人畜无害但 JavaScript 的引用传递机制会在里面埋一颗大雷。这颗雷如果你不亲自踩一次光看文档是记不住的。这篇文章就以这个需求为引子把让数组对象重复多次这件事彻底拆开讲清楚。内容包括两种重复语义的区分、fill 的坑、四套替代方案的对比、深拷贝场景的处理最后还有一个可以直接抄走的通用封装函数。适合刚入门 JavaScript 的同学也适合写了几年代码但没认真琢磨过引用类型细节的开发者。1. 需求拆解数组重复的两种语义别一开始就选错工具先说一个很多人会忽略的问题。让一个数组重复多次这句话在真实业务里可能指向两种完全不同的结果。1.1 重复成嵌套数组和重复并拼接是一回事吗第一种语义是外层嵌套。比如你有一个[1, 2, 3]重复三遍后希望得到[[1, 2, 3], [1, 2, 3], [1, 2, 3]]。这种场景常见于日历网格一周七天作为一个基本单位整个月视图由四周或六周组成外层是周内层是天。第二种语义是平铺拼接。还是[1, 2, 3]重复三遍后希望得到[1, 2, 3, 1, 2, 3, 1, 2, 3]。这种场景常见于列表数据填充、序列生成、分页数据模拟。这两种需求的代码写法完全不同。嵌套场景要保留数组边界拼接场景要把边界打开。如果你在动手前没想清楚到底要哪种后面很容易写出表面对、逻辑错的代码。我在项目里见过一个真实案例同事想生成一个选择器的选项数据基础单位是[周一, 周二]需要连续展示三周他用了Array(3).fill()得到嵌套数组也没报错但页面上显示的永远是同一个引用最后数据全部串位。问题根源就是语义混淆。1.2 先理解引用类型为什么数组重复没那么简单要真正理解后面的坑必须先弄清楚一个概念JavaScript 里的数组是对象对象是引用类型。简单说当你写const a [1, 2, 3]时变量a里存的并不是[1, 2, 3]这三个数字本身而是一个指向堆内存中某块区域的地址。真正的一等公民是那块内存里的数据a只是一个门牌号。所以当你把a塞进另一个数组时塞进去的不是一份拷贝而是同一个门牌号。之后你通过任何一条路径修改数组内容其他所有指向同一块内存的引用都会同步变化。这就是引用类型和基本类型数字、字符串、布尔最本质的区别。至于为什么标题里强调数组对象而不说数组恰恰是因为很多人知道对象是引用类型却没意识到数组也是对象的一种所以填坑填得猝不及防。下面这个fill事故就是最典型的例子。2. fill() 是最快的捷径也是最大的陷阱如果你搜索JavaScript 数组重复大概率第一个跳到眼前的就是fill。它确实是最短、最直白的写法但用在数组对象上它就是一个行走的 bug。2.1 一行代码搞定复现共享引用事故现场先看这段代码const weekData [周一, 周二, 周三, 周四, 周五]; const calendar new Array(6).fill(weekData); console.log(calendar); // 输出看起来是 // [[周一, ...], [周一, ...], [周一, ...], [周一, ...], [周一, ...], [周一, ...]]表面看一切正常六周的数据模板都生成了。但现在尝试修改第一周的周一calendar[0][0] 改过了; console.log(calendar[1][0]); // 改过了 console.log(calendar[5][0]); // 改过了 console.log(weekData[0]); // 改过了连原始数组都变了你会看到所有周的数据同步被改甚至原始数组也被污染。这不是偶然而是必然fill(weekData)把同一个引用填进了数组的六个槽位这六个槽位指向同一块内存。我曾经排查过一个更隐蔽的版本。当时用户反馈某一天设了提醒结果整月都被设置了。当时的代码就是用fill重复了一个包含日期和提醒状态的对象数组。因为外层数组看起来是对的数据也能正常渲染直到有人修改了某一项的嵌套字段整月数据瞬间全变。排查了几小时才定位到fill这一行。2.2 fill 传基本类型和对象类型的底层差异这里需要明确一个容易混淆的点不是所有fill都有这个问题。const nums new Array(3).fill(7); nums[0] 99; console.log(nums); // [99, 7, 7]数字是基本类型fill传进去的是值本身修改其中一个槽位其他槽位不会受影响。同理字符串、布尔值、null、undefined都没问题。问题只出在对象上。对象包括数组、普通对象、日期、函数被赋值时传的是引用fill做的只是把引用复制了 N 份所以 N 个槽位共享同一个对象。普通对象也好数组也好只要你是把数组对象作为填充物就一定会踩中这个坑。还有一个衍生误区有人觉得用[...calendar]或calendar.slice()把外层拷贝一下就能隔离。这确实能避免外层数组的共享但内层每个元素的引用依然没变。weekData还是那个weekData改装谁还是改谁。这属于拷贝了一层就以为安全了的典型错觉。3. 四套可行方案的横向对比与选型逻辑既然fill不能用那就得换正确的写法。我在这里整理四套方案每套都给出代码、原理和适用场景最后再聊一下选型标准。3.1 Array.from 工厂函数方案Array.from是目前处理这类需求最优雅的写法。它的第二个参数是一个 map 函数可以在创建数组的同时为每个槽位生成独立的值。const arr [1, 2, 3]; const repeated Array.from({ length: 4 }, () [...arr]); console.log(repeated); // [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]] repeated[0].push(99); console.log(repeated[1]); // [1, 2, 3]不受影响这里的核心在于() [...arr]每次调用都会执行一次数组展开创建出一个全新的数组。所以四个外层槽位持有的是四个不同的引用互不干扰。如果原始数组里装的是普通对象而你需要每个副本里的对象也是独立的那还要再深一层拷贝。后面章节会专门讲深拷贝这里先不展开。3.2 展开运算符与 map 组合另一种常见写法是展开运算符配合mapconst arr [1, 2, 3]; const repeated [...Array(4)].map(() [...arr]);它和Array.from本质上是一回事都是遍历固定长度然后生成新数组。但有一个细节值得注意Array(4)会创建一个稀疏散列里面四个槽位都是空位empty不是undefined。数组的map方法会跳过空位所以必须先用...展开把空位变成显式的undefinedmap才能正常遍历每个槽位。如果你图省事直接写Array(4).map(...)结果会得到一个同样稀疏的新数组重复任务静默失败连报错都不会有。这个坑对新手非常不友好。3.3 手写循环与 concat / flatMap 平铺方案循环虽然不酷但在 JavaScript 世界里它永远是兜底方案而且可读性极强function repeatNested(arr, n) { const result []; for (let i 0; i n; i) { result.push([...arr]); } return result; }没有任何隐藏语义每一步都清清楚楚。在性能测试中手写循环和Array.from通常不相上下甚至在部分引擎里for循环更快因为它少了一些函数调用开销。如果你要的是第一节说的平铺拼接那常规的fill、Array.from都不合适需要把数组展开后往里推// 方式一reduce concat const repeated Array.from({ length: 4 }).reduce( (acc) acc.concat([...arr]), [] ); // 方式二flatMap 更简洁 const repeated new Array(4).fill(0).flatMap(() [...arr]); console.log(repeated); // [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]flatMap的思路是外层生成 4 个槽位每个槽位展开成[1, 2, 3]然后自动把每个返回的数组平铺进结果。代码短语义也清楚。注意这里fill(0)填充的是基本类型数字不会引发引用共享问题所以fill可以安心用在辅助数组上。3.4 五种方案的选择标准与实际性能观察我自己在项目里测过一组数据用一个长度为 1000 的数组重复 1000 次最终约 100 万条记录for循环、Array.from、展开 map三者耗时都在毫秒级差距很小。fill确实最快但它带着共享引用的毛病属于能跑但有毒。所以选型标准很简单我一般按这三条走需求推荐方案原因生成嵌套数组外层 N 份Array.from({ length: n }, () [...arr])语义清晰代码短没有额外中间结构生成嵌套数组且追求极致性能手写for循环函数调用开销最少可读性靠注释补生成平铺数组拼接延长flatMap(() [...arr])一行搞定天然平铺不容易出错兼容极老环境手写for循环Array.from和flatMap需要 ES6 / ES2019注意一点flatMap是 ES2019 的方法如果目标环境是非编译的老浏览器比如某些政企项目里的古董内核用reduce concat更保险。另外如果只是为了生成 N 个相同的基本类型占位比如Array(n).fill(0)那没问题。我判断的标准是填充物是对象还是基本类型。是对象一律避开fill。4. 多维数组和对象成员的深拷贝暗坑前面几套方案都用了[...arr]也就是浅拷贝。数组元素是基本类型时它完全够用可一旦数组里套着数组或对象浅拷贝就会埋下第二个雷。4.1 浅拷贝重复二维数组时发生了什么看这个例子const row [0, 0, 0]; const grid Array.from({ length: 3 }, () [...row]);这行代码创建了一个三维数组吗其实是错的逻辑它创建的是三行互相独立的[0,0,0]看起来很像二维数组。真正要复现二维数组被浅拷贝坑的场景应该是这样const originalGrid [ [0, 0, 0], [0, 0, 0], [0, 0, 0], ]; const repeatedGrid Array.from({ length: 2 }, () [...originalGrid]); // repeatedGrid[0] 和 repeatedGrid[1] 是两个独立的外层数组 repeatedGrid[0][0][0] 1; console.log(originalGrid[0][0]); // 1被改了发生了什么[...originalGrid]把最外层三个槽位复制成了新的数组但每个槽位里的值也就是[0,0,0]那个数组依然是原来的引用。所以repeatedGrid[0][0]和originalGrid[0]指向同一个内部数组。内层数据照样被共享、被污染。这种场景在真实业务里很常见。比如你要生成多个互不干扰的二维棋盘副本、多组筛选条件模板、多份表单草稿一旦内层有对象或数组浅拷贝就顶不住了。4.2 structuredClone 与 JSON 序列化的取舍当浅拷贝不够用就需要深拷贝。现代 JavaScript 提供了一个内置方案structuredClone。const originalGrid [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; const repeatedGrid Array.from({ length: 2 }, () structuredClone(originalGrid)); repeatedGrid[0][0][0] 1; console.log(originalGrid[0][0]); // 0完全隔离structuredClone是浏览器和 Node.js 都内置的深拷贝工具支持嵌套数组、普通对象、Date、Map、Set、ArrayBuffer等绝大多数数据类型而且正确处理循环引用。我目前几乎所有深拷贝需求都直接调它。在这之前很多人用的是JSON.parse(JSON.stringify(arr))。这招对付纯文本、数字结构没问题但有三个明显的坑const task { title: 写周报, deadline: new Date(), callback: () console.log(done), nested: { enabled: undefined }, }; const copy JSON.parse(JSON.stringify(task)); console.log(typeof copy.deadline); // stringDate 变成了字符串 console.log(typeof copy.callback); // undefined函数直接被丢弃 console.log(copy.nested.enabled); // 整个属性都没了undefined 被忽略如果你的数组元素里正好有日期、函数、undefined字段用 JSON 方案就会静默改数据排查起来非常痛苦。另一个致命问题是循环引用对象里self指回自己时JSON.stringify直接报错。所以我的建议是环境支持structuredClone就用它不支持再考虑手写或引入工具库JSON 方案只适合纯数据接口返回的普通对象千万别用于业务运行时数据。4.3 根据业务数据形态选择合适的复制深度我可以给一个比较稳妥的判断标准大家写代码前先想几秒钟数组元素全是基本类型数字、字符串、布尔、null浅拷贝就够[...arr]安全。数组元素是对象或数组且副本之间不允许互相影响必须深拷贝优先structuredClone。数组元素是对象或数组但只读不写或者只是当模板传入组件浅拷贝理论上够用但我个人依然建议直接深拷贝因为没人能保证后续不往里面塞状态。我见过太多先浅拷贝后来加了字段修改逻辑的线上事故。这里再补一个手写的轻量深拷贝。如果你面对的只是数组里套数组、不涉及函数和日期的纯数据一个递归也够了function deepCopyArray(arr) { return arr.map((item) Array.isArray(item) ? deepCopyArray(item) : item ); }但如果元素是对象且需要递归处理对象属性这个函数就不够了。更复杂的场景我强烈建议直接上structuredClone别重复造轮子。5. repeatArray 工具函数封装与实战案例把前面的知识整理一下写成一个工具函数以后在项目里直接调用。这样不仅能规避踩过的坑还能统一整个团队的写法。5.1 可配置的通用封装我的目标是做一个兼顾嵌套、拼接、深拷贝三个维度的工具函数/** * 将数组重复多次 * param {Array} arr 原始数组 * param {number} times 重复次数 * param {Object} options 配置项 * param {boolean} options.flatten 是否平铺拼接默认 false生成嵌套数组 * param {boolean} options.deep 是否深拷贝默认 false浅拷贝 * returns {Array} */ function repeatArray(arr, times, { flatten false, deep false } {}) { if (!Array.isArray(arr) || times 0) { return []; } if (flatten) { return new Array(times).fill(0).flatMap(() deep ? structuredClone(arr) : [...arr]); } return Array.from({ length: times }, () deep ? structuredClone(arr) : [...arr] ); }调用方式// 嵌套浅拷贝适合元素为基础类型的模板重复 const nested repeatArray([1, 2, 3], 4); // 嵌套深拷贝适合对象元素且后续会修改副本 const tasks repeatArray( [{ name: task, done: false }], 10, { deep: true } ); // 平铺拼接得到 [1, 2, 3, 1, 2, 3] const flat repeatArray([1, 2, 3], 2, { flatten: true });我个人建议在项目里默认把deep设为true除非你能明确保证数组内容是基本类型且不会被修改。宁可多花一点拷贝的时间也不要在上线后花三个小时查数据漂移。5.2 实战日历网格数据生成拿文章开头提到的日历场景来完整演示一下。假设后端返回的是一周的日程数据const weekSchedule [ { date: 2025-05-12, events: [晨会] }, { date: 2025-05-13, events: [] }, { date: 2025-05-14, events: [评审] }, { date: 2025-05-15, events: [] }, { date: 2025-05-16, events: [发布] }, { date: 2025-05-17, events: [] }, { date: 2025-05-18, events: [] }, ];日历需要展示六周每一周都是这个结构的副本但用户可能在某一天添加新事件。如果直接用浅拷贝给第 1 周的第 3 天加一个事件第 2 周的第 3 天也会同步出现。使用我们封装的函数const calendarGrid repeatArray(weekSchedule, 6, { deep: true }); // 用户在第 1 周周三添加一个临时会议 calendarGrid[0][2].events.push(临时会议); console.log(calendarGrid[1][2].events); // [评审]不受影响这里deep: true保证了每一周的每一份日程对象都是独立副本用户操作不会再跨周污染。另一个常见的应用是分页骨架屏。比如需要重复渲染 8 个相同的加载卡片每张卡片有独立的loading状态const skeletonCard { loading: true, visible: true }; const skeletonCards repeatArray([skeletonCard], 8, { deep: true }).flat();注意这里用.flat()把嵌套数组拉平因为 UI 列表需要一维数组。这也是先按语义生成再按展示层需求转换的一个小技巧。5.3 我对这个需求的最终体会数组重复这个需求越看越像 JavaScript 引用类型的一堂浓缩课。它表面简单实际牵扯到值传递、引用传递、浅拷贝、深拷贝、稀疏数组等多个概念。踩过fill的坑之后我现在写代码前会先问自己两个问题我要的是嵌套数组还是平铺数组数组里的元素有没有对象嵌套这两个问题想清楚了代码基本不会跑偏。如果你目前还在用fill直接操作对象数组我建议今天就改成Array.from加展开或深拷贝的写法。别等到数据被污染的那一天再来翻这篇文。最后分享一个调试技巧如果怀疑数据被共享了最快的方式不是打日志而是在副本里改一个你觉得无关紧要的字段然后看原始数组是否也跟着变。JavaScript 的引用问题靠改一处看全局的直觉往往比读文档定位得更快。

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

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

免费获取报价 →
↑