前端队列,我是怎么从 `push + shift` 换成游标的

前端队列,我是怎么从 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)。唯一要收尾的是已消费元素的内存,定期压缩一下就解决了。

一个看起来不起眼的数据结构,关键就在于想清楚"出队到底在做什么"。想通了,实现自然就出来了。

相关推荐
Qinging1 小时前
var_let_const
javascript
星_离1 小时前
《Ajax - 实战基础篇》
前端·ajax
Maxkim1 小时前
私钥就是数字 4:扒一扒这场把一家 CA 直接送走的事故
javascript·架构
gCode Teacher 格码致知1 小时前
前端教学8:提高 CSS 选择器的优先级,有哪些方法?
前端·css·html5
u0111026752 小时前
Canvas 导出 PNG 时 quality 参数为什么不起作用
前端·javascript·vue.js
众链网络2 小时前
年卡跨淡旺季的有效期模型:不可用日期、次数与顺延规则
java·前端·数据库
2601_949847752 小时前
jose 跨端三方库仓颉适配全流程
前端·javascript·flutter
CappuccinoRose2 小时前
滚动交互体系
前端·javascript·交互·html5·1024程序员节
时空节拍AI数字人2 小时前
博物馆数字人讲解系统设计:知识库 schema、展项联动协议与时序控制
java·服务器·前端·人工智能·ai·语音识别