JS初始化二维数组为何不能用Array.fill([])

作者:小括号

时间:2026-11-04

浏览:154

深入解析在 JavaScript 中初始化二维数组时为何不能使用 new Array(m).fill([]),剖析浅拷贝共享引用的底层原理,并提供可靠的高效初始化实践方案。

文章目录
在 JavaScript 中,如果使用 new Array(m).fill([]) 初始化二维数组,外层数组的每个元素填充的实际上是对同一个空数组 [] 的引用。当修改任意一行的某一项时,所有行都会发生联动修改。解决此问题的正确路径是让每一行都独立分配堆内存,例如使用 Array.from({ length: m }, () => [])。

一、现象重现:一次写入为何导致全行联动?

在算法练习(如网格型动态规划、图的邻接表)或日常业务开发中,开发者经常需要初始化一个大小为 m 行 n 列的二维数组。许多人在掌握了一维数组 new Array(n).fill(0) 的简写技巧后,常常直觉地写出如下代码:

// 尝试创建一个 3 行的二维数组,初始每行为空数组
const matrix = new Array(3).fill([]);

// 查看初始化结构
console.log(matrix); 
// 示例输出:[ [], [], [] ] —— 表面看起来完全符合预期

此时控制台打印的结果形式非常具有欺骗性,它看似确实输出了包含 3 个空数组的外层数组。然而,一旦我们尝试对其中的“某一特定行”进行修改赋值,诡异的现象便立刻发生:

// 目标:仅仅修改第 0 行第 0 列的值为 'A'
matrix[0][0] = 'A';

// 再次查看 matrix 的整体内容
console.log(matrix);
// 示例输出:[ [ 'A' ], [ 'A' ], [ 'A' ] ]  <-- 每一行都多出了 'A'!

开发者明明只向 matrix[0][0] 写入了数据,第 1 行和第 2 行却同时被修改了。这种“牵一发而动全局”的联动变异并不是 JavaScript 引擎的 Bug,而是其核心对象模型与求值规则共同决定的确定性行为。

JS初始化二维数组为何不能用Array.fill([])
图 1:new Array(3).fill([]) 异常现象复现与全等诊断排查流程

如果我们执行一次全等比较验证:

console.log(matrix[0] === matrix[1]); // true
console.log(matrix[1] === matrix[2]); // true

全等判断返回 true,这明确证明了 matrix[0]、matrix[1] 和 matrix[2] 并非 3 个独立的数组,它们本质上是同一个数组在内存中的 3 份引用指针。

二、核心机制:内存引用与 fill() 的底层规则

要透彻理解为何产生这种现象,需要结合 JavaScript 的数据存储类型与方法参数的求值时机两个层面进行分析。

1. 原始类型与引用类型的存储差异

在 JavaScript 中,数据类型被分为两大家族:

  • 原始类型(Primitive Types):包括 Number、String、Boolean、Null、Undefined、Symbol 和 BigInt。它们的值直接保存在栈内存(Stack)中,按值进行复制与传递。
  • 引用类型(Reference Types):包括 Object、Array、Function 等。对象实体保存在堆内存(Heap)中,而变量或数组槽位中存储的仅仅是指向该堆内存物理地址的指针(引用地址)。

2. 参数即时求值(Argument Evaluation)

当执行 new Array(3).fill([]) 时,JavaScript 引擎并非把 [] 当作一个“每次需要时重新创建的模板”,而是严格遵循函数的求值顺序:

  1. 在进入 fill 函数体内之前,先计算参数表达式 []。
  2. 表达式 [] 在堆内存中分配了一块唯一的空间,生成了一个数组实例(假设内存地址为 0x3FA1)。
  3. 计算完成后的地址 0x3FA1 作为实参传递给 fill 方法。
  4. Array.prototype.fill 内部执行一个朴素的循环赋值,将收到的地址 0x3FA1 重复赋值给外层数组的索引 0、1、2。
JS初始化二维数组为何不能用Array.fill([])
图 2:内存引用模型对比:单实例共享(左)与独立堆分配(右)

从图 2 可以清晰看到,左侧错误模式中,外层数组的三个索引槽位虽然各自独立,但它们存储的指针全部汇聚到了同一个堆内存地址。因此,无论通过 matrix[0]、matrix[1] 还是 matrix[2] 访问,操作的都是同一个底层容器。

三、推导演进:为什么 fill(0) 稳妥,而 fill([]) 却成陷阱?

很多开发者会产生疑问:在初始化一维数组时,使用 new Array(5).fill(0) 非常稳定且安全,为什么换成 fill([]) 就会踩坑?

其关键分歧在于复制的内容究竟是数据本体还是内存指针:

JS初始化二维数组为何不能用Array.fill([])
图 3:Array.prototype.fill(value) 底层执行时序与求值推导

当执行 new Array(5).fill(0) 时:

  • 数字 0 是原始类型,传递的是纯数值;
  • 外层数组的 5 个槽位分别存储了数值 0 的独立副本;
  • 当修改 arr[0] = 9 时,仅仅覆写了索引 0 对应的栈空间数值,不会影响其他槽位。

而当执行 fill({}) 或 fill([]) 时:

  • 参数是对象或数组,传递的是堆内存地址;
  • 所有槽位存储的是同一份地址指针;
  • 对任何一个槽位内部属性的变动,例如 arr[0].push(1) 或 arr[0][0] = 'X',都是对该地址所指向的公共对象进行原地修改(Mutation)。
重要辨析:赋值覆盖 vs 原地变异
如果在 const arr = new Array(3).fill([]) 之后执行的是 arr[0] = [1, 2],此时其余槽位并不会变成 [1, 2]。因为这是将 arr[0] 处的指针重新定向到了新的数组地址,并没有变动原公共对象。而 arr[0][0] = 'A' 是顺着原指针修改内部数据,因而引发了全体联动。

四、正确姿势:初始化二维数组的 3 种可靠方案

解决二维数组初始化问题的根本原则只有一个:必须让每一行都在堆内存中被单独创建一次,保证各行的内存地址互不相同。在实际项目和算法编写中,有以下 3 种常见且可靠的实现方案。

方案 1:Array.from 工厂映射(推荐首选)

ES6 引入的 Array.from() 接收第二个参数作为映射函数(Mapping function)。该回调函数会在生成每一个元素时被独立调用,因此每一行都会实例化一个全新的数组:

// 初始化一个 3 行 4 列,初始值为 0 的二维网格
const rows = 3;
const cols = 4;

const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));

// 验证独立性
matrix[0][0] = 99;
console.log(matrix[0]); // 示例输出: [ 99, 0, 0, 0 ]
console.log(matrix[1]); // 示例输出: [ 0, 0, 0, 0 ](完全独立,未受污染)

方案 2:fill(null).map 链式初始化

如果习惯数组原型链上的链式调用,可以通过先填充占位符,再用 map 为每行创建独立实例:

// 注意:必须先 .fill(null) 占位,否则 new Array(rows) 生成的空槽(empty slots)会被 map 忽略跳过
const matrix = new Array(rows)
  .fill(null)
  .map(() => new Array(cols).fill(0));

matrix[0][0] = 99;
console.log(matrix[1][0]); // 示例输出: 0(互不干扰)
注意事项:直接写 new Array(rows).map(...) 是无效的。因为 new Array(n) 创建的是仅有长度、没有实际属性键的稀疏空槽,map 会直接跳过未初始化的空槽。必须先通过 fill(null) 填充基础值激活槽位。

方案 3:传统双重 for 循环(性能敏感场景)

在海量数据矩阵运算、图形图像逐像素处理或游戏物理引擎等对内存分配与执行效率要求严苛的场景中,传统的 for 循环避免了高阶函数与闭包创建的微小开销,执行速度最为稳固:

const matrix = [];
for (let i = 0; i < rows; i++) {
  const row = [];
  for (let j = 0; j < cols; j++) {
    row.push(0);
  }
  matrix.push(row);
}
JS初始化二维数组为何不能用Array.fill([])
图 4:JavaScript 二维数组常用初始化方案特性对比与技术决策图

五、初始化方案技术选型与防坑准则

为了在实际编码中兼顾代码简洁度与健壮性,我们可以将上述方案的适用场景归纳如下:

初始化方案 代码示例 行内存隔离 语法简洁度 适用场景
Array.from (推荐) Array.from({length: m}, ()=>Array(n).fill(0)) 完全隔离 高(一行完成) 日常前端业务、算法刷题、常规开发
fill(null).map new Array(m).fill(null).map(()=>Array(n).fill(0)) 完全隔离 中(需先占位) 团队规范偏好链式调用场景
经典 for 循环 for(let i=0; i<m; i++) { ... push() } 完全隔离 低(多行模板代码) 高频计算、海量网格、极致性能优化
fill([]) (危险) new Array(m).fill([]) 完全共享(危险) 短但有严重缺陷 严禁使用

总结要点

  1. Array.prototype.fill(value) 的参数在调用前立即求值,对整个数组填充的是该值的单一副本。
  2. 当 value 是数字、字符串、布尔值等原始类型时,按值复制安全无害;当 value 是数组或普通对象等引用类型时,浅拷贝将导致全体槽位共享同一个堆对象。
  3. 凡是需要动态生成独立引用类型元素的场景,切勿使用只传固定值的 fill,应优先选用支持每项回调计算的 Array.from({ length }, () => [])。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章