1. 先从“元素”和“属性”这两个词说起
很多人学JS数组时,第一眼接触的就是“数组是一组有序数据的集合”,然后背了一堆方法名,写起来还是迷迷糊糊。我个人觉得,真正理解数组的钥匙,反而是先搞清楚标题里两个常被忽略的词:元素和属性。
数组里的“元素”,就是你在方括号里放的每一项数据。它可以是数字、字符串、对象、函数,甚至是一个嵌套的数组。数组里的“属性”,最常见的是length,它表示数组里有多少个元素。但这里的“属性”其实有两层含义:一是数组自带的内建属性(比如length,还有通过原型链继承来的方法在语义上也算数组的能力);二是数组元素本身也可以携带自定义属性,因为数组是对象,元素是对象的属性值,这一点很多人没转过弯来。
打个生活化的比方:数组就像一栋公寓楼,length是楼里登记的房间总数,每个元素是住在某个房间里的“住户”。你不需要知道住户长什么样才能管理这栋楼,只要知道房间号(索引)就能找到他。JS的数组索引从0开始,所以arr[0]是第一个住户,arr[length - 1]是最后一个住户。这套“编号房间”的模型,只要你脑子里建立起来,后面所有方法都不会再让你晕。
那“属性”在JS里到底怎么体现?你可以直接给数组挂自定义字段:
const arr = [1, 2, 3]; arr.tag = '我的数组'; console.log(arr.tag); // '我的数组'甚至给某个元素挂属性(虽然实际开发里很少这么干,但能帮助理解“数组即对象”):
arr[1].remark = '这是第二个元素';因为数组本质上是特殊的对象,数字索引会被转换为字符串键名存放在对象属性里。这就是为什么Object.keys([10, 20, 30])会得到['0', '1', '2']而不是[0, 1, 2]。这些细节,写业务代码时未必用得上,但一旦遇到“遍历数组时的键名判断”“把数组转成JSON后字段顺序是否符合预期”这类问题时,理解就比死记硬背管用得多。
2. 创建数组与初始化的多种姿势,以及藏在里面的坑
数组初始化几乎是每个JS项目里最常出现的操作,但恰恰这里是新手踩坑最多的地方。先说推荐写法:永远优先使用字面量。
// 推荐 const list = []; // 也推荐,明确长度并填充 const matrix = new Array(3).fill(0);字面量[]简单直观,不会有歧义。而new Array()有个著名的坑:当传入一个数字参数时,它创建的不是“包含这个数字的数组”,而是一个长度为该数字的空数组。
const a = new Array(3); console.log(a.length); // 3 console.log(a[0]); // undefined,但不是真正的undefined,是“空位”这种“稀疏数组”和[undefined, undefined, undefined]不完全等价。空位在forEach、map、filter等遍历方法中会被跳过,而undefined作为明确的值会被正常遍历,这个区别堪称经典“隐形Bug”。
反过来,如果你确实想创建“全0数组”,最稳妥的是Array.from:
const arr = Array.from({ length: 5 }, () => 0); // [0, 0, 0, 0, 0]聊到初始化,热词里还出现了“python创建数组元素为汉字”和“c++字符串数组初始化”,这正好引出一个跨语言对比。JS里创建包含中文的数组非常直接:
const names = ['张三', '李四', '王五'];不需要指定长度,也不需要声明类型,因为JS数组是“动态数组”,可以随时增删,长度可变。这跟C++那种“定长数组”或者Python中偏向“列表”的机制都不同。最直观的差异是:C++里你初始化int arr[5]就得负责管理这5个位置,JS里你只管往里塞,内存和扩容都交给引擎处理。理解这种动态性,你才能明白为什么push就像往行李箱里硬塞一件大衣,行李箱自己会变大,而不是你在外面买箱子时就得定好容量。
再补充一个很多教程不提的细节:用fill初始化对象数组时,填充的是同一个引用。
const grids = new Array(3).fill([]); grids[0].push(1); console.log(grids); // [[1], [1], [1]]三个子数组指向同一个对象,修改一个,其他全变。这也是热词里“二维数组初始化”最容易踩的坑。正确定义二维数组的方式是循环创建:
const rows = 3; const cols = 4; const matrix = Array.from({ length: rows }, () => new Array(cols).fill(0));每一行是独立的新数组,互不干扰。这个细节你在面试里答出来,基本能秒杀一大半只背了数组方法的候选人。
3. 访问、修改、删除数组元素:不只push和pop
数组的增删改查,是日常编码里跟“元素”打交道最频繁的场景。很多人一说删除就只会splice,一说增加就只会push,这当然能干活,但理解不同操作的适用场景和性能差异,才是经验分水岭。
先看最常用的尾部操作:
const stack = [1, 2, 3]; stack.push(4); // 尾部加一个 const last = stack.pop(); // 尾部弹出一个这两兄弟的时间复杂度是O(1),也就是不管数组多大,操作耗时基本恒定。而shift和unshift是头部操作,每执行一次,所有元素都得挪位置,时间O(n)。数组越大,代价越大。所以“用数组模拟队列”时,如果数据量很大,用shift会明显卡顿,更优做法是维护一个头部索引(或者直接改用链表思路)。
再说splice,这是最灵活的增删工具,一个方法能完成删除、替换、插入三种动作:
const arr = [1, 2, 3, 4, 5]; // 删除:从索引1开始删2个 arr.splice(1, 2); // [1, 4, 5] // 替换:从索引0开始删1个,换成9 arr.splice(0, 1, 9); // [9, 4, 5] // 插入:从索引1开始删0个,插入8 arr.splice(1, 0, 8); // [9, 8, 4, 5]splice的第二个参数是“删几个”,而不是“删到哪个位置”,这点是新手最常搞混的地方。网上不少教程会在评论区看到“为什么我删除数组指定元素删不掉”,多半就是这里参数理解错了。
删除指定元素到底怎么删?如果是想按值删,用filter更符合函数式思路:
const arr = [1, 2, 3, 2, 4]; const withoutTwo = arr.filter(item => item !== 2);filter不会修改原数组,而是返回新数组。这个特性在某些场景里是优点(不污染原数据),但也有缺点(每次都复制一份,大数组性能开销高)。如果确定只删第一个匹配项,可以直接用findIndex加splice组合:
const index = arr.findIndex(item => item === 2); if (index > -1) arr.splice(index, 1);我自己的建议是:能不动原数组就尽量不动,尤其是在React/Vue这类强调状态管理的框架里。直接改原数组容易引起“数据变得莫名其妙”的问题,返回新数组让数据流更可预测。热词里“vue”出现多次,关注Vue生态的读者一定要记住这一点:arr[0] = xxx这种按索引改值的方式,在Vue2的响应式系统里并不会触发视图更新,这就是数据监听层面的老坑。
再来看查询。includes判断数组是否包含某个元素:
const arr = [1, 2, 3]; console.log(arr.includes(2)); // true注意,includes使用SameValueZero比较规则,NaN也能正确匹配:
const nanArr = [NaN]; console.log(nanArr.includes(NaN)); // true,而indexOf会返回-1热词里“js判断字符串是否包含”也跟这里有交叉。字符串的包含判断用str.includes(subStr),数组的包含判断用arr.includes(item),两个方法同源不同物,但都遵循“找到了返回true,找不到返回false”的统一逻辑。你用一次就记住了。
4. 数组排序:sort方法里那个容易忽略的比较器
数组排序的热度一直居高不下,网上搜“js数组排序的几种方法”,几乎每篇都是“冒泡排序、选择排序、插入排序、快速排序、sort方法”这种清单。但说句实在话,业务开发里90%的排序需求,Array.prototype.sort就够了,你要会的是怎么正确使用它。
眼看着简单,一用就错的地方在于:默认的sort先把元素转成字符串再按字典序排序。
const nums = [10, 9, 100, 2]; nums.sort(); console.log(nums); // [10, 100, 2, 9]因为字符串排序时把每个元素转成字符串,“100”排在“10”后面,“2”排在“9”后面,完全不符合数值直觉。解决办法就是传比较器函数:
nums.sort((a, b) => a - b); console.log(nums); // [2, 9, 10, 100]比较器返回负数、0、正数,分别表示a应排前面、保持不变、a应排后面。a - b是升序,b - a是降序。这个规则记牢,比背十个排序算法都实用。
再说对象数组按属性排序。比如你有一堆商品数据,想按价格从低到高排:
const products = [ { name: '键盘', price: 299 }, { name: '鼠标', price: 99 }, { name: '显示器', price: 1499 }, ]; products.sort((a, b) => a.price - b.price);这个写法是sort最经典的业务场景。但有一点必须注意:sort是原地排序,它会修改原数组。如果你不想改动原始数据,先拷贝一份再排:
const sorted = [...products].sort((a, b) => a.price - b.price);热词里还有“数组里面不能直接调用sort吗”这类搜索,很多人疑惑为什么不行。其实不是不能调,而是调用后原数组变了,有时候你没意识到。比如先filter出一个子数组再sort,你以为排序不影响原数组,结果因为filter返回新数组才安全;但如果直接对原数组sort,原数组就被重排了。这种细微差别,就是经验积累的地方。
再补充一个排序场景:“中文数组按拼音排序”。原生sort默认按Unicode码点排,中文会得到“拼音不一定、码点一定”的结果。想要按拼音排,可以用localeCompare:
const names = ['张三', '李四', '王五']; names.sort((a, b) => a.localeCompare(b, 'zh'));localeCompare是字符串的方法,给中文排序时指定语言标签,会比默认排序更符合直觉。这个技巧在CMS后台按姓名排序、城市列表排序时特别好用。
5. 数组遍历的几种常见姿势,别再只会for循环
遍历是数组操作里最“日常”的动作,但要写出高效、可读性强的遍历,是需要选择的。for循环、forEach、map、filter、reduce、some、every、for...of,每个都有自己的适用场景。
forEach适合“对每个元素做点事但不需要新数组”的情况:
arr.forEach(item => { console.log(item); });map适合“把每个元素转换后收集成新数组”的情况:
const nums = [1, 2, 3]; const doubled = nums.map(n => n * 2); // [2, 4, 6]filter适合“按条件保留元素”的情况,这个前面已经讲过。reduce则适合“把一个数组累加成单个值”的情况:
const total = [1, 2, 3, 4].reduce((sum, cur) => sum + cur, 0); // 10如果你只是想判断“数组里有没有满足条件的元素”,用some:
const hasEven = [1, 2, 3].some(n => n % 2 === 0); // true想判断“是不是所有元素都满足条件”,用every:
const allPositive = [1, 2, 3].every(n => n > 0); // true这几个方法一旦用熟,你会发现很多原本要写五行的循环,一行就能表达清楚,代码的“意图”也一眼就能看出来。函数式编程的核心思想就是把“怎么做”交给底层实现,你只需要声明“做什么”。
热词里出现“js深入浅出vue”,顺带提一句:Vue的模板里遍历列表时用的v-for,底层也依赖数组遍历和key管理。你给每项加的key,本质上就是给数组元素的索引打上一个“稳定标识”,方便框架最小化DOM操作。如果你在Vue里用index作为key,一旦数组头部插入一个元素,所有元素的下标都变了,Vue会认为整个列表都变了,从而引发不必要的重渲染。理解数组索引和元素之间的关系,对前端框架调优非常关键。
再说for...of,这个循环可以遍历类数组对象和可迭代结构:
for (const item of arr) { console.log(item); }它和for...in有本质区别。for...in遍历的是键名(字符串形式的下标),而for...of遍历的是值。有人会在for...in里取到数组的自定义属性名,比如给数组挂了arr.tag = '标记',for...in会把'tag'也遍历出来,而for...of不会。所以遍历数组请优先用for...of,for...in留给对象。
6. 二维数组、扁平化与矩阵操作
热词里反复出现“二维数组”、“矩阵元素的边界值”、“numpy三维数组相乘”,这些其实是数组嵌套的高级话题。JS里二维数组的实质就是“数组套数组”,比如:
const grid = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ];访问grid[1][2]就是先拿第二行([4, 5, 6]),再拿它的第三个元素(6)。这在处理表格数据、棋盘类游戏、图像像素矩阵时特别常见。
二维数组最大也最容易被忽视的坑,就是“引用共享”,前面提过的new Array(3).fill([])就是一种。除此之外,还有一个“边界值”问题。比如一个3x3矩阵,你想遍历所有元素,判断“某元素是否在最外层边缘”,通常的逻辑就是判断索引row === 0 || row === rows - 1 || col === 0 || col === cols - 1。这种边界处理要是漏了“边缘元素”的条件,程序很容易越界或者漏算。凡是涉及矩阵遍历的算法题,写代码前先把四周边界条件列出来,比写好再调试快得多。
再聊“扁平化”。把一个二维或三维数组拉平成一维,在数据处理中很常用。JS新版提供了flat方法:
const arr = [1, [2, [3, 4]]]; console.log(arr.flat()); // [1, 2, [3, 4]] 默认只拉平一层 console.log(arr.flat(2)); // [1, 2, 3, 4] 拉平两层 console.log(arr.flat(Infinity)); // [1, 2, 3, 4] 全部拉平flat不会修改原数组,而是返回新数组。如果你在面试题里遇到手写扁平化,思路就是递归检测每一项,若该项还是数组就继续递归,否则收集起来。这个写法跟flat内部实现逻辑基本一致。
热词里还出现了“numpy三维数组相乘”,这里存在语言的边界。NumPy本身跟JS没关系,我的建议是:如果你的项目里真的需要高性能的多维数组运算,JS的普通嵌套数组只适合“业务逻辑层”,不适合“科学计算层”。真要算矩阵乘法之类,有两个正经思路:一是用typedArray(类型化数组)加上自己写运算,适合对性能有要求的场景;二是直接用WASM或者WebGPU,把计算丢给更底层的工具。普通网页应用如果偶尔做一点矩阵计算,直接用嵌套数组加循环也够了,没必要上重型工具。
7. 数组与字符串、类数组的互转,实战中的高频组合
数组操作不是孤立的,尤其是“字符串拆分转数组”和“类数组转数组”这两个场景,几乎每个项目都会遇到。
先说字符串转数组。最简单的方式是split:
const str = 'a,b,c'; console.log(str.split(',')); // ['a', 'b', 'c']拷Excel表数据时,经常要“按逗号分隔”或“按行分割”,一行代码就能搞定:
const lines = rawText.split('\n').map(line => line.trim());热词里“excel 提取前两列匹配的数据成一个数组”就是这个场景的变体。你在浏览器里拿到CSV文本,先按\n切出行,再按,切出列,然后用filter筛选需要的行,最后落到数组里。这个链路比任何库都轻量,也特别好调试。
再说类数组转真数组。类数组长什么样?最典型的是arguments对象(函数内部可用)、DOM查询得到的NodeList、字符串本身(有索引也有length)。它们有数字索引和length属性,但不是真正的数组,没有map、filter这些方法。标准的转换方式是:
const realArray = Array.from(nodeList); // 或者老一点的写法 const realArray2 = Array.prototype.slice.call(nodeList);热词里“playwright定位元素”“dom元素”都跟这个相关。你用document.querySelectorAll拿到NodeList后,如果不转成真数组,就不能直接调map,很多刚接触DOM操作的人会在这里报错“xxx is not a function”。Array.from是现在最推荐的方案,它既能转类数组,又能接受映射函数:
const texts = Array.from(document.querySelectorAll('.item'), el => el.textContent);一步到位,把DOM元素数组转成文本数组。这个写法在处理页面数据采集时堪称神器。
还有一个高频场景是“数组去重”,搜热词时经常和数组方法混在一起。用Set是最省事的方式:
const arr = [1, 2, 2, 3, 3, 4]; const unique = [...new Set(arr)]; // [1, 2, 3, 4]Set的特点是元素唯一,展开回数组就自动去重了。注意它去重用的是SameValueZero规则,NaN也能正确去重,这比双循环判断indexOf要可靠得多。
8. 常见问题与排查技巧
数组相关的报错和Bug,在开发中出现的频率非常高。我整理一下自己踩过和帮别人排查过的典型问题,做成一分钟速查表:
| 现象 | 原因 | 解决办法 |
|---|---|---|
arr[0]是undefined,但数组明明有长度 | 稀疏数组,空位而不是真正的元素 | 用for...of或Array.from处理,避免遍历方法跳过 |
sort()结果和预期数值顺序不同 | 默认按字符串字典序排序 | 传(a, b) => a - b |
map返回的数组长度和原来一样,但值没变 | 忘记写return | map里必须显式返回新值 |
filter后原数组变了 | filter不会变原数组,你多半改的是对象元素内部 | 检查是否修改了对象成员的属性 |
splice删除时删多了/删少了 | 第二个参数是数量,不是目标索引 | 先findIndex找到位置,再splice |
includes找不到NaN | 如果是用indexOf才会这样 | 用includes |
new Array(3).fill([])修改一个子数组全变 | fill填充的是同一个引用 | 用Array.from循环创建 |
NodeList没有map方法 | 类数组不是真数组 | 用Array.from转换 |
| 数组按索引赋值在Vue里不更新视图 | Vue2响应式限制 | 用splice或Vue.set,Vue3不用管 |
arr.length = 0后数据清空但引用还在 | 这是清空数组的常用手段,但可能被其他变量引用 | 考虑重新赋值arr = [] |
再分享一个排查数组问题时比较实用的习惯:在遍历方法里用console.log打印中间态,定位哪一步数据不对。
const result = rawData .map(item => item.value) .filter(v => v > 10) .sort((a, b) => a - b);如果你发现最终结果不对,就在.map后面加一行console.log,看看进入filter之前的数据是否符合预期。数组链式调用的好处是每一步都有明确的输入输出,哪里断了电一目了然。
另一个经验是:大数组操作尽量避免在循环里反复使用splice或shift。比如你要删除所有符合条件的元素,如果用filter就干净利落:
arr = arr.filter(item => !shouldDelete(item));如果必须在原数组上操作,那就倒序遍历:
for (let i = arr.length - 1; i >= 0; i--) { if (shouldDelete(arr[i])) { arr.splice(i, 1); } }倒序删除的妙处在于,删除后面的元素不会影响前面元素的下标。很多人在正序遍历时删除当前项,结果跳过了一个元素,这种“看似没删干净”的Bug非常隐蔽。
9. 最后分享一点个人总结
数组这个东西,说简单也简单,无非是存数据、取数据、改数据;说深也深,length属性与索引的关系、稀疏数组的遍历差异、引用共享带来的副作用、遍历方法的性能差异,每一条都能延展出一片知识海洋。我自己的体会是:不要死记方法清单,而是给每个方法找一个你在业务里真正会用到的场景。你做过“根据URL参数生成数组”“把接口返回的对象数组按条件分组”“把Excel粘贴文本拆成表格数据”,再回头看split、filter、reduce这些东西,就像工具箱里每一把用过的手钳,闭着眼睛都知道该拿哪一把。
最后再提供一个额外的编码习惯:写数组相关代码时,第一反应先想清楚“我要不要保留原数组”。如果要保留,就先[...arr]或者arr.slice()拷贝一份再操作。这个习惯特别能降低出差错概率,也让我在代码评审时少挨很多骂。
如果你正在学JS,试着把今天提到的场景挨个跑一遍,用console.log看每一步的输出,亲自感受一下sort某个数字数组的“诡异顺序”和fill共享引用的“连带修改”,这比读十篇博客都管用。数组这门课,动手跑一遍,永远比背一遍记得牢。