数组不是想象中的数组:稀疏数组、类数组和扩展运算符的边界
有个实习生问我一个问题:他写了 new Array(3),想拿它初始化一个长度为 3 的数组,然后 .map((_, i) => i) 生成 [0, 1, 2],结果打印出来还是三个空位。他说这在网上抄的代码是这么写的,为什么到他这里不管用。
这个问题背后牵扯出的东西,比看上去要多一层:JavaScript 的数组到底是怎么存的,map 遍历的到底是什么,以及"看起来像数组"的东西为什么不一定能当数组用。
稀疏数组:空位不是 undefined
new Array(3) 创建的是一个长度为 3、但没有任何元素的数组。控制台打印出来是 [ <3 empty items> ],跟 [undefined, undefined, undefined] 长得像,但完全是两回事。
1const sparse = new Array(3); 2const dense = [undefined, undefined, undefined]; 3 4console.log(sparse.length); // 3 5console.log(dense.length); // 3 6 7sparse.map((_, i) => i); // [ <3 empty items> ],还是空的 8dense.map((_, i) => i); // [0, 1, 2]
原因是 map、forEach、filter 这些方法在遍历前会先检查每个下标"有没有值",用的是 in 运算符那套逻辑(准确说是 HasProperty)。空位上根本没有这个属性,遍历直接跳过。dense 里的 undefined 是实打实赋过值的属性,遍历会正常访问到。
这也是为什么 sparse.forEach(fn) 里的 fn 一次都不会被调用——不是数组长度不对,是压根没有可遍历的项。
想把空位真正填成有值的数组,要么用 .fill():
1Array(3).fill(0); // [0, 0, 0] 2Array(3).fill(0).map((_, i) => i); // [0, 1, 2],这次真的能生效
要么用 Array.from:
1Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]
这两种写法我现在都在用,fill 更适合先填个默认值再统一处理,Array.from 适合边生成边用下标算值,一步到位。但这两种写法在语义上是不同的两步:fill 只是把每个位置填成同一个值,之后想按下标算值还得再链一次 map;Array.from({ length: 3 }, (_, i) => i) 是"撑出空位 + 按下标生成值"一步做完,中间不会产生一个先填 0 又被丢弃的临时数组。如果只是要一个全 0 或全 null 的定长数组,fill 更直接;如果每个位置的值依赖下标本身,Array.from 更省一次遍历。
1// 等价但成本不同的两种写法 2Array(5).fill(0).map((_, i) => i * 2); // [0, 2, 4, 6, 8],先造一个全 0 数组,再整体 map 一遍 3Array.from({ length: 5 }, (_, i) => i * 2); // 同样结果,只走一次
数组长度不大的时候这点差异根本感觉不出来,但如果是生成大批量 mock 数据(比如联调时造几千条测试记录),fill 那种写法会先分配一次数组、填一次值,再整体遍历生成第二个数组,Array.from 的回调形式把"造"和"填"压到了一次遍历里,省下的是这一次多余的中间数组和一次额外遍历。
稀疏数组不只是 new Array(n) 会产生,手写字面量跳过某一项、或者 delete 数组的某个下标,也会留下空位:
1const arr = [1, 2, 3]; 2delete arr[1]; 3console.log(arr); // [ 1, <1 empty item>, 3 ] 4console.log(arr.length); // 3,长度没变 5 6arr.map((x) => x * 2); // [ 2, <1 empty item>, 6 ]
delete 只是抹掉了那个下标的值,数组长度和其他元素的位置都不受影响。业务代码里如果要删数组某一项,该用 splice 或 filter,delete 留下的空洞会在后面的遍历、序列化里制造麻烦——比如 JSON.stringify 会把空位转成 null,这个坑我见过一次,排查了好一阵才想起是谁在中间 delete 了一下。
稀疏数组还会在数组方法链式调用里带来另一种隐蔽的不一致:同样是"跳过空位",不同方法跳过的方式不完全一样。forEach、map、filter、some、every 都会跳过空位,但 join 和扩展运算符不会——它们会把空位当成 undefined 参与拼接:
1const holey = [1, , 3]; 2holey.join('-'); // '1--3',空位被当成空字符串 3[...holey]; // [1, undefined, 3],扩展运算符把空位转成了 undefined 4holey.map((x) => x * 2); // [ 2, <1 empty item>, 6 ],map 跳过空位,位置留空
也就是说同一个稀疏数组,用不同方法处理,"空位"这个概念在每种方法里的表现都不一样——有的当它不存在,有的当它是 undefined。这种不一致不是哪个方法设计得不对,是历史遗留(join 早在 ES3 就有,规范里对空位的处理和后来 ES5 加的 forEach/map 这批方法不是同一套逻辑),但排查的时候如果不知道这个区别,很容易看着同一个数组、两种输出对不上而怀疑自己写错了代码。
数组的本质:带 length 的特殊对象
顺着这个坑往下挖,还得弄清楚一件更底层的事:数组在引擎里到底是什么。V8 对连续存的小整数下标数组会做特殊优化(内部叫 fast elements,可能用连续内存存储),但从语言规范角度看,数组就是一个特殊的对象——下标是字符串形式的属性名,外加一个自动维护的 length 属性。
1const arr = [1, 2, 3]; 2arr.foo = 'bar'; 3console.log(arr); // [1, 2, 3, foo: 'bar'] 4console.log(arr.length); // 3,foo 不算进 length 5 6arr[10] = 'x'; 7console.log(arr.length); // 11,下标赋值会把 length 顶上去 8console.log(arr); // [1, 2, 3, foo: 'bar', <7 empty items>, 'x']
length 不是遍历出来数的,是数组对象自己维护的一个特殊属性:任何下标赋值只要大于等于当前 length,都会把 length 改成"下标 + 1",中间空出来的部分就成了稀疏空位。反过来改 length 也会截断数组:
1const arr2 = [1, 2, 3, 4, 5]; 2arr2.length = 2; 3console.log(arr2); // [1, 2],后面的元素直接被扔了
这条我们项目里踩过一次坑:有个列表模块对外暴露的是同一个数组的引用,别的模块里用 const list = getSharedList() 接住之后长期持有着,指望"清空后原数组还能被别处感知到"。想清空数组时用 list.length = 0,这个操作本身没问题(也是清空数组最快的写法之一,比新建一个空数组更省一次分配),length = 0 恰好保留了原数组的引用地址,所以在这个场景反而是对的写法;真正出问题的是另一个模块里有人图省事,在自己的函数作用域里也声明了一个同名的局部变量 let list = [] 想"重置一下",这个 list 从声明那一刻起就和外面共享的那个数组是两个完全不同的引用,两边的名字撞了,但操作的根本不是同一块内存,最后排查出来是这个局部同名变量把大家绕晕了,改列表的人以为自己清空的是共享数据,其实只是新建了一个和外部毫无关系的空数组扔在自己的作用域里。数组是不是要保留引用、还是可以整个替换,这个判断要提前想清楚,变量名撞了也得留意到底是不是同一个作用域下的同一个绑定。
length 这个属性还有一条容易被忽略的规则:它只认非负整数下标,字符串形式的非数字下标不会撑大 length,纯数字字符串下标才算数。
1const arr3 = [1, 2, 3]; 2arr3['foo'] = 'bar'; 3console.log(arr3.length); // 3,字符串属性名不影响 length 4 5arr3['10'] = 'x'; 6console.log(arr3.length); // 11,数字字符串下标照样生效,因为规范里数组下标本来就是"看起来像数字的字符串"
这也是为什么 V8 内部要分"fast elements"和"dictionary elements"两套存储:下标连续、从 0 开始的这类数组会被存成一段连续内存,访问和遍历都快;一旦出现大量稀疏空位、或者混入了字符串属性名,引擎就得把它退化成一个普通的哈希表结构存。前面 arr[10] = 'x' 这种一下子把 length 顶到 11、中间空出 7 个位置的写法,就是典型的会触发这种退化的操作——这也是为什么有经验的写法里很少直接给数组下标"跳着赋值",而是倾向于用 push、splice 保持下标连续。
类数组:长得像数组,用不了数组方法
arguments、document.querySelectorAll 返回的 NodeList、还有 DOM 的 HTMLCollection,都是"类数组"——有 length,能用下标访问,但原型链上没有 Array.prototype 那一堆方法。
1function demo() { 2 console.log(arguments.length); // 3 3 console.log(arguments[0]); // 'a' 4 arguments.map((x) => x); // TypeError: arguments.map is not a function 5} 6demo('a', 'b', 'c');
这就引出类数组的问题:为什么 arguments.map 报错,但看着明明是"数组"。因为它根本不是 Array 的实例,只是形状相似。转成真正的数组,最常见的两种写法:
1// 老写法,2020 年很多存量代码还在用 2function demo() { 3 const args = Array.prototype.slice.call(arguments); 4 return args.map((x) => x.toUpperCase()); 5} 6 7// 现在更推荐的写法 8function demo2() { 9 const args = Array.from(arguments); 10 return args.map((x) => x.toUpperCase()); 11} 12 13// 或者用扩展运算符,效果一样 14function demo3() { 15 const args = [...arguments]; 16 return args.map((x) => x.toUpperCase()); 17}
Array.from 是我现在的首选,一是语义上更直白("从某个东西生成一个数组"),二是它接受的第二个参数其实等于内置了一次 map:
1Array.from(arguments, (x) => x.toUpperCase()); 2// 等价于 Array.from(arguments).map(x => x.toUpperCase()),但少生成一个中间数组
第二参数这个用法我们项目里用得最多的场景是批量生成 mock 数据或者处理 NodeList:
1const inputs = document.querySelectorAll('.form-item input'); 2const values = Array.from(inputs, (el) => el.value);
不用先转数组再 map,一步做完,也省了一次中间数组的分配。
这里牵出一个更大的问题:链式调用数组方法的时候,每一次 .map()、.filter() 都会生成一个全新的中间数组,不是在原数组上"就地转一下"。写惯了链式风格的代码经常是这样:
1const result = list 2 .filter((item) => item.active) 3 .map((item) => item.name) 4 .filter((name) => name.length > 0) 5 .map((name) => name.toUpperCase());
四个方法调用,中间产生了三个临时数组(第一次 filter 的结果、第一次 map 的结果、第二次 filter 的结果),最后才是真正要的那个。数据量不大的时候这点开销完全感觉不出来,但如果 list 有几万条、这段代码又跑在滚动列表或者高频触发的搜索过滤里,这几次分配加起来是能在 DevTools 的 Performance 面板里看出一截 GC 时间的。一次 code review 上有资深前端指出我提交的一段类似代码有这个问题,建议要么用 reduce 一次遍历做完所有事情,要么在数据量确实大的场景改成一个 for 循环里判断加转换:
1// 一次遍历,不产生中间数组 2const result2 = list.reduce((acc, item) => { 3 if (item.active) { 4 const name = item.name; 5 if (name.length > 0) acc.push(name.toUpperCase()); 6 } 7 return acc; 8}, []);
链式写法可读性更好,reduce 或者手写循环性能更好,这是很实在的取舍,不是非黑即白。我们现在的做法是:默认写链式,只有在性能分析工具里实际看到某段热路径上数组操作占了明显的耗时,才回头改成单次遍历,不会一上来就为了"可能更快"牺牲可读性。
类数组转真数组还有一个隐藏条件:对象得先有合法的 length 属性,否则转出来是空数组。
1Array.from({ length: 3 }); // [undefined, undefined, undefined] 2Array.from({ a: 1, b: 2 }); // [],没有 length,转不出东西 3Array.from({ length: 3, 0: 'x', 1: 'y', 2: 'z' }); // ['x', 'y', 'z'],凑出一个合法的类数组对象
这个特性也是前面 Array.from({ length: 3 }, (_, i) => i) 能生成序列的原理——先按 length 撑出对应长度的空位,再用第二个回调填值,实际上是把"造一个定长数组"和"填充"两步合并了。
扩展运算符和解构在数组上的具体行为
扩展运算符 ... 在数组字面量里做的事情是"浅展开",和 concat 效果类似,但写法更灵活:
1const a = [1, 2]; 2const b = [3, 4]; 3const merged = [...a, 0, ...b]; // [1, 2, 0, 3, 4]
浅展开意味着只展开一层,嵌套数组不会被拍平:
1const nested = [[1, 2], [3, 4]]; 2const copy = [...nested]; // [[1, 2], [3, 4]],内层数组还是同一个引用 3 4copy[0].push(99); 5console.log(nested[0]); // [1, 2, 99],改动能看到,因为内层数组没被复制
这一点我们排查过一个状态更新的 bug:以为 [...state.list] 就是"深拷贝一份列表",改了 copy[0].name 之后触发不了组件更新,因为对象引用还是原来那个,React/Vue 的浅比较没检测到变化。数组本身展开了,但数组里的对象没有跟着变成新的引用,这个区别在做不可变更新时必须分清楚。
解构在数组上依赖的是可迭代协议,不是下标语法糖那么简单,跳过某一项、设默认值、收集剩余项都很顺手:
1const [first, , third, fourth = 'default'] = [1, 2, 3]; 2console.log(first, third, fourth); // 1 3 'default' 3 4const [head, ...rest] = [1, 2, 3, 4]; 5console.log(head, rest); // 1 [2, 3, 4]
解构默认值只在对应位置的值是 undefined 时才生效,null 不会触发默认值——这条经常和对象解构的默认值行为搞混,值得单独记一下:
1const [x = 1] = [undefined]; // x = 1 2const [y = 1] = [null]; // y = null,不是 1
解构依赖的是可迭代协议这一点,意味着解构不只能作用在数组上,任何实现了 Symbol.iterator 的对象都能被数组解构语法处理,Set、Map、字符串都算:
1const [a1, a2] = new Set([10, 20, 30]); 2console.log(a1, a2); // 10 20 3 4const [k, v] = new Map([['name', '张三']]).entries().next().value; 5console.log(k, v); // name 张三 6 7const [c1, c2] = 'hi'; 8console.log(c1, c2); // h i
反过来,普通对象因为没有实现 Symbol.iterator,直接拿去做数组解构会报错,这也是判断"能不能展开/解构"最本质的标准——不是看它长得像不像数组,是看它有没有这个迭代协议:
1const [p1] = { 0: 'x', length: 1 }; // TypeError: {(intermediate value)} is not iterable
这也解释了前面 arguments 为什么能用扩展运算符和解构,却调不了 .map:arguments 对象虽然不在 Array.prototype 的原型链上,但规范专门给它实现了 Symbol.iterator,所以解构、for...of、扩展运算符都能用;NodeList 同理,现代浏览器里也实现了这个协议,可以直接 for...of 遍历或者用扩展运算符转数组,唯独还是没有 .map、.filter 这些方法,用起来经常让人误以为"能遍历就等于是数组"。
顺带一提,concat 和扩展运算符在合并数组这件事上效果一样,都是浅合并、不拍平嵌套层级,选哪个更多是风格问题:
1[1, 2].concat([3, 4], [5, 6]); // [1, 2, 3, 4, 5, 6] 2[...[1, 2], ...[3, 4], ...[5, 6]]; // 同样结果
concat 还有个扩展运算符做不到的能力:如果参数不是数组,会直接当成一个元素追加,不会报错也不会展开;扩展运算符遇到非可迭代对象直接抛错:
1[1, 2].concat(3, 4); // [1, 2, 3, 4],非数组参数直接当元素加进去 2[...[1, 2], 3, 4]; // [1, 2, 3, 4],这里 3、4 本来就是普通值,不是通过展开加进去的,效果一样只是因为写法上没有对非可迭代对象做展开
两者日常混用问题不大,我个人更偏向扩展运算符,主要是因为它和数组字面量写在一起,插入位置更直观,要在中间插入元素、要跟别的字面量元素混着写,扩展运算符看着更连贯。
字符串和数组之间的转换
扩展运算符还有个常被忽略的行为:它是按 Unicode 码位展开字符串,不是按字符编码单元。这对处理 emoji 或者一些生僻字(用 UTF-16 代理对表示的字符)比 .split('') 更安全:
1'abc'.split(''); // ['a', 'b', 'c'] 2[...'abc']; // ['a', 'b', 'c'],普通字符看不出区别 3 4'😀'.length; // 2,因为它在 UTF-16 里占两个编码单元 5'😀'.split(''); // ['\ud83d', '\ude00'],拆成了两个没意义的半个字符 6[...'😀']; // ['😀'],按码位展开,拿到的是完整的一个字符
这个坑在做字符计数、字符串截断的时候特别容易踩,去年团队处理过用户昵称长度校验的问题,用 str.length 数出来的长度和用户实际感知的字数对不上,根源就在这——真正按"用户看到的字符"切分,扩展运算符比 split('') 更接近直觉,但要注意有些复合 emoji(用零宽连接符拼出来的家族 emoji 之类)连这个方法也处理不准,这类边界目前没有零成本的完美方案,只能上专门的分词库。
按码位数字符最直接的写法就是先展开再取 length:
1function codePointLength(str) { 2 return [...str].length; 3} 4 5'😀😀'.length; // 4,按 UTF-16 编码单元数的 6codePointLength('😀😀'); // 2,按码位数的,更接近用户感知的"两个字符"
昵称校验那次的实际处理是把校验规则从"字节/编码单元数不超过 N"改成了"码位数不超过 N",改动很小,但对使用 emoji 昵称的用户体感差异很明显——以前同样打 2 个 emoji 就提示超限,改完之后可以正常输入。截断同理,如果不按码位切,直接 str.slice(0, n) 有可能正好切在一个代理对中间,显示出来是个乱码方块:
1const str = '你好😀世界'; 2str.slice(0, 3); // '你好\ud83d',切断了 emoji 的代理对,末尾是半个字符 3[...str].slice(0, 3).join(''); // '你好😀',按码位切,完整保留
字符串反过来转数组,除了 [...str],Array.from(str) 走的是同一套可迭代协议,效果完全一样,两者选一个纯属个人偏好;但如果只是想把字符串拆成数组,不涉及码位安全的诉求,split('') 依然是最省事的写法,没必要为了"更严谨"每次都上扩展运算符,普通 ASCII 文本两者结果没有区别。
查找方法之间的细微差异
数组里查一个值在不在,indexOf 和 includes 看起来是同一件事的两种写法,绝大多数场景下也确实可以互换,但在 NaN 上会给出不一样的答案:
1const nums = [1, NaN, 3]; 2nums.indexOf(NaN); // -1,用的是严格相等 ===,而 NaN === NaN 是 false 3nums.includes(NaN); // true,用的是 SameValueZero 算法,这套算法认为 NaN 等于它自己
indexOf 底层用严格相等比较,NaN 和任何值(包括它自己)比较都是 false,所以永远找不到。includes 用的是 ES2015 之后规范里定义的 SameValueZero 算法,这套算法和严格相等几乎一样,唯一的区别就是它认为两个 NaN 相等(+0 和 -0 在这套算法里仍然视为相等,这点和 Object.is 不同,Object.is 会区分 +0 和 -0)。业务代码里数组值来自计算结果、可能出现 NaN 的场景,判断"在不在"一律用 includes 更保险,indexOf 只在需要拿到具体下标位置的时候才用得上,找不找得到 NaN 通常不是它的强项。
findIndex 和 find 也值得一提,它们接受的是回调而不是值,查找逻辑完全自定义,所以天然不受这个问题困扰:
1nums.findIndex((x) => Number.isNaN(x)); // 1,自己写判断条件,想怎么比就怎么比
如果要查的不是简单值而是"满足某个条件的第一项",直接用 find/findIndex,不要绕道用 indexOf 配合一次额外的 map 去凑。
几个和数组结构相关的判断
Array.isArray 是判断"是不是数组"的唯一可靠方式,typeof arr === 'object' 分不出数组和普通对象,instanceof Array 在跨 iframe 场景会因为不同全局环境有不同的 Array 构造函数而失效:
1typeof [] === 'object'; // true,看不出是不是数组 2Array.isArray([]); // true 3Array.isArray(document.querySelectorAll('div')); // false,NodeList 不是数组
Array.of 和 Array 构造函数容易搞混,区别只在参数个数为 1 且是数字的情况:
1Array(3); // [ <3 empty items> ],长度为 3 的空数组 2Array.of(3); // [3],就是一个包含数字 3 的数组 3Array(1, 2, 3); // [1, 2, 3] 4Array.of(1, 2, 3); // [1, 2, 3],两者行为一致
我现在写需要用数字初始化单元素数组的代码时,会优先选 Array.of,避免因为参数刚好是一个数字而意外触发"创建定长空数组"的那个重载。
排序的稳定性:以前是约定俗成,现在写进了规范
Array.prototype.sort 默认按字符串排序这个坑很多人都踩过——数字数组不传比较函数直接 sort(),会得到一个看起来乱掉的结果:
1[10, 1, 21, 2].sort(); // [1, 10, 2, 21],按字符串字典序排的 2[10, 1, 21, 2].sort((a, b) => a - b); // [1, 2, 10, 21],传比较函数才是数值排序
这条是老坑了,但今年整理的时候发现一个以前没细想过的点:排序算法是不是"稳定"的(两个排序键相等的元素,排序前后相对顺序不变),以前 ECMAScript 规范里其实没强制要求,各家引擎实现可以自己选、可以不稳定。真正把"稳定排序"写成强制要求的是 ES2019(Array.prototype.sort 那一条规范修订),V8 从 Chrome 70 左右开始把 sort 换成了稳定的 TimSort 实现。这意味着像下面这种"先按一个字段排、结果依赖同值元素保持原有相对顺序"的写法,现在是可以放心依赖的:
1const users = [ 2 { name: '张三', age: 28 }, 3 { name: '李四', age: 30 }, 4 { name: '王五', age: 28 }, 5]; 6users.sort((a, b) => a.age - b.age); 7// 张三和王五 age 相同,稳定排序下张三还是排在王五前面,和原数组里的相对顺序一致
以前写这种多字段排序(先按 age 排,同 age 再按录入顺序排)经常要额外记录一个原始下标当二级排序键来保证稳定性,现在主流浏览器和 Node 12+ 都能保证 sort 稳定,这一步可以省掉了,但如果项目还要兼容很老的移动端 WebView,稳妥起见这个二级排序键还是值得留着。
flat 和 flatMap:拍平嵌套数组不用再手写递归
ES2019 加进来的 Array.prototype.flat 和 flatMap 现在已经是稳定可用的方法,处理接口返回的嵌套数据结构比自己写递归或者 reduce 拼数组要省事很多:
1const nested = [1, [2, 3], [4, [5, 6]]]; 2nested.flat(); // [1, 2, 3, 4, [5, 6]],默认只拍平一层 3nested.flat(2); // [1, 2, 3, 4, 5, 6],传层数 4nested.flat(Infinity); // 不管嵌套多少层,全部拍平
我们有个接口返回的是分组数据(每个分组下面挂一个子数组),页面上要展示成一个平铺列表,以前的写法是 reduce 手动拼:
1const groups = [{ items: [1, 2] }, { items: [3, 4] }]; 2const flatOld = groups.reduce((acc, g) => acc.concat(g.items), []);
现在用 flatMap 一步做完,语义上更接近"map 完再拍一层":
1const flatNew = groups.flatMap((g) => g.items); // [1, 2, 3, 4]
flatMap 等价于 map 之后紧跟一次 flat(1),但引擎实现上是合并成一次遍历完成的,不会像手写 map().flat() 那样先生成一个嵌套数组、再整体拍平一遍。日常这点差异不明显,但如果 map 里每次会返回长度不定的数组(比如按条件展开成 0 到多条),flatMap 是比 map + filter + flat 三连更直接的写法。
类型化数组:另一套完全不同的东西
顺带提一句容易和普通数组混淆的 TypedArray(Int32Array、Float64Array 这些)。名字里带"Array",用起来也支持下标访问、.length、部分数组方法,但底层是完全不同的东西——它是 ArrayBuffer 上的一层视图,每个元素的类型和字节宽度在创建时就固定死了,不能像普通数组那样随便塞一个字符串进去或者动态变长。
1const typed = new Int32Array(3); 2typed[0] = 1; 3typed[1] = 'abc'; // 不会报错,但会被转成 0(转数字失败) 4typed.push; // undefined,没有 push 这个方法,长度天生固定
普通数组按元素分别存对象引用,TypedArray 是连续的定长二进制内存块,适合处理音视频采样数据、Canvas 像素数据、WebSocket 传来的二进制帧这类场景——数据量大、类型单一、要直接和底层内存打交道。日常写业务表单、列表这类场景基本用不上它,但如果项目里要处理 canvas.getImageData() 返回的 Uint8ClampedArray,或者用 WebSocket 收发二进制协议数据,认出它跟普通数组不是一回事,别指望在它上面调用 push、splice 这类会改变长度的方法,能少走不少弯路。
这几个问题问完,实习生原来的疑问也就有了答案:new Array(3) 生成的是空位而不是真正的元素,map 遍历会跳过空位,想生成 [0, 1, 2] 得先用 fill 或者 Array.from 把空位变成有值的位置。数组方法用得顺不顺手,很多时候不是记不住 API,而是没弄清楚当前手里这个东西到底是不是数组、有没有空位、展开的是几层。