JavaScript队列实现与性能优化:从shift陷阱到异步任务调度

发布时间:2026/10/19 15:06:51
JavaScript队列实现与性能优化:从shift陷阱到异步任务调度 先讲件真实发生的事。之前接手一个前端图片压缩模块用户上传相册后脚本要把每张图都压缩一遍。最初的实现很直白用一个数组存待处理任务每处理完一张就调 shift 把数组第一项删掉。单张图片还好用户一次传了几百张任务量上万整个页面直接卡到近乎假死。排查之后发现原因不在压缩算法而是 shift 这个方法在作祟它每次删掉数组头部剩余所有元素都得跟着往前挪一格数据量一大这个成本会不断叠加。后来我把这套逻辑换成真正的队列数据结构同样一批图片处理顺畅了许多。这件事给我留下的印象特别深——JavaScript 队列不是面试才用的偏门概念它在真实项目里每天都在替我们干活。这篇入门文章就围绕 JavaScript 队列展开我打算用大概 5 分钟的信息量把核心讲透。不堆概念也不会把复杂度公式挂在嘴边吓人。咱们直接从实现入手先写一个能跑的 Queue再谈它为什么能解决刚才那种性能问题最后看看它在事件循环、广度优先遍历和异步任务调度里的真实位置。内容不短但足够你从零开始理解并真正用起来。1. 先来找找队列在真实项目里的影子1.1 从一次页面卡顿排查说起刚才那个图片压缩例子值得再拆开看。最初的代码大概是这个逻辑拿到用户选择的所有图片按顺序生成一个处理任务列表然后循环处理。处理完当前任务就把任务从列表里删掉自然要删第一个因为只有处理完第一个才能开始第二个。用数组实现时最容易写出的代码就是 shift。当任务只有几十个时这点移动成本根本感觉不到但当任务达到数千上万个shift 每次都要移动一整批元素页面主线程被大量无意义的数组搬移动占据浏览器自然卡顿。后来我做的改动其实很小把“数组 shift”改成“队列 指针”。入队任务时放到队尾出队任务时只记录队头位置向后移动一位不再搬动任何元素。同样的业务逻辑底层数据结构一变性能问题瞬间消失。这件事说明理解队列的价值不在于背几个方法名而在于知道它用什么样的方式组织数据、为什么这种组织方式适合“先进先出”。1.2 队列在前端的三类高频场景除了图片处理前端至少还有三类常见场景依赖队列。第一类是异步任务调度。浏览器内核维护着任务队列定时器、事件回调、需要执行的任务都会按顺序排队主线程空闲后从队头取任务执行。第二类是遍历。尤其是树的层级遍历、图的最短路径搜索都靠队列一层一层往外扩这就是广为人知的 BFS。第三类是并发控制。比如批量上传、请求限流、消息提示系统往往用一个队列缓存待处理任务再由调度器按下发能力逐个弹出执行。这些场景表面差异很大但底层都是同一件事按顺序维护一批等待被处理的项目。你把这些场景里的“顺序”二字抓住队列的概念就立住了。场景为什么用队列典型例子异步任务调度保证回调按触发顺序执行浏览器事件循环、定时器回调广度优先遍历需要按层、按距离逐级展开二叉树层序遍历、最短路径并发控制缓冲超出处理能力的任务批量上传、请求限流、消息队列2. 队列为什么是“先进先出”2.1 队列和栈的反差萌很多初学者学数据结构时第一个搞不清的就是队列和栈的区别。我用两个生活场景来记栈像叠盘子后放上去的盘子总会先被拿走队列像排队买奶茶谁先到谁先点单。所以栈有个外号叫 LIFOLast In, First Out后进先出队列叫 FIFOFirst In, First Out先进先出。如果你是后端转前端可能觉得这个概念太基础但把它放在 JavaScript 的数组操作里还真有人天天用错。数组的 push 和 pop 组合起来天然就是栈push 和 shift 组合则更像队列。问题是 shift 代价太高所以后面要自己造一个更合适的轮子。维度栈Stack队列Queue方向同一端进出尾部进、头部出原则后进先出先进先出生活类比叠盘子排队买奶茶数组示意push / poppush / shift或指针2.2 术语表enqueue、dequeue、front、back队列常用英语术语其实就四个。enqueue 是入队往队尾添加一个元素dequeue 是出队从队头移除一个元素front 是队头也就是下一次出队时最先被拿到的元素back 或 rear 是队尾最新入队的元素位置。另外还有 isEmpty 判空、size 返回长度。掌握这几个词看任何语言的队列实现都能对应上。术语中文含义典型实现enqueue入队在队尾放入元素pushdequeue出队从队头移除元素shift 或移动指针front / peek队头查看下一次将被移除的元素items[headIndex]back / rear队尾最新加入的元素items[tailIndex - 1]isEmpty判空队列是否为空headIndex tailIndex2.3 Front和Back谁在队首细节别搞反这里有一个被初学者反复问的问题数组里索引 0 到底是 front 还是 back不同的教材实现不一样不必迷信某一个。关键是自洽。本文所有实现都约定索引 0 或 headIndex 指向的是队头 front数组末尾或 tailIndex - 1 指向队尾 back。入队从尾部加出队从头部取。只要明确了约定代码就不会乱。如果你用“数组 push 出队 unshift 入队”这种相反方向逻辑上也成立但 unshift 依然是 O(n)而且很容易把自己绕晕。所以入门阶段建议固定成“尾部入队、头部出队”这一种习惯后面所有示例都会沿用这个约定。3. 20行代码实现第一个队列3.1 最朴素的数组版队列先把最简单的版本写出来。它很直观所有方法都能在教科书上找到影子。class Queue { constructor() { this.items []; } enqueue(item) { this.items.push(item); } dequeue() { return this.items.shift(); } front() { return this.items[0]; } isEmpty() { return this.items.length 0; } size() { return this.items.length; } }用法如下。enqueue 依次放入 A、B、Cdequeue 拿回 A因为它是第一个进去的。front 不会移除元素只是看看队头是谁。const queue new Queue(); queue.enqueue(A); queue.enqueue(B); queue.enqueue(C); console.log(queue.dequeue()); // A console.log(queue.front()); // B console.log(queue.size()); // 2 console.log(queue.isEmpty()); // false如果你只是写个小 Demo这个版本完全够用。可一旦队列里躺着几千甚至上万个任务shift 的短板就会暴露。3.2 为什么不建议用 shift 出队JavaScript 数组在引擎层面通常用连续内存保存元素。当执行 shift 删掉第一个元素时为了让索引从 0 开始继续保持连续后面的每个元素都要往前搬一格。你可以想象一排椅子抽走最前面一把后面的人必须全部站起来挪一个位置。这个动作的时间复杂度是 O(n)n 是数组长度。一个队列如果有 1 万个元素出队一次就要移动 9999 次。如果同时处理几千个任务总成本就从“几千次操作”变成“几千万次操作”页面卡顿就是这样来的。要验证也很简单你打开浏览器开发者工具的 Console跑一下这段代码数据量越大差距越明显。const n 100000; const arr []; for (let i 0; i n; i) arr.push(i); console.time(shift); while (arr.length 0) arr.shift(); console.timeEnd(shift);终端打印出来的耗时就是 shift 的真实代价。正因为这种成本生产环境很多队列库不会直接用 shift 做 dequeue。3.3 用索引来代替 shift 的优化版上一节的数组版队列入队 O(1) 没问题出队可能 O(n)。换一种思路不真正删除头部元素而是移动一个“队头指针”。这样出队也只是修改索引不再搬动任何元素平均复杂度变成 O(1)。class Queue { constructor() { this.items {}; this.headIndex 0; this.tailIndex 0; } enqueue(item) { this.items[this.tailIndex] item; this.tailIndex; } dequeue() { if (this.isEmpty()) return undefined; const item this.items[this.headIndex]; delete this.items[this.headIndex]; this.headIndex; return item; } front() { return this.items[this.headIndex]; } isEmpty() { return this.headIndex this.tailIndex; } size() { return this.tailIndex - this.headIndex; } }这里用对象保存元素key 就是队列里的位置。enqueue 时在 tailIndex 处放元素tailIndex 加一dequeue 时取 headIndex 处元素然后 delete 掉headIndex 加一。delete 只是让那个 key 消失不会触发大范围搬移。虽然对象哈希查找也有开销但整体上比每次移动几千个元素稳得多。耗时的核心其实是工程实现选择而不是队列本身。你要在小 Demo 中图省事用数组版没问题一旦涉及长任务、大批量数据换成索引版几乎不需要改业务代码收益却非常明显。3.4 测试几个边界条件写完队列最好立刻验证三种边界情况空队列出队、元素全部出队后再次入队、和 front 不改变队列状态。下面这段测试可以丢到浏览器 Console 直接跑。const q new Queue(); console.log(q.dequeue()); // undefined console.log(q.isEmpty()); // true q.enqueue(1); q.enqueue(2); console.log(q.front()); // 1 console.log(q.dequeue()); // 1 console.log(q.dequeue()); // 2 console.log(q.isEmpty()); // true q.enqueue(3); console.log(q.size()); // 1 console.log(q.front()); // 3注意空队列时 dequeue 返回 undefined 是一个设计选择而不是 JS 报错。有的编程语言会抛出异常但对业务不友好。JavaScript 项目里返回 undefined 通常更安全。4. 队列在前端生态里的真实应用4.1 事件循环与消息队列前端第一堂队列课应该从事件循环开始。浏览器主线程是一个单线程环境同一时间只能做一件事。那些正在等待的回调、定时器、用户事件都需要排队等着被执行。事件循环的职责就是执行完当前任务后从任务队列里取出下一个任务继续执行。这个队列天然是 FIFO因为回调是按触发顺序入队的后面触发的不能插队除非是微任务的特殊处理。理解了这一点很多执行顺序问题就不需要死记。你也许听过“宏任务队列”和“微任务队列”的区别。严格来说它们不是同一个队列而且微任务在每一轮事件循环结束时会一次性清空。但归根结底它们都属于队列思想在前端异步模型中的体现。先把 FIFO 这个根抓住再去研究轮次和优先级会轻松很多。4.2 BFS 与树的层级遍历数据结构面试里队列最常见的搭档是广度优先搜索BFS。举一个非常经典的需求输入一棵二叉树按层级输出节点值。function levelOrder(root) { if (!root) return []; const result []; const queue []; queue.push(root); while (queue.length 0) { const levelSize queue.length; const currentLevel []; for (let i 0; i levelSize; i) { const node queue.shift(); currentLevel.push(node.value); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(currentLevel); } return result; }运行时最先入队的是根节点然后左右子节点入队。因为队列 FIFO所有同一层的节点一定会在下一层节点之前被处理所以结果天然按层排好。如果用栈一层节点会被从右到左甚至错乱顺序取出思路就不对了。这就是队列在算法里的不可替代性。需要承认上面为了可读性用了 shift如果树的节点特别多也可以换成我们写的索引版队列。面试中重点讲清逻辑再补一句“工程上我会用指针实现避免 O(n)”印象分会高不少。4.3 生产节流与异步批量任务调度再贴近工程一点。假设你写了一个批量上传组件服务端只能同时处理两个请求但用户一下子选了 50 张图。你不能把 50 个请求同时打出去也不能让后面的任务永远干等。最朴素的方案就是动态队列把任务全部放进一个队列最多启动两个 worker每个 worker 处理完当前任务后自动从队列头取下一条任务。下面这个实现只保留核心逻辑业务可自行扩展。function worker(queue, onDone) { function next() { if (queue.length 0) return onDone(); const task queue.shift(); Promise.resolve(task()) .finally(() next()); } next(); } async function runWithLimit(tasks, limit 2) { const queue [...tasks]; const workers []; for (let i 0; i limit; i) { workers.push(new Promise(resolve worker(queue, resolve))); } await Promise.all(workers); }这里用 shift 纯粹是为了演示简单数据量大时同样建议换指针。你也可以把任务写成返回 Promise 的函数然后把 runWithLimit 用于并发上传、并发拉取分页数据、或者实现简单的批处理队列。不用额外的库用队列思想就能解决这类并发限制问题。5. 进阶一步循环队列、优先队列和双端队列5.1 循环队列把空间省到极致普通的动态队列头部指针一直向后移动前面空出来的位置无法再利用这在固定内存、高性能场景下是浪费。循环队列的思路是把存储区域看成一个环当 tailIndex 或 headIndex 超过容量时用取模运算绕回开头。它常被用在环形缓冲区、音频流处理、硬件 FIFO 等场景。下面是最小实现固定容量 capacity入队前判断是否已满。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.count this.capacity) return false; this.items[this.tail] item; this.tail (this.tail 1) % this.capacity; this.count; return true; } dequeue() { if (this.count 0) return undefined; const item this.items[this.head]; this.head (this.head 1) % this.capacity; this.count--; return item; } isFull() { return this.count this.capacity; } size() { return this.count; } }要留意 count 这个字段它比传统 head/tail 判断更容易理解。head 和 tail 相对位置有时会出现 head tail 却一空一满两种状态引入 count 可以清晰区分。5.2 优先队列让“重要的先走”普通队列严格按照入队顺序但业务经常说“这个任务更重要先做”。优先队列的核心是元素除了值之外还有一个优先级。出队时不再取最先入队的元素而是取优先级最高的元素。入门阶段可以用一个很朴素的数组实现每次入队时按优先级排序出队时直接从头部取。复杂度不好看但思路清楚。class PriorityQueue { constructor() { this.items []; } enqueue(item, priority 0) { this.items.push({ item, priority }); this.items.sort((a, b) a.priority - b.priority); } dequeue() { if (this.items.length 0) return undefined; return this.items.shift().item; } }这段代码用 sort 让优先级小的排前面priority 越小越重要。每次插入排序 O(n log n)出队 shift 又 O(n)。真实生产环境一般会用二叉堆把入队和出队做到 O(log n)但二叉堆单独写一篇文章才讲得透。先把概念跑通再往堆方向进阶即可。5.3 双端队列两面都能进出双端队列Deque允许在头部和尾部同时插入和删除。前端里滑动窗口算法、历史记录、撤销重做等都有它的影子。JavaScript 数组本身提供了 push、pop、unshift、shift 四种方法天然就是最简单的双端队列。但如果数据量大unshift 和 shift 同样会触发整体搬移。更高效的双端队列应该用双向链表或两个栈来模拟。这里不展开代码只提供数组版的使用示例帮助你识别这个模式。const deque []; deque.push(尾部入队); deque.unshift(头部入队); deque.pop(); // 尾部出队 deque.shift(); // 头部出队建议你把这个当作一个引子遇到两边都能操作的场景先想到 Deque如果性能扛不住再研究双向链表版本。6. 写队列时最容易踩的三个坑6.1 用数组 shift 出队数据量一大就卡这是所有队列实现里最常见的性能坑也是全文反复强调的重点。刚开始学习时很多人写队列都直接套数组shift 出来一个就删一个。小数据量感知不到问题等数据规模上来页面卡顿、接口超时接踵而至。我之前遇到的那个图片压缩模块就是活例子。解决办法不是记住一句“不要用 shift”而是理解数组为什么删头成本高。连续内存结构决定了删除头部必须移动所有后续元素。如果队列操作很频繁请选择对象 指针的方式。要是业务代码已经写完了替换也只是把 items 从数组换成对象把 shift 改成 headIndex 自增改动面很小。6.2 把队列和栈的出入方向搞混第二个坑来自概念混淆。有一类经典题目用两个栈实现队列。很多人会把栈的 pop 顺序误当成队列的出队顺序。其实思路很清晰一个栈专门入队另一个栈专门出队。class QueueUsingTwoStacks { constructor() { this.inStack []; this.outStack []; } enqueue(item) { this.inStack.push(item); } dequeue() { if (this.outStack.length 0) { while (this.inStack.length 0) { this.outStack.push(this.inStack.pop()); } } return this.outStack.pop(); } }入队时直接 push 到 inStack。出队时如果 outStack 是空的就把 inStack 里的元素一个个弹出来再 push 进 outStack顺序反转一次后最后弹出的自然是队列头部。这种题能帮助你更深地理解栈和队列的差异值得自己跑一遍。6.3 没有考虑空队列和唯一元素第三个坑是边界条件。有的同学实现 dequeue 时默认队列不为空空队列一调就报错有的同学没注意唯一元素出队后的状态。一个健壮的队列空队列出队应该返回 undefined或者按项目约定抛出明确错误而不是让程序崩掉。处理唯一元素时出队后 headIndex 和 tailIndex 会相等此时 isEmpty 必须返回 true。另外用对象 指针的版本长期运行后headIndex 和 tailIndex 会不断变大。虽然 delete 已经释放了元素但索引本身可能无限增长。绝大多数场景不会触及 JavaScript 数字上限但如果你在写一个长期运行的服务可以加一个压缩方法把现有元素从 0 开始重新排列重置指针。compact() { if (this.headIndex 0) return; const items {}; let index 0; for (let i this.headIndex; i this.tailIndex; i) { items[index] this.items[i]; index; } this.items items; this.tailIndex index; this.headIndex 0; }这里的分段和重新赋值也能帮助你熟悉对象遍历。工程上不是每次都要压缩而是当 headIndex 超过某个阈值时触发一次兼顾性能和干净程度。7. 从零写一个可复用的任务调度队列7.1 场景设计一个带暂停能力的小型任务队列前面几节介绍了队列本身的实现和几个经典应用现在把它们拼成一个更接近工程实物的类。这个小队列支持添加任务、限制并发数、暂停与恢复。代码不长却同时用到了队列、并发、状态管理三个概念。class TaskQueue { constructor(concurrency 2) { this.concurrency concurrency; this.queue []; this.running 0; this.paused false; } add(task) { this.queue.push(task); this.next(); } pause() { this.paused true; } resume() { this.paused false; this.next(); } get size() { return this.queue.length; } next() { if (this.paused || this.running this.concurrency) return; if (this.queue.length 0) return; const task this.queue.shift(); this.running; Promise.resolve() .then(task) .finally(() { this.running--; this.next(); }); } }构造一个实例后连续 add 十个任务它只会同时执行两个跑完一个立刻从队列里补充一个。pause 会阻止 next 继续启动新任务但已经在跑的任务不会被强制中断。resume 之后队列会从上次停下的位置继续推进。这个类其实就是一个迷你调度器理解了它很多异步并发库的内部就不再神秘。7.2 给队列增加可观察的日志能力调试工程队列时最怕“不知道现在排到谁了”。我的习惯是给关键方法加上回调入队、出队、任务完成各留一个钩子。下面改动只展示思路。class ObservableTaskQueue extends TaskQueue { constructor(concurrency, options {}) { super(concurrency); this.onEnqueue options.onEnqueue || (() {}); this.onDequeue options.onDequeue || (() {}); this.onTaskDone options.onTaskDone || (() {}); } add(task) { this.onEnqueue(task, this.size); super.add(task); } next() { if (this.paused || this.running this.concurrency) return; if (this.queue.length 0) return; const task this.queue.shift(); this.onDequeue(task, this.size); this.running; Promise.resolve() .then(task) .finally(() { this.running--; this.onTaskDone(task, this.size); this.next(); }); } }加入日志后你在 Console 里就能看到任务从入队到完成的完整流动过程。这种可观测性在排查异步问题时会救你一命任务是不是堆积了、并发数是不是没释放、哪个任务执行时间异常一眼就能看出来。7.3 自测建议跑通这组断言再上业务最后给一组非常朴素的自测代码你可以把它当成队列组件的验收清单。覆盖核心接口和边界情况后再接到业务里会踏实很多。const q new Queue(); const order []; order.push(q.dequeue()); // undefined q.enqueue(1); q.enqueue(2); order.push(q.dequeue()); // 1 order.push(q.front()); // 2 order.push(q.size()); // 1 order.push(q.dequeue()); // 2 order.push(q.isEmpty()); // true console.log(order);实际项目里可以用自带的断言库甚至写成一个本地脚本。重点不是测试框架而是把空队列、单元素、连续出队这些情况全部覆盖。队列的接口很简单测试就是要逼自己在替换成循环队列、优先队列之后仍然保证对外行为不变。写这篇入门的时候我一直在回忆最开始踩 shift 坑还没解决的问题。如果只留一条经验给你我想说队列不是一堆需要死记的方法名而是一种“先来后到”的思维方式。你不需要背 enqueue 和 dequeue 的拼写但你需要能在一堆待处理任务里看到队列的影子。去写一个并发调度器跑一组几万条的任务数据亲眼看看 shift 版本的卡顿和指针版本的顺滑比盯着任何文章都管用。到那个时候判断数组方法该不该用就不需要别人提醒了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询