队列在前端中的使用
队列是一种常见的数据结构,可以看做一种特殊的列表。数据的插入与删除操作仅限于两端。
队列与栈类似,不同点在于
- 栈:后进先出
- 队列:先进先出
而在我们生活中常见的例子便是排队,比如我们排队上公交,排在前面的先上车,排在后面的后上,新来的人不允许插队,只能在队尾排队等待。
图1. 队列示意图
代码实现
队列常用的操作有:出队与入队。出队只能从队首操作,入队仅能从队尾操作。
数组由于可以用 shift 删除第一个元素,push 在数组中添加新的元素,与队列的操作非常吻合,因此可作为队列的最佳实践。
class Queue {
items = []
constructor() {}
enqueue(element) {
this.items.push(element);
}
dequeue() {
this.items.shift();
}
peek() {
return this.items[0];
}
size() {
return this.items.length;
}
isEmpty() {
return this.size() === 0;
}
}
Javascript也可以使用其他数据类型来实现队列,如:Object,ES 的 Set、Map 等。但是没有数组这么简单,感兴趣的可自行搜索。
任务队列
我们使用队列最终都是为了完成某项特定的任务,因此需要在原有的基础上实现任务队列。
而任务队列通常有流量限制,并且都是异步的,比如我们去银行排队办理个人业务,银行有 3 个个人业务的窗口,那每次最多允许 3 个人同时办理业务。而窗口处理各业务的完成时间是不定的。有的窗口快,有的慢,这就是异步了。
如果我们将3个窗口,看成每次只允许运行1个任务的3个小队列,从等待队列中取最前面的一个人来加入这个小队列,就过于僵化,把问题想的复杂了。
不如仅做一个等待队列,先判断当前是否可以开始办理业务,如果是,把等待队列中的人拉出来开始办理即可。
图2. 等待队列与3个执行队列、单个等待队列对比
这样反而简单易操作,代码如下:
class TaskQueue {
concurrency = 1;
tasks = [];
runningTasksCount = 0;
constructor(concurrency) {
this.concurrency = concurrency;
}
pushTask(task) {
this.tasks.push(task);
this.runNextTask();
}
pushTasks(tasks) {
this.tasks.push(...tasks);
this.runNextTask();
}
runNextTask() {
if (this.runningTaskCount >= this.concurrency || this.tasks.length === 0) {
return;
}
const task = this.tasks.shift();
if (task) {
this.runningTaskCount ++;
try {
task();
} catch (e) {
console.log(e);
} finally {
this.runningTaskCount --;
this.runNextTask();
}
}
}
}
此时,入队 enqueue 为了支持多任务,改名 pushTask 和 pushTasks。出队 dequeue 移入了 runNextTask 中。虽然表面上变成了数组,但是我们清楚其数据结构是队列,必须遵循先进先出得,不做 unshift、pop 等破坏数组操作。
这样我们就得到了一个任务队列。
异步任务处理
异步任务难免要做一些业务处理,这时候如果把异步任务的逻辑放进此公共类里就不合适了。
因此可以通过继承、重载 runNextTask 方法就可以实现自己想要的处理了。如果你还想返回一些任务统计数据,则可以通过发布订阅模式进行事件分发。
class RequestTask extends TaskQueue {
runNextTask() {
if (this.runningTaskCount >= this.concurrency || this.tasks.length === 0) {
return;
}
const task = this.tasks.shift();
if (task) {
this.runningTaskCount ++;
task().then(data => {
console.log(data); // 对所有数据做相同处理
}).catch((e) => {
console.log(e);
}).finally(() => {
event.emit('task-finished', this.size()); // 返回剩余任务数
this.runningTaskCount --;
this.runNextTask();
});
}
}
}
这样一个异步处理的任务就完成了。
使用示例
// 1. 创建任务
const rq = new RequestTask(5);
// 2. 发起请求
rq.pushTasks([() => {
// new Promise() 可以替换成 axios 或其他请求方法
return (new Promise(...)).then((data) => {
// 有自行处理的 then,需要最后回传数据给事件中心。
return data;
});
},...]);
// 3. 订阅事件
event.on('task-finished', (data) => {
console.log(data); // 剩余任务数
});
上面的代码经过修改,就可以在以下的地方使用了:
- 大文件切片上传
- 多次请求后端接口,返回数据集中处理。
可以看到的是,我们通过发布订阅模式,把任务队列和业务代码解耦出来了。常常听说,前端不知道设计模式怎么用,其实只要在抽象、解耦上多想想,这些知识是随时可以用上的。
扩展
概念理清
没有异步队列这个概念!如果有,这个队列违背了“先进先出”的原则。
继承的缺点
继承的方式有个被人诟病的地方是,如果有过多的层级,后期维护难度会级数上升。因此不建议超过三层的继承。
除了继承外,如果使用 typescript 也可以通过接口实现,减少上述不利维护的因素。
类图:
其他队列
除了异步任务外,还有其他一些队列形式,也可以参考标准的队列进行实现。
- 动画队列:按顺序加载动画,让动画更流畅
- 图片队列:预加载图片,减少堵塞
在这些代码中,你可能无法看到明确的 enqueue、dequeue,反而可能看到 Array.shift、Array.push 的操作,如果无其他破坏数组的操作,实际上所用的数据结构就是队列。
另外还有更复杂的循环队列、链队列等,可自行搜索了解。
总结
队列是一种常见的数据结构,其特点是先进先出,可以利用数组进行简单实现。
在前端中可用于多任务处理,常见于带并发控制的请求,如文件上传、按顺序请求等功能。这些都是任务队列的变形应用。
掌握好队列,能更好地做异步任务的解耦。
参考
chatGPT
转载自:https://juejin.cn/post/7215967109171003448