前端队列,我是怎么从 push + shift 换成游标的
平时写代码,总会碰到这种需求:一次性拿到一批任务,要一个一个处理;或者一批请求,要控制同时最多发几个,其余的排队等着。处理完一个,再从剩下的里面拿下一个。
这种"排着队、挨个来"的场景,就需要一个容器帮我们记住顺序:谁先放进去,谁先被拿出来。这个容器就是队列。
那前端要实现一个队列,第一反应会想到什么?大概率就是数组。
一、最直觉的做法:push 入队,shift 出队
js
const queue = []
// 入队
queue.push(task)
// 出队
const task = queue.shift()
说实话,这个写法挑不出毛病。语义清晰,代码干净,push 往后加,shift 从前面拿,天然就是队列。长度不大的时候,它跑得好好的。
问题出在数据量上去以后。
shift() 干的事情是:把第一个元素摘掉,然后把后面所有元素整体往前挪一位。队列里有 10 个元素,挪 9 个;有 10 万个元素,每出一次队就要搬 10 万次。你循环把队列消费空,整体代价就是平方级的。
平时数组短,感知不到。但有些场景队列会真的堆到很大------比如日志、埋点攒着批量上报,断网重连后积压的一堆消息等着处理,大文件分片上传时一长串分片排队等着发。这种时候 shift 的代价就会从"理论上慢"变成"肉眼可见地卡主线程"。
所以问题很清楚:push 没问题,慢的是 shift。
二、换个思路:出队为什么要动数组?
盯着 shift 看一会儿,会发现一个关键点:它慢,是因为它非要"删除"元素。可队列出队,真的需要把元素从数组里删掉吗?
不需要。
我们真正关心的只有一件事:下一个该消费谁。 只要知道"消费到哪了",后面的元素挪不挪位置,其实无所谓。
这就是游标法的出发点:数组只往后追加,出队只移动游标,不删除任何元素。
给数组配一个游标 cursor,指向下一个待消费的位置。入队还是 push 到末尾;出队时,把 cursor 指向的元素取出来,然后 cursor 往后走一格。数组本身一动不动。
cursor 之前是已经消费过的区域,cursor 和它之后是还没消费的区域。出队从 O(n) 变成了 O(1),因为只是改了一个数字。
三、把游标队列写出来
js
class Queue {
constructor() {
this.items = [] // 所有元素,永远正序追加
this.cursor = 0 // 下一个待消费元素的下标
}
enqueue(item) {
this.items.push(item) // O(1)
}
dequeue() {
if (this.cursor >= this.items.length) return undefined
return this.items[this.cursor++] // O(1)
}
peek() {
return this.cursor < this.items.length ? this.items[this.cursor] : undefined
}
get size() {
return this.items.length - this.cursor
}
get isEmpty() {
return this.cursor >= this.items.length
}
}
到这儿,核心就讲完了。入队 O(1),出队 O(1),看队首 O(1),判空和长度也都是 O(1)。原来那个平方级的消费过程,现在变成线性的了。
用起来和普通队列没区别:
js
const q = new Queue()
q.enqueue('a')
q.enqueue('b')
q.dequeue() // 'a'
q.peek() // 'b'
q.size // 1
四、还有一个尾巴:已消费的元素怎么办
上面这个实现有个副作用:数组只增不减,已经消费过的元素一直留在数组前面,不会被释放。
如果队列是短命的,用完就扔,那无所谓。但如果是长时间运行、反复入队出队的队列,items 会一路膨胀,变成事实上的内存泄漏。
解决办法不复杂:在合适的时机压缩一次数组。 当已消费的部分积累得够多、超过数组一半的时候,把前面那段切掉:
js
dequeue() {
if (this.cursor >= this.items.length) return undefined
const item = this.items[this.cursor++]
// 已消费够多,且超过一半,压缩一次
if (this.cursor > 1024 && this.cursor * 2 > this.items.length) {
this.items = this.items.slice(this.cursor)
this.cursor = 0
}
return item
}
slice 本身是 O(n),但它是隔很久才发生一次,均摊到每次出队仍然是 O(1)。这就是典型的摊还分析场景:偶尔一次大动作,换来平时都是常数时间。
阈值 1024 不是硬性规定,按你队列的典型长度和内存压力调就行。原则是:别太频繁地压缩,也别让它无限膨胀。
五、最后
回头看,整个故事其实很简单:
前端队列第一反应是 push + shift,语义对,但 shift 是 O(n),数据量一大就拖垮主线程。想明白出队根本不需要删元素,只要记住消费到哪了,游标法就出来了------数组只追加,游标只管往后走,所有操作都是 O(1)。唯一要收尾的是已消费元素的内存,定期压缩一下就解决了。
一个看起来不起眼的数据结构,关键就在于想清楚"出队到底在做什么"。想通了,实现自然就出来了。