☰
JavaScript数组进阶:从API选型到性能优化与框架实践
2026/10/6 5:14:08 网站建设 项目流程

从一次 Code Review 说起。上个月我帮团队看一段列表拖拽排序的代码,发现同事用splice在循环里删除数组元素,结果索引错乱,排出来的顺序完全不对。他挠头说“数组不就那几个 API 嘛,怎么还能写出 bug”。我当时的反应是:数组确实是前端最基础的数据结构,但恰恰因为基础,很多人的用法停留在“能跑”阶段,离“好用”和“不出错”还有一段距离。这篇文章就是把我在实际项目里和数组打交道的经验整理出来,覆盖 API 选型、面试高频题、框架状态管理、数据结构应用、大数据量性能优化、手写源码这些场景。适合正在进阶的前端工程师,也适合准备面试的朋友,看完可以直接用到你的项目里。

1. 从增删改查到排序查找:数组 API 选型里的门道

1.1 增删改查:splice 不是万能钥匙,声明式写法更省心

数组的增删改查 API 不算多,但选错 API 的代价往往不是性能,而是逻辑混乱。我见过最多的问题就出在splice上。

splice会直接修改原数组,返回被删除的元素数组。它功能很强——可以删、可以插、可以替换——但正因为太强,在循环里配合使用非常容易翻车。一个典型的错误是:

const arr = [1, 2, 3, 4, 5]; for (let i = 0; i < arr.length; i++) { if (arr[i] % 2 === 0) { arr.splice(i, 1); // 删除后索引前移,会跳过下一个元素 } }

删除索引 1 后,原来索引 2 的元素变成了索引 1,循环却继续走 i = 2,等于跳过了它。想用splice在循环里删元素,要么倒着遍历,要么用一个临时数组先记录要删的索引。但我的建议是:能不 splice 就不 splice,改用filter生成新数组:

const numbers = [1, 2, 3, 4, 5]; const filtered = numbers.filter((n) => n % 2 === 0);

这样原数组不变,没有索引错乱问题,语义也更清晰。React 状态更新、Vue 数据操作里,这种声明式写法尤其重要,后面会细说。

再来说插入。头部插入unshift、尾部push、中间插入splice,各有各的适用场景。栈操作用push+pop,队列操作用push+shift,双端队列用unshift+shift或push+pop。实际项目里我几乎只用push,因为unshift和shift在数组头部操作时,JS 引擎需要把所有元素往后或往前挪,时间复杂度是 O(n)。数据量小无所谓,但如果你维护一个高频操作的队列,比如 WebSocket 消息缓冲,头尾操作的选择就是性能分水岭。

1.2 查找与判断:includes、indexOf、find 的边界差异

“数组里有没有这个值”这个问题,看着简单,其实有一堆隐藏差异。

indexOf在 ES7 之前是主流方案,但它有两个短板:一是找不到返回 -1,写起来不够直观;二是对NaN无效——[NaN].indexOf(NaN)的结果是 -1,因为indexOf内部用的是严格相等,而NaN === NaN为 false。includes这两点都更好:找不到返回布尔值,并且能正确识别NaN。

如果你要查的是对象元素,indexOf和includes都力不从心,因为它们比较的是引用地址。这时候应该用find(返回元素本身)或findIndex(返回索引):

const users = [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]; const user = users.find((item) => item.id === 2);

顺带提醒一个性能相关的细节:find是 ES6 新增的,回调会对每个元素执行到找到为止,最坏情况是 O(n)。如果查找频率很高,建议先把数组转成 Map 或 Set,用哈希查找,空间换时间。

排序方面,sort()的默认行为是“把元素转成字符串,再按字典序排”,这是无数人踩坑的地方:

const numbers = [1, 10, 2, 21]; numbers.sort(); // 结果其实是 [1, 10, 2, 21],因为字符串 '10' < '2'

所以数值排序必须传比较函数:numbers.sort((a, b) => a - b)。另外 ES2019 之后,sort是稳定排序,相同元素的相对顺序会保持不变。这个特性在表格多列排序时很关键——你可以先按次要字段排序,再按主要字段排序,相同主要字段的行仍然保持次要字段的顺序。

2. 数组去重与扁平化:经典题里的边界比答案更重要

2.1 Set 去重为什么快?NaN 和对象引用是两个认知死角

数组去重在面试里的出现频率高得离谱,但绝大多数人只知道[...new Set(arr)]这种一行流解法。问题是:面试官真正想考察的是你对“相等”的理解。

一行流解法确实没问题,而且性能很好,因为Set内部基于哈希表,查找是 O(1)。但有两个边界你得知道:

第一,Set判断元素是否重复采用的是SameValueZero算法,它和严格相等(===)几乎一样,唯一区别是它认为NaN等于自身。所以[...new Set([NaN, NaN])]会得到[NaN],而[NaN].indexOf(NaN)是 -1。很多人死记“Set 可以去重 NaN”,但不理解原理,换个场景就不会了。

第二,Set去重的是引用地址。两个结构完全相同的对象,在 Set 眼里是两个不同的值:

const objects = [{ id: 1 }, { id: 1 }]; [...new Set(objects)]; // 长度还是 2

如果要对对象数组按某个字段去重,要用Map或者手动维护一个标记集合:

function uniqueByField(arr, field) { const seen = new Set(); return arr.filter((item) => { if (seen.has(item[field])) { return false; } seen.add(item[field]); return true; }); }

2.2 扁平化与递归深度:flat、flatMap 和手写方案的选择

Array.prototype.flat(depth)是 ES2019 的标准方法,默认展开一层,depth传Infinity可以完全展开:

[1, [2, [3]]].flat(2); // [1, 2, 3] [1, [2, [3]]].flat(Infinity); // [1, 2, 3]

flatMap则是map之后再flat(1)的组合,适合“一个元素映射出多个元素”的场景,比如按订单展开明细。要注意它只能展开一层,嵌套更深的数据还得靠flat。

但面试里经常不让直接用原生方法,要手写。手写扁平化的本质是树的深度优先遍历或广度优先遍历。递归是最直观的:

function flatten(arr) { const result = []; for (const item of arr) { if (Array.isArray(item)) { result.push(...flatten(item)); } else { result.push(item); } } return result; }

这里有个隐蔽的坑:数组层级特别深(比如超过一万层)时,递归会爆调用栈。如果这是个真实场景(比如处理 AST 嵌套节点),建议用栈实现:

function flattenIterative(arr) { const stack = [...arr]; const result = []; while (stack.length) { const item = stack.pop(); if (Array.isArray(item)) { stack.push(...item); } else { result.push(item); } } return result.reverse(); }

用栈模拟时,注意pop取出的是最后一个元素,所以最后要reverse一下,或者改用shift+push的队列式遍历(但是性能差一些)。这是面试官最爱追问的点:递归好写,但你能不能再写一个非递归版本?

3. 框架联动中的数组状态管理:为什么索引直改总出问题

3.1 React 中“不可变更新”的真正原因

React 状态更新的核心机制是引用比较。你用setState传入新状态时,React 会对比新旧引用,如果引用没变,它认为数据没变,不触发重新渲染。所以直接arr.push()之后setState(arr),新旧引用是同一个数组,界面纹丝不动——这是我见过新手犯得最多的问题。

正确的做法是创建一个新数组。有两种路径:

// 增加 setList([...list, newItem]); // 删除 setList(list.filter((item) => item.id !== targetId)); // 修改 setList(list.map((item) => item.id === targetId ? { ...item, done: true } : item));

展开运算符、filter、map都会返回新数组,加上回调里返回新对象,整个状态更新就是纯函数式的。这样写的好处不只是能触发渲染,更重要的是让状态变更可预测,方便调试和回溯——这在 Redux、Zustand 这类状态管理库里是硬性要求,因为它们依赖不可变数据来做时间旅行和选择性订阅。

还有一个很容易被忽略的点:不要在渲染函数里直接修改数组。比如在组件函数体内写list.push(...),组件每次渲染都会改同一个数组,叠加多次后数据就全乱了。数组操作要么放在事件回调里,要么放在useEffect里,都要生成新数组再赋值。

3.2 Vue2 的索引监听缺陷与 Vue3 的改进

Vue2 的响应式系统基于Object.defineProperty,数组的索引和length属性无法被拦截。所以直接arr[0] = newValue或者arr.length = 0,界面不会更新。Vue2 只能通过Vue.set或$set来显式触发更新:

// Vue2 中 this.$set(this.list, 0, newValue); this.list.splice(0, 1); // 为什么 splice 可以?因为 Vue 重写了数组的 7 个方法

Vue2 之所以重写push、pop、shift、unshift、splice、sort、reverse,就是为了在调用这些方法后手动通知依赖更新。但sort和reverse对索引的篡改同样检测不到,所以还是会有盲区。

Vue3 换成了Proxy,可以代理整个数组对象,包括索引、length、任意属性,所以上面的问题基本消失了。arr[0] = newValue可以直接触发更新。不过即使如此,我还是建议在 Vue3 里也用“生成新数组”的写法,因为配合computed和watch时,引用变了依赖追踪更清晰,也更容易从数据层面理解组件何时重新计算。

另外无论是 Vue 还是 React,列表渲染时key的选择都值得多说一句。很多人图省事用index当key,这在纯尾部追加时问题不大,但只要涉及删除、排序、中间插入,index会导致节点错配——React 会复用错误组件的 DOM 状态(比如输入框内容串位)。数组里的元素应该在数据模型层就设计出稳定的唯一标识(如id或uuid),这才是列表渲染的正解。

4. 从环形队列到树状数组:数组承载数据结构时的前端实战

4.1 环形队列 q[m]:WebSocket 消息缓冲与任务调度的朴素实现

热词里有一句很经典的数据结构题:“假设以数组 q[m] 存放循环队列的元素,同时以 rear 和 length 分别指示环形队列的队尾和队列长度。”这看起来是考研题,但它在前端有一个非常实用的大白话版本:环形队列 = 定长数组 + 首尾指针循环利用。

前端为什么要用环形队列?最常见的是 WebSocket 消息缓冲。服务端推送频率高于 UI 消费速度时,消息需要排队。如果用普通数组push加shift,shift每执行一次就要把剩余全部元素往前挪,消息量大时会有无谓的性能损耗。环形队列用模运算把数组空间循环利用,插入和删除都是 O(1):

class CircularQueue { constructor(capacity) { this.capacity = capacity; this.queue = new Array(capacity); this.rear = 0; // 下一个写入位置 this.length = 0; // 当前元素个数 } enqueue(value) { if (this.length === this.capacity) { return false; // 队列满 } this.queue[this.rear] = value; this.rear = (this.rear + 1) % this.capacity; this.length++; return true; } dequeue() { if (this.length === 0) { return undefined; } const front = (this.rear - this.length + this.capacity) % this.capacity; const value = this.queue[front]; this.length--; return value; } }

这个front = (rear - length + capacity) % capacity的式子,就是环形队列在“只记录 rear 和 length”时的核心推导。前端做直播弹幕、音视频帧缓冲、高频埋点批量上报时,这套结构可以直接用。

4.2 树状数组的模板化应用:区间求和与逆序对不是后端专利

树状数组(Binary Indexed Tree, BIT)听起来很“算法”,但前端也有它的生态位。表格类的产品经常要算“某段行号的数值总和”,比如数据面板里的区间聚合。直接每次遍历区间求和,数据量大了就慢,而树状数组能在 O(log n) 内完成单点更新和前缀和查询。

模板本身不长:

class FenwickTree { constructor(size) { this.tree = new Array(size + 1).fill(0); } lowbit(index) { return index & -index; } update(index, delta) { while (index < this.tree.length) { this.tree[index] += delta; index += this.lowbit(index); } } query(index) { let sum = 0; while (index > 0) { sum += this.tree[index]; index -= this.lowbit(index); } return sum; } rangeSum(left, right) { return this.query(right) - this.query(left - 1); } }

我在一个排班表项目里用它做“某两周内每个员工的总工时统计”,表格每次滚动只触发局部更新,性能比全量重算好很多。面试里你如果能从“数组”聊到 BIT,并且说明它在可编辑表格的区间统计场景上的价值,会明显比背题的人领先一截。

4.3 二维数组与矩阵操作:初始化陷阱和表格转置

热词里的“二维数组”也是个容易出现低级错误的点。最典型的问题是初始化:

const matrix = new Array(3).fill(new Array(4).fill(0)); // 每一行其实是同一个数组的引用 matrix[0][0] = 1; // matrix[1][0] 也变成了 1

new Array(4).fill(0)创建了一个数组,fill把这个数组的引用复制给了三行。正确初始化应该用两层循环或Array.from:

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

二维数组的实战场景包括表格数据矩阵、Canvas 像素缓冲区、棋盘游戏状态等。一个我常用来考察候选人的题目是矩阵转置:

function transpose(matrix) { return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); }

这个写法的巧妙之处在于用map同时完成列遍历和行遍历,不需要嵌套循环。理解了数组的映射语义,很多矩阵操作都可以用类似方式组合出来。

5. 大数据量下的数组性能:分片、传输与虚拟渲染

5.1 大文件分片上传:数组切割比你以为的更讲究

大文件上传是前端“数组实战”最典型的高频场景之一。核心思路是:用Blob.slice把文件切成多个分片,按顺序维护一个分片数组,逐个上传。

分片的大小直接决定并发和失败重试的粒度。我常用 2MB 到 5MB 一个分片,太小了请求太多,太大了单次失败重传代价高。切分代码很简单:

const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB const chunks = []; for (let start = 0; start < file.size; start += CHUNK_SIZE) { chunks.push({ index: chunks.length, blob: file.slice(start, start + CHUNK_SIZE), }); }

上传过程中,用数组记录每个分片的状态(pending / uploading / success / failed),失败的分片单独挑出来重试。这里有个实战细节:并发控制不要all上去,一般用 3 到 5 个并发即可。可以用一个简单的“滑动窗口”来调度:维护一个待上传的队列和一个正在上传的集合,每当有上传完成,就从队列里取出下一个补位。这个模式本质上是把数组当成任务池在用。

进度计算也别掉以轻心:不要用“已上传分片数 / 总分片数”来算百分比,因为每个分片大小不同。正确的是维护一个已上传字节数累加器,每成功一个分片就加上该分片的实际大小,再除以文件总大小。

5.2 Web Worker 里传递数组:结构克隆的巨大开销和 Transferable 方案

热词里有“前端使用 worker 上传大文件”“讯飞实时语音转写大模型前端适配”,这些场景都绕不开一个问题:主线程和 Worker 之间传输数组,到底怎么传最省内存?

新手最常见的写法是直接把数组postMessage过去:

worker.postMessage({ buffer: largeArray });

浏览器默认会做“结构化克隆”,也就是深拷贝一份整个数组。大数组会带来明显的耗时和内存翻倍。正确姿势是用Transferable转移所有权:

// 把底层缓冲区直接转移给 Worker,原线程不再持有数据 worker.postMessage({ buffer: largeArray.buffer }, [largeArray.buffer]);

转移之后,主线程那边的largeArray会变成空的,因为底层内存已经被“搬”走了。所以这种方案适用于单向大数据传输——比如实时语音转写时,主线程把音频帧的Uint8Array缓冲区转移给 Worker,Worker 处理完后再把结果转移回来。一来一回夹在两个内存区域之间,不会复制大块数据。

这里还要提一下“类型数组”。Uint8Array、Float32Array这些TypedArray是建立在ArrayBuffer之上的视图,它们和普通数组最大的区别是:元素类型固定、内存连续、长度不可变。处理二进制协议、WebGL 顶点数据、音频采样数据时,普通数组的对象数组结构反而不合适。热词里那些“c++ 多维数组 指针”“指针数组”在 JS 里的近亲其实就是类型数组——内存连续、按偏移访问,只是指针被隐藏了。

5.3 大量 DOM 与虚拟列表:数组是渲染窗口的“滑动窗口”

还有一个高频场景:后端一次性返回一万条数据,前端怎么渲染?直接全量生成 DOM,卡到爆。解决方案是虚拟列表,而虚拟列表的核心数据结构就是一个“窗口数组”。

做法是维护startIndex和endIndex,只渲染这个区间内的数据,滚动时更新区间。数据源本身不用复制——直接通过索引切片:

const visibleList = allData.slice(startIndex, endIndex + 1);

每次滚动都要重新计算visibleList,注意最好用requestAnimationFrame做节流,避免滚动一帧触发多次计算。窗口前后可以加一点“缓冲区”行(overscan),比如多渲染上下各 5 行,这样快速滚动时不会出现短暂的空白。这个思路在热词里的“markdown-it 渲染大量文字”“echart 闪烁怎么解决”中也很实用——大数据量下的核心原则就是“不要让数组和 DOM 一一对应”,而是让数组配合索引片段按需映射。

6. 手写数组核心方法与面试追问:光会 API 不够

6.1 手写 map / filter / reduce:循环背后的稀疏数组处理

面试现场手写数组方法,考察的是你对“遍历 + 回调 + 边界”的理解。一个不太容易想到的细节是:原生map会跳过稀疏数组的空槽。比如[1, , 3].map(...),中间的空位不会被调用回调,但返回的数组中该位置仍然是空槽。后来规范改了,map会保留稀疏性,Array.from等则会把空位填成undefined。

第一个版本,大多数人能写出循环 + 回调的形态:

Array.prototype.myMap = function (callback, thisArg) { const result = new Array(this.length); for (let i = 0; i < this.length; i++) { if (i in this) { result[i] = callback.call(thisArg, this[i], i, this); } } return result; };

i in this这个判断就是在处理稀疏数组。filter同样要考虑空槽跳过的问题,还要注意它返回的是新数组,并且长度可能比原数组短。reduce更复杂一点:初始值可以不传,不传时把第一个元素当初始值,并且从索引 1 开始遍历。

很多人认为reduce只是“求和”,但它其实是数组的“瑞士军刀”。用reduce可以重写map、filter、flat、flatMap,面试时可以主动展示这一点:

Array.prototype.myFlat = function (depth = 1) { return this.reduce((acc, item) => { if (Array.isArray(item) && depth > 0) { acc.push(...item.myFlat(depth - 1)); } else { acc.push(item); } return acc; }, []); };

6.2 数组转字符串的三条路:join、toString 和 String() 的差异

热词里“数组转字符串”看似基础,但三条路径的行为并不一样。

join(separator)最可控,可以指定分隔符,元素是null或undefined时会转成空字符串:

['a', 'b', null].join('-'); // 'a-b-'

toString()等价于join(','),但它被隐式调用——任何数组参与字符串拼接时都是这个结果:

['a', 'b'].toString(); // 'a,b'

String(array)内部也会调用toString,所以结果相同。需要注意嵌套数组转字符串时,每一层都会递归调用toString,比如[[1, 2], [3, 4]].toString()是'1,2,3,4'。如果你想把数组的原始结构保留下来,应该用JSON.stringify,而不是什么字符串转换方法。

6.3 类数组与类型数组:arguments、NodeList、Uint8Array 及跨语言类比

面试追问到这里,很多前端就卡壳了:JS 数组到底和其他语言数组有什么本质不同?这是个特别好的收尾话题。

JS 的“类数组”(array-like)是指有length属性和索引的元素集合,但不是数组,比如arguments、NodeList、HTMLCollection。它们的共性是可以用Array.from()或展开运算符转成真正的数组:

const nodes = document.querySelectorAll('div'); const nodeArray = Array.from(nodes);

Array.from还能接收第二个参数做映射,是“类数组转数组并加工”的一行流方案。

类型数组(TypedArray)则对应热词里那些偏底层的概念。Uint8Array之于ArrayBuffer,有点像 C 语言里的指针之于连续内存——只不过 JS 把指针隐藏了,你操作的是有类型的视图。和真正数组的差异包括:长度固定、只能存同一类型、没有push/map等方法(虽然可以通过包装实现类似操作)。所以二进制流处理只能用类型数组,这也是前端在音频、视频、WebAssembly 场景中绕不开的一环。

聊到这里,再回头看热词里那些“c# 不同的 class 可以组成数组吗”“vba 数组”“c++ 多维数组指针”——跨语言对比的核心其实就是一件事:数组在内存里是一段连续的同类型空间,语言越底层,你越要自己管这段空间;语言越上层,数组越像一个随时能增删改查的集合。JS 的数组动态扩容、可以混装不同类型的特性,既给了它便利,也意味着你在做高性能场景时要主动收敛类型、主动管理内存边界。

这六块内容,从 API 选型到框架联动,从数据结构到大数据量优化,再到手写源码和跨语言追比,基本覆盖了我这些年在前端项目里和“数组”交手的全部经验。如果你能把这一整套理解串起来——不是背 API,而是理解每个 API 背后的边界条件和适用场景——那日常开发里数组相关的坑,十有八九你都能提前避开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询