
第一次在JavaScript里接触到队列Queue的时候我脑子里冒出来的第一个念头是这不就是个数组吗后来真到做异步任务调度、做消息推送、写广度优先遍历才反应过来队列远不止“先来后到”这么简单。今天这篇笔记就围绕JavaScript里的队列数据结构聊聊它的核心原理、三种实现方式、进阶玩法以及那些只有踩过坑才会记住的细节。不管你是刚学前端的新手还是写过一阵业务代码但没系统梳理过数据结构的开发者这篇都能帮你把队列这块补扎实。很多人学数据结构习惯从数组、链表入手队列往往被一笔带过。但在实际开发里队列的身影无处不在浏览器的事件循环、消息队列、网络请求的并发控制、前端的状态派发……可以说理解了队列等于拿到了读懂JavaScript异步机制的一把钥匙。我会用尽可能贴近实战的方式把队列从原理到实现再到应用串起来你可以直接照着代码敲一遍五分钟左右就能跑通。1. 为什么需要队列——从现实排队说起队列这名字本身就是从生活里借来的。食堂打饭、银行叫号、地铁进站全都是“先来的先服务后来的排队等着”。这种规则在计算机世界里有个正式称呼先进先出英文叫First In First Out缩写FIFO。与之相对的栈是后进先出LIFO。很多初学者分不清这两个结构其实只要记住一个生活场景洗盘子的时候你总是拿最上面的那个盘子这是栈而排队打饭站在最前面的那个先打这是队列。1.1 先进先出FIFO到底是怎么一回事队列的数据结构可以看成一条单行道。数据从一端进去这一端叫队尾tail数据从另一端出来这一端叫队头head。核心操作只有两个入队enqueue把数据放到队尾出队dequeue把队头的元素取出并从队列里移除。另外还需要一个读取队头但不移除的peek方法以及一个判断是否为空的方法。我用JavaScript模拟一个最简单的心理模型// 想象一个队伍 const queue []; // 入队队伍尾部进人 queue.push(A); queue.push(B); queue.push(C); // 出队队伍头部离开 queue.shift(); // 移除 A这里用数组的push和shift模拟入队和出队方向完全正确。但是要注意如果你去做性能测试频繁调用数组的shift并不划算原因我们后面细说。队列的FIFO特性决定了它天然适合“顺序处理”的场景谁先来谁先被处理谁也不插队。1.2 JavaScript里哪些场景天生就适合队列先说一个最常见的异步任务调度。JavaScript是单线程语言处理网络请求、定时器、事件回调时都会把任务塞进一个队列然后按照顺序依次执行。虽然我们平时写代码不会直接操作事件循环的队列但如果你要自己实现一个“限制最大并发数”的请求池就需要手动维护一个任务队列。再比如说操作批量导入。假设用户上传了一个Excel文件里面有几千条记录后端一次只允许处理100条直接一股脑全部发送会超时。这时候可以定义一个队列每次从队列里取出一批任务发送收到成功回调后再取下一批。这样做顺理成章代码可读性也高。还有一个非常典型的场景是BFS也就是广度优先搜索。在一张图或者树结构里我们要一层一层地往外遍历每一层的节点放进队列然后逐个取出处理再把下一层节点入队。这个算法本质上就是队列的教科书应用后面我会给一个实际例子。简单总结一下凡是符合“先到先处理”规则、需要自然顺序和公平性的场景队列都是首选结构。如果在代码里看到大量“数组头部删除、尾部追加”的操作大概率都可以抽象成队列来管理。2. 五分钟手写队列三种实现逐个拆解纸上谈兵结束了现在直接上手写代码。我会按照由简到繁的顺序给出三种队列实现数组版、对象版、链表版。第一种适合理解概念第二种适合日常工程使用第三种适合做算法题或需要频繁插入删除的场景。2.1 基于数组的最简实现这是初学者最先想到的写法。利用JavaScript数组天然提供push和shift方法代码量可以压缩到十行以内。class ArrayQueue { constructor() { this.items []; } enqueue(item) { this.items.push(item); } dequeue() { if (this.isEmpty()) return undefined; return this.items.shift(); } peek() { if (this.isEmpty()) return undefined; return this.items[0]; } isEmpty() { return this.items.length 0; } get size() { return this.items.length; } }这个实现最大的好处是简单直观适合学习和演示。但问题也很明显数组的shift方法会移动后面所有元素的下标。比如队列里有10000个元素执行一次shift后面9999个元素都要往前挪一位时间复杂度是O(n)。如果你在循环里大量出队会出现明显的卡顿。所以在性能敏感的场景我通常不建议直接用数组shift来实现队列。需要特别提一点不要用unshift配合pop来实现虽然看起来也能凑合但同样存在数组头部插入导致的元素整体移动问题时间复杂度和shift一样是O(n)。2.2 基于对象的性能优化实现解决shift性能问题最常用的一种思路是引入两个指针也就是头指针和尾指针用普通对象存储数据。入队时把数据挂在tail对应的键上tail加一出队时读取head对应的键然后删除这个键head加一。class ObjectQueue { constructor() { this.storage {}; this.head 0; this.tail 0; } enqueue(item) { this.storage[this.tail] item; this.tail; } dequeue() { if (this.isEmpty()) return undefined; const item this.storage[this.head]; delete this.storage[this.head]; this.head; return item; } peek() { return this.storage[this.head]; } isEmpty() { return this.head this.tail; } get size() { return this.tail - this.head; } }这段代码的关键在于head和tail永远不会往回退只是不断递增。所以就算队列里最后只剩一个元素head和tail之间也可能存在差值storage对象里还会残留一些已经被删除项留下的空洞索引不过它们对应的值已经被delete了内存是释放的。我自己的经验是在业务代码或工具函数里这个对象版队列的性价比非常高。它不需要额外维护链表节点代码也容易读懂而且入队、出队的时间复杂度都是O(1)。唯一需要注意的就是size的计算要用tail减head而不是数对象里有多少个键。2.3 基于链表的扩展实现如果你正在刷LeetCode或者需要频繁在队列中间做插入删除操作链表版会更灵活。链表队列的核心思想是维护两个节点引用一个指向队头一个指向队尾。入队时在tail后面接新节点出队时让head指向原来的下一个节点即可。class Node { constructor(value) { this.value value; this.next null; } } class LinkedListQueue { constructor() { this.head null; this.tail null; this.count 0; } enqueue(value) { const node new Node(value); if (this.tail) { this.tail.next node; this.tail node; } else { this.head node; this.tail node; } this.count; } dequeue() { if (!this.head) return undefined; const value this.head.value; this.head this.head.next; if (!this.head) { this.tail null; } this.count--; return value; } peek() { return this.head ? this.head.value : undefined; } isEmpty() { return this.head null this.tail null; } get size() { return this.count; } }链表实现的好处是出队时不需要考虑数组元素移动也不像对象版那样会留下稀疏索引。缺点是每个节点都要创建一个对象在数据量小的时候反而显得浪费。我个人看法是如果需要手写队列首选对象版如果是做算法题链表版更通用数组版适合快速演示别用在生产环境大量数据场景就行。3. 队列的进阶玩法双端、循环与优先队列掌握了基础队列之后很多场景会对队列做一些变形。常见的有三种双端队列、循环队列、优先队列。它们仍然是队列的思想但各自解决了特定问题。3.1 双端队列既能从头部出也能从尾部进双端队列Deque允许在队头和队尾两端进行插入和删除操作。它更像一个“两头都能进出的数组”。在JavaScript里业务中用到双端队列最多的场景是撤销/重做功能、浏览器历史记录、或者滑动窗口最大值这类算法题。实现双端队列最简单的方式是直接基于对象扩展增加unshift和pop对应的方法。我这里给一个核心思路class Deque { constructor() { this.storage {}; this.head 0; this.tail 0; } addFront(item) { this.head--; this.storage[this.head] item; } addBack(item) { this.storage[this.tail] item; this.tail; } removeFront() { if (this.isEmpty()) return undefined; const item this.storage[this.head]; delete this.storage[this.head]; this.head; return item; } removeBack() { if (this.isEmpty()) return undefined; this.tail--; const item this.storage[this.tail]; delete this.storage[this.tail]; return item; } isEmpty() { return this.head this.tail; } }注意addFront里是先让head减一再赋值。这样保持“head到tail之间全是有效元素”的区间概念。之前我在写的时候容易犯一个错误addFront里先赋值再减一结果出队时读错了位置。调试了半天才发现是索引顺序写反了。双端队列写起来细节多还是要先在纸上把索引流程画一遍再动手。3.2 循环队列解决数组shift()的性能顽疾循环队列是一种用定长数组实现、但可以复用空间的队列。它的核心是把数组逻辑上看成一个环形tail到了末尾之后再回头从0开始。这样做的好处是在固定容量的场景下不需要频繁删除和搬迁元素。典型的应用场景是数据缓冲比如音频流处理、控制台日志门面、定时任务轮询池。循环队列的代码比普通队列稍复杂关键在于两个指针的取模运算。class CircularQueue { constructor(capacity) { this.capacity capacity; this.items new Array(capacity); this.head 0; this.tail 0; this.count 0; } enqueue(item) { if (this.isFull()) return false; this.items[this.tail] item; this.tail (this.tail 1) % this.capacity; this.count; return true; } dequeue() { if (this.isEmpty()) return undefined; const item this.items[this.head]; this.head (this.head 1) % this.capacity; this.count--; return item; } peek() { return this.items[this.head]; } isEmpty() { return this.count 0; } isFull() { return this.count this.capacity; } get size() { return this.count; } }这里用了模运算(this.tail 1) % this.capacity相当于让指针在0到capacity-1之间循环跳转。我建议你在本地多打印几次tail和head的变化观察它们从5变到0的过程这样比硬记公式有用得多。另外要留意循环队列的判空和判满不能用单一指针相等来判断必须维护一个count字段否则空队列和满队列的状态会混淆。3.3 优先队列让紧急任务插队优先队列不再是单纯的FIFO而是每个元素带一个优先级出队时总是返回优先级最高的那个。它通常用堆来实现但在JavaScript里业务场景数据量不大时也可以用数组配合排序来模拟。我举一个简单例子一个后台任务系统普通日志任务优先级是1数据同步任务优先级是3紧急修复任务优先级是10。当多个任务都在队列里时应该让紧急修复任务先执行。最简单的优先队列实现就是每次入队时按优先级插入合适位置或者出队时遍历找出最大值。遍历的复杂度是O(n)数据量大不划算但如果你就几十上百个任务代码简单反而更可靠。class PriorityQueue { constructor() { this.items []; } enqueue(item, priority) { const entry { item, priority }; let added false; for (let i 0; i this.items.length; i) { if (entry.priority this.items[i].priority) { this.items.splice(i, 0, entry); added true; break; } } if (!added) { this.items.push(entry); } } dequeue() { return this.items.shift()?.item; } peek() { return this.items[0]?.item; } }这里的splice虽然也是数组操作但因为插入位置通常比较靠前数据量不大时性能可以接受。如果要处理大规模数据那就该考虑用二叉堆实现真正的优先队列了那是另一个话题。记住一个原则优先队列改变了出队顺序但它依然要有明确的“先来后到”兜底也就是优先级相同时先入队先出队这才稳定。4. 队列在实战中的典型应用与踩坑记录队列不只是一个数据结构练习题它几乎是很多系统的基础组件。我在实际项目里用过队列的地方非常多从简单的前端请求控制到复杂的消息中转都离不开它。这一小节讲讲实战中怎么用队列以及容易翻车的几个点。4.1 异步任务队列与消息队列最常见的一种是异步任务队列。比如前端需要并发上传多张图片但服务器限制了最大并发数是3。一个靠谱的写法是维护一个请求任务队列每次最多同时执行3个任务某个任务结束就立刻从队列里取出下一个。我在给某跨平台系统做文件上传功能时就用了一个简单的循环调度队列class TaskQueue { constructor(concurrency) { this.queue new ObjectQueue(); this.concurrency concurrency; this.activeCount 0; } add(task) { this.queue.enqueue(task); this.next(); } next() { while (this.activeCount this.concurrency !this.queue.isEmpty()) { const task this.queue.dequeue(); this.activeCount; Promise.resolve(task()) .finally(() { this.activeCount--; this.next(); }); } } }这个设计里add不断往队列里塞任务next是一个触发开关只要并发数没满就不断从队列里取任务执行。关键点在于finall回调里的next它保证了任务结束后会立刻替补新任务而不是等所有任务都结束才继续。很多初学者容易把逻辑写成“每添加一个任务就执行一次”这样一旦任务添加速度远大于执行速度瞬间就会并发爆炸把服务器打挂。定期轮询的定时任务也可以这么做把下一次要执行的定时任务放进优先队列每次循环从头取一个最近要执行的任务等待到时间就触发然后继续。这种实现比单纯用setInterval更可控因为每执行完一个任务都能更新队列。4.2 BFS广度优先搜索与队列的配合BFS在树和图中太常用了而队列就是BFS的天然搭档。比如查找一个组织结构里的岗位路径或者在地图网格上求最短步数都可以用BFS。我以“模拟项目X”中的一个迷宫寻路Demo来举例。假设迷宫是一个二维数组1表示墙0表示路从起点出发每次向上、下、左、右走一步需要输出到达终点的最少步数。BFS的思路就是逐层往外扩张而队列用来保存每一层待探索的格子。function bfs(grid, start, end) { const rows grid.length; const cols grid[0].length; const queue new ObjectQueue(); const visited Array.from({ length: rows }, () new Array(cols).fill(false)); const directions [[-1, 0], [1, 0], [0, -1], [0, 1]]; queue.enqueue({ x: start.x, y: start.y, step: 0 }); visited[start.x][start.y] true; while (!queue.isEmpty()) { const current queue.dequeue(); if (current.x end.x current.y end.y) return current.step; for (const [dx, dy] of directions) { const nx current.x dx; const ny current.y dy; if (nx 0 nx rows ny 0 ny cols !visited[nx][ny] grid[nx][ny] 0) { visited[nx][ny] true; queue.enqueue({ x: nx, y: ny, step: current.step 1 }); } } } return -1; }这里的核心技巧是入队时立刻标记visited而不是出队时再标记。很多人第一次写会踩这个坑如果在出队时才标记已访问队列中同一个格子可能被多个邻居重复加入导致队列膨胀几倍甚至死循环。我排查过不少同学写的BFS十有八九是这个原因。所以记住一个口诀入队即锁定出队只处理。4.3 实战中容易踩的三个坑第一个坑用数组shift模拟出队导致大数据量时卡顿。我遇到过某图像处理Demo里频繁对队列进行shift操作处理到上千元素时界面明显卡住。后来改成对象版队列卡顿问题立刻缓解。性能分析工具里能看到shift导致的大规模内存拷贝这比耗时函数更难发现。第二个坑队列的容量控制缺失。如果不限制队列长度在消息生产速度大于消费速度时队列会无限增长内存迟早被吃掉。我见过一个某日志上报模块接口偶发故障时队列从几百条涨到几十万条页面直接白屏。后来加了最大长度限制和“溢出丢弃策略”才解决。这里提醒一句队列不是无限缓冲区实现的时候一定要考虑溢出的处理策略。第三个坑注意队列中存的是引用还是值。如果入队的是一个对象你在外部修改了这个对象的属性队列里的数据其实也变了因为存的是同一份引用。特别是涉及到异步操作时这个问题非常隐蔽。我自己在做轮询任务时把任务对象放进了队列后来在循环里复用了同一个对象变量导致所有任务都拿到的是最后一份数据。解决办法是入队时复制一份快照或者用不可变数据。5. 面试自测与学习路线建议队列作为数据结构里最基础的结构之一面试中出现频率很高。但面试官很少直接问“队列是什么”更多是结合场景来考察。把队列和具体问题结合起来思考才是真正的能力。5.1 高频面试题与思考方向题目一用两个栈实现一个队列。这个题考察的是你对栈和队列特性差异的理解。思路是在一个栈里做入队操作出队时如果另一个栈为空就把第一个栈里的元素全部倒过去再弹出栈顶。这样做最坏情况下出队是O(n)但均摊是O(1)。题目二击鼓传花或约瑟夫环问题。循环队列可以模拟一圈人依次报数每数到指定数字就淘汰一个人。这个问题在教材里经常出现实际中也对应轮询调度算法。题目三最近的请求次数。每次调用ping时传入一个时间戳需要统计过去3000毫秒内的请求次数。这个题用队列最合适新请求入队队列头部如果时间太旧就不断出队最后队列长度就是结果。很多所谓的设计类题目背后都是队列思想。还有就是你了解的异步队列和消息队列有什么区别这个问题没有标准答案但可以围绕“进程内调度”和“跨系统通信”来答。异步队列解决的是单机内的任务顺序问题消息队列是分布式系统中解耦组件用的前者可以用普通队列模拟后者通常依赖专业的消息中间件。5.2 从入门到精通的练习清单如果你想把队列玩扎实我的建议是别只看文章还是要亲自动手。可以按这个顺序练习第一先手写一个普通队列分别用数组、对象、链表实现三种版本并写一个简单的测试去验证入队、出队、peek、isEmpty的行为。重点感受一下对象版的指针移动逻辑。第二用双端队列解决一个实际问题。比如实现一个撤销快捷的草稿功能用户输入历史可以前进后退这相当于用两个队列或者栈维护状态。第三写一个循环队列容量设置成5反复入队出队20次打印每一步的head、tail、count观察指针如何回绕。这一步能帮你彻底拿下取模运算。第四用优先队列实现一个带优先级的任务调度器比如普通任务、中等任务、紧急任务模拟它们按照优先级执行的顺序。这个练习能让你理解为什么有的任务突然“插队”了。每完成一个练习都把你自己的代码和本文代码做对比问自己如果数据量变成一万条我的写法会不会崩。带着这个问题去排查你会比只抄代码的人多走很远。最后分享一个小经验队列这个东西看起来简单但它几乎是所有复杂异步逻辑的地基。我自己写队列踩过的坑大多数都不是建队列本身而是使用队列时忽略了生命周期和容量。如果你现在刚开始学不用急着弄懂所有进阶队列变形先把“入队出队为什么是O(1)”这个点吃透再把BFS代码多默写几遍很快你就能在业务里自然地用上队列。等你真正体会到“一个队列解决了一个并发问题”的爽感就会明白这个入门花五分钟绝对值。