前端面试进阶:JS高阶函数实现并行任务调度控制
引子
在前端面试的笔试题中,高频出现的题目肯定包含任务并行调度控制器,那么当面试官出这道题目时,他想考察的是候选人的哪些能力呢?今天我们从面试官的角度出发,来逐步实现一个完整全面的并行任务调度控制器
定义
任务并行调度控制器,即可以让一系列特定任务按照预先设置的并行调度规则有序执行的控制容器,这里的任务可以抽象为未知函数。
初阶版
题目:请按要求补齐下面的代码片段,urls数组中存放着要发起的一批异步请求的地址URL,用函数实现一个针对这一批请求(请求通过request方法模拟)的并行控制器,并且在所有请求结束后同时返回所有的请求响应结果,最高并行数maxNum要支持可配
// 实现一个URLS数组,用promise实现多个请求并行,返回所有请求结果,
// 最高并行数最多有maxNum个
// 模拟异步请求
function request(url) {
return new Promise((resolve, reject)=>{
console.log(`${url}开始请求`)
setTimeout(()=>{
const flag = Math.round(Math.random())
if(flag){
resolve(url + ' success');
}else{
reject(new Error('fail'));
}
}, Math.floor(Math.random()*5+2)*1000);
})
}
const urls = ['url1','url2','url3','url4','url5','url6','url7','url8','url9','url10'];
// 实现该函数
function maxRequest() {
// todo
}
step1
这道题目是一个具体业务场景的代码片段,需要你来补齐函数,已经给了非常详细的上下文,候选人只需要实现一个函数即可,如何在一个函数中实现所有的诉求,是我们需要思考的点,我们先来拆解一下:
首先,从题干上我们了解函数返回的结果要收集到所有的请求响应结果,同时每个请求又是异步的,那么很直觉的会联想到 Promise.all,但是Promise.all 本身是不支持并行控制的,言外之意,我们函数要实现一个类 Promise.all 的返回,但是这个实现还需要支持并行控制,那么 maxRequest 函数的主干我们就清楚了,核心就是返回一个Promise,另外maxNum要支持可配置的意思是说这个函数的入参是它,那我们先把基础框架实现一下:
function maxRequest(urls, num) {
return new Promise((resolve, reject)=>{
})
}
step2
既然实现了第一步,那么接下来的核心就是实现并行控制逻辑了,我们知道Promise.all是将一个数组中所有的promise全部并行处理,如果要让里面的各个异步promise按照一定规则依次触发,并且在前面的请求响应完成后去判断是否再发起后面的请求,要做到这一点,大家肯定会想到队列,所以这里也考察了数据结构方面的知识,如何保证后面的异步任务发起时,前面的已经完成了呢?最简单的方式就是通过一个指针来记录队列中已经完成请求的任务位置,当指针移动到队列末尾时,即表明所有的任务都执行了,每次任务发起时依赖前面的任务是否完成,这样就完成了对最高并行数的控制,既然是实现一个类Promise.all,那么我们其中的每一个promise就要抽象为一个个的异步请求了,并且外层Promise状态的改变依赖所有的异步请求promise的状态变化总和,考虑到这些点,我们来完善一下maxRequest函数:
// 模拟异步请求
function request(url) {
return new Promise((resolve, reject)=>{
console.log(`${url}开始请求`)
setTimeout(()=>{
const flag = Math.round(Math.random())
if(flag){
resolve(url + ' success');
}else{
reject(new Error(url + ' fail'));
}
}, Math.floor(Math.random()*5+2)*1000);
})
}
const urls = ['url1','url2','url3','url4','url5','url6','url7','url8','url9','url10'];
// 并行任务调度控制函数
function maxRequest(urls, num) {
return new Promise((resolve, reject)=>{
let result = [];
let count = 0;
let index = 0; // 指针
async function post() {
const flag = index;
const url = urls[index];
index++;
try{
const res = await request(url);
result[flag] = res;
}catch(e){
result[flag] = null;
}
count++;
if(count ===urls.length){
resolve(result);
}
if(index < urls.length){
post();
}
}
// 开启并发
for(let i=0; i< Math.min(num, urls.length); i++){
post();
}
})
}
// 设置最高并行数为3启动并发
maxRequest(urls, 3).then((res)=>{
console.log(res);
})
需要注意的是,开启并发这里,我们需要考虑一下urls数组本身大小与最高并发数的大小,开启并发的数目肯定是两者中更小的那个,简单解释一下就是,如果urls数组中只有2个任务,但是最高并发数设置为了3,那么直接并行这两个任务即可
考察的知识点
从上面的实现中,大家可以仔细琢磨一下,作为候选人需要掌握哪些JS的核心知识点?
首先肯定是考察Promise,Promise.all的掌握程度,其次,子函数的设计考察的是作用域链,响应结果成功和失败的场景,考察的是async函数与try catch捕获错误的意识,并行控制则需要理解指针调度队列等数据结构的知识,另外在完成代码编写,调试的过程中,面试官还可以观察你的代码注释,代码缩进,代码可读性等工程师基础素养。
不过上面的考察方式其实并不全面,maxRequest 只是一个复杂函数,但并不是高阶函数,另外并行处理的任务数目是有限的,题目并没有能考察到候选人在封装通用功能方面的能力,那么接下来,让我们一起挑战一道更难的题目
进阶版
实现一个函数,满足并行任务的执行调度控制逻辑,代码需要满足场景上下文
// 模拟异步请求
function request(url) {
return new Promise((resolve, reject)=>{
console.log(`${url}开始请求`)
setTimeout(()=>{
const flag = Math.round(Math.random())
if(flag){
resolve(url + ' success');
}else{
reject(new Error(url + ' fail'));
}
}, Math.floor(Math.random()*5+2)*1000);
})
}
// 并行任务调度控制
function taskPool(maxNum) {
}
//执行
const addTask = taskPool(3);
addTask(()=>{
console.log('同步1');
})
addTask(request, 'xxxx').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(request, 'yyyy').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(request, 'zzzz').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(()=>{
console.log('同步2');
})
addTask(request, 'y323').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
step1
根据题目要求,taskPool这个函数执行后返回的是一个新的函数,新的这个函数支持传入一个任务函数并且返回一个Promise对象,看到这里,很多同学可能已经晕了,因为对高阶函数的定义不太理解。 高阶函数通常是指返回一个新函数的函数,那么大家肯定会问,这么做的目的是什么呢?其实高阶函数通常是为了配合返回的新函数来实现一些隐藏内部上下文的目的,我们希望外部使用者只需要会使用新导出的函数即可,对于内部如何实现并发控制是不需要了解的,并且外部也没有办法来影响内部的并行逻辑,这一点体现了封装性,从上面的调用事例我们了解到,新函数执行会传入一个函数,而返回的Promise在resolve后,获取到的结果是传入的函数运行的结果,根据当前的分析,我们可以先实现函数的入参与返回框架
// 高阶函数并行任务调度控制器
function taskPool(num){
let queue = [];
let maxNum = num;
let runningTaskNum = 0;
// 执行任务
function runTask(){
}
// 返回的函数
return function(fn, ...args){
return new Promise((resolve, reject) => { // 返回promise
const task = () => {
return Promise.resolve(fn(...args)).then((result)=>{
resolve(result);
}).catch((e)=>{
reject(e);
})
}
queue.push(task);
runTask();
})
}
}
使用Promise.resolve来包裹 fn 是为了兼容传入的函数类型,因为任务不一定是一个异步任务,也可能是同步任务,这里通过Promise.resole包裹是为了后面在执行任务时可以统一按照promise形式来处理
step2
runTask其实就是我们通过闭包来隐藏的具体实现逻辑,所以高阶函数通过闭包的形式只暴露了添加任务的方法,同时很巧妙的隐藏了内部实现并行的逻辑,外部无法直��访问queue,runningTaskNum等变量,实现了封装性,那么完整的代码如下:
// 模拟异步请求
function request(url) {
return new Promise((resolve, reject)=>{
console.log(`${url}开始请求`)
setTimeout(()=>{
const flag = Math.round(Math.random())
if(flag){
resolve(url + ' success');
}else{
reject(new Error(url + ' fail'));
}
}, Math.floor(Math.random()*5+2)*1000);
})
}
// 高阶函数并行任务调度控制器
function taskPool(num){
let queue = [];
let maxNum = num;
let runningTaskNum = 0;
// 执行任务
function runTask(){
if (queue.length && runningTaskNum < maxNum) {
runningTaskNum++;
const task = queue.shift();
// 任务执行调度
task().then(() => {
runningTaskNum--;
runTask();
})
}
}
// 返回的新函数
return function(fn, ...args){
return new Promise((resolve, reject) => { // 返回promise
const task = () => {
return Promise.resolve(fn(...args)).then((result)=>{
resolve(result);
}).catch((e)=>{
reject(e);
})
}
queue.push(task);
runTask();
})
}
}
//执行
const addTask = taskPool(3);
addTask(()=>{
console.log('同步1');
})
addTask(request, 'xxxx').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(request, 'yyyy').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(request, 'zzzz').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
addTask(()=>{
console.log('同步2');
})
addTask(request, 'y323').then((result)=>{
console.log(result)
}).catch((e)=>{
console.log(e.message)
})
因为请求接口有成功,或者失败,下面截图为某一次执行的结果示例:
考察的知识点
这道题目考察的JS知识范围就更广了,除了初阶版中作用域,Promise,Promise.all,error捕获,async函数,数据结构(队列)等这些知识点,还加入了闭包,高阶函数,同步异步,事件循环,封装,s设计模式,arguments类数组,箭头函数等重要知识点的考察
总结
并行任务调度控制器之所以会成为面试中高频的笔试题目,是因为这类题目在考察候选人编码能力的同时,还可以帮助面试官快速了解候选人对于众多JS核心基础知识的掌握程度,相比于八股文的考察方式,这类实际业务场景下的编程题目更能反映候选人的真实水准
对于面试官而言,面试就是在既定时间内对候选人做到最大程度的了解,那么反过来,如何在很短的时间内表现自己的专业度与技术深度就是候选人最需要掌握的面试技巧了,后续我也会继续分享关于前端知识学习,前端面试相关的内容,感谢持续关注
转载自:https://juejin.cn/post/7395471538764496908