力克笔试:异步编程题篇——5种类型妈妈我再也不怕啦本文是《2024前端力克笔试》系列的第一篇——详述了5种异步编程题目及
本文是《2024前端力克笔试》系列的第一篇,写这个系列的原因是最近因为大环境不好,笔试题原来越难了;且笔试题都偏向于实际应用场景和leetcode的变种,不再是简单的原题。按老方法刷leetcode,花费时间长不说,效果也越来越差。为了解决这个问题,本系列特地:
- 将收集到的笔试题分类整理,并尝试用一套通用的最优方案来解决
- 从面试官(而不是应聘者)的角度上审视题目的难点、卡点,方面大家明确真正的考察点,加深记忆
希望本系列能帮助大家高效通过笔试,不踩坑。如果觉得有用,欢迎点击关注并持续跟进其他篇章。
导读
异步编程Promise相关的题目是近年前端笔试的常客,本系列总结了5种类型,包含1种基础和4种变种,思维导图如下:
通过本文你还能了解到:
- 通用的Proimse相关笔试题写法
- JAVA中阻塞队列的概念
- ES2024中新增静态工厂方法Promise.withResolver()与控制反转
- 二分法与异步函数的结合,以及笔试答题的最佳策略
- 分布式系统中响应乱序的概念、出现原因和处理策略
1 并行:实现Promise.all或者Promise.allSettled
题目:手写Promise.all和Promise.allSettled,完成下面函数的实现
const request1 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 3000);
});
const request2 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 2000);
});
const request3 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(3);
}, 1000);
});
const request4 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(4);
}, 1000);
});
const case1 = [request1(), request2(), request3()];
const case2 = [request1(), request2(), request3(), request4()];
function myPromiseAll(promises) {
// 你的代码
}
function myPromiseAllSettled(promises) {
// 你的代码
}
const result1 = await myPromiseAll(case1);
const result2 = await myPromiseAll(case2).catch(err => err);
const result3 = await myPromiseAllSettled(case2);
console.log('result1', result1); // 应为[1,2,3]
console.log('result2', result2); // 应为4
console.log('result3', result3); // 应为[{"status":"fulfilled","value":1},{"status":"fulfilled","value":2},{"status":"fulfilled","value":3},{"status":"rejected","reason":4}]
做笔试最忌讳的就是眼高手低,一看都会一写都废(我就是这样😫😫😫)。可以先盲写试试,然后比对答案示例,比照下哪些点没做到还可以做的更好。记得关掉copilot插件,不然这一切将毫无意义。
答案示例
function myPromiseAll(promises) {
// 基础点1: PromiseAll返回的是一个promise对象
return new Promise((resolve, reject) => {
// 基础点4: 记得处理边界值
if (!Array.isArray(promises)) {
reject(new TypeError('Argument must be an array'));
return;
}
if (promises.length === 0) {
resolve([]);
}
// 基础点2: promiseResults和resolvedCount都要定义在new Promise内部
const promiseResults = Array(promises.length);
let resolvedCount = 0;
promises.forEach((element, index) => {
element.then(res => {
// 基础点3: 注意Promise.all的返回是有序的,不能直接push
promiseResults[index] = res;
resolvedCount++;
if (resolvedCount === promises.length) {
resolve(promiseResults);
}
// 基础点4: 记得处理边界值
}).catch(err => {
reject(err);
})
});
})
}
function myPromiseAllSettled(promises) {
return new Promise((resolve, reject) => {
if (!Array.isArray(promises)) {
reject(new TypeError('Argument must be an array'));
return;
}
if (promises.length === 0) {
resolve([]);
}
const promiseResults = Array(promises.length);
let completedCount = 0;
promises.forEach((element, index) => {
element.then(res => {
// 基础点5: 返回值的格式要明确
promiseResults[index] = { status: 'fulfilled', value: res }
}).catch(err => {
promiseResults[index] = { status: 'rejected', reason: err }
// 基础点6:使用finally来处理resolve和reject都会遇到的情况
}).finally(() => {
completedCount++;
if (completedCount === promises.length) {
resolve(promiseResults);
}
})
})
})
}
面试官视角
- 流畅写出能通过3个测试用例的代码,result中返回值的顺序正确 ➡️ 了解Promise的基本原理,并有一定的代码编程能力
- allSettled方法的返回结构正确,使用了finally,并针对边界值做了处理 ➡️ 注重细节,代码风格好
2 限流:实现一个带并发限制的异步调度器
题目:实现一个带并发限制的异步调度器,当异步调度器中的请求个数没有达到并发限制,就立即执行;如果超过并发限制,就等待直到异步调度器中有空位
const request1 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 3000);
});
const request2 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 2000);
});
const request3 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(3);
}, 1500);
});
const request4 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(4);
}, 100);
});
function myPromiseQueue(max) {
// 你的代码
}
const addRequest = myPromiseQueue(2);
addRequest(request1);
addRequest(request2);
addRequest(request3);
setTimeout(() => {
addRequest(request4);
}, 200)
// 期望输出如下内容
// 2 1 4 3
答案示例
function myPromiseQueue(max) {
// 基础点2: 记得处理边界值
if (max <= 0) throw new RangeError('并发量必须大于0');
const queue = [];
let count = 0;
function queueShift() {
if (queue.length && count < max) {
count++;
const promiseFunc = queue.shift();
promiseFunc().then(res => {
console.log(res);
}).catch(err => {
console.log(err);
}).finally(() => {
count--;
// 基础点1:用递归来保证队列中有任务就消费掉
queueShift();
})
}
}
return function(promiseFunc) {
queue.push(promiseFunc);
// 基础点1:用递归来保证队列中有任务就消费掉
queueShift();
}
}
这道题也是一道常考题,它其实更多的考察中日常工作中是否有处理过控制并发吞吐率的情况。如果有过涉及,那么就很简单了。其实这是类似JAVA的并发编程中阻塞队列的实现。本题其实是需要我们实现阻塞队列的吞吐率和消费者线程的部分(实际中JAVA因为是多线程,是用通知机制而不是递归来实现的)。
阻塞队列: 阻塞队列是一种JAVA应用于高并发场景的线程处理方案,保证了吞吐率不至于过大。通常会涉及到两种线程:生产者线程用于生产新的对象并插入到阻塞队列中,消费者线程则从阻塞队列中取出并使用这些对象。除了控制吞吐率之外,当队列为空时,消费者线程会被阻塞,直到有新元素加入队列。当队列已满时,生产者线程会被阻塞,直到有空闲空间可用。这种机制保证了生产者不会在队列已满时加入新元素导致溢出,消费者也不会在队列为空时取出元素导致异常。
面试官视角
- 使用递归,流畅写出能通过测试用例的代码 ➡️ 能用递归解决问题,理解并发限制的实现
- 针对边界值做了处理 ➡️ 注重细节,代码风格好
- 能串联到日常的工作使用场景 ➡️ 项目复杂度可能高,有考察价值
3 限流注册:实现一个带并发限制的异步注册器
题目:实现一个带并发限制的异步注册器,当异步注册器中的请求个数没有达到并发限制,就立即执行;如果超过并发限制,就等待直到有空位再执行。异步任务会在不同作用域中输出
const request1 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 3000);
});
const request2 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 2000);
});
const request3 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(3);
}, 1500);
});
const request4 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(4);
}, 100);
});
function myPromiseQueueRegister(max) {
// 你的代码
}
const addRequest = myPromiseQueueRegister(2);
addRequest(request1).then(res => {
console.log('request1', res);
});
addRequest(request2).then(res => {
console.log('request2', res);
});
addRequest(request3).then(res => {
console.log('request3', res);
});
addRequest(request4).then(res => {
console.log('request4', res);
});
// 期望输出如下内容
// request2 2
// request1 1
// request4 4
// request3 3
答案示例
function myPromiseQueueRegister(max) {
// 基础点2: 记得处理边界值
if (max <= 0) throw new RangeError('并发量必须大于0');
const queue = [];
let count = 0;
function queueShift() {
if (queue.length && count < max) {
count++;
const { request, resolve, reject } = queue.shift();
request().then(res => {
resolve(res);
}).catch(err => {
resolve(err);
}).finally(() => {
count--;
// 基础点1:用递归来保证队列中有任务就消费掉
queueShift();
})
}
}
return (promiseFunc) => {
return new Promise((resolve, reject) => {
// 基础点3:队列中保存resolve、reject,从而保证能在queueShift中使用
queue.push({ request: promiseFunc, resolve, reject });
queueShift();
});
}
}
看起来第三题和第二题差不多,但是第二题统一输出即可,第三题却是各个异步任务自己输出的。所以第三题主要考查了如何将resolve方法放到别的作用域下执行。
这里很巧妙的把需要并发限制的异步函数,和包裹它的Promise对象的resolve/reject一起放到队列中,这样在要使用的时候就能拿到了(尽管看起来很简单,但是没见过想要想到确实有点难度)。
其实,这道题是考查了ES2024新特性Promise.withResolvers()的理解和使用。
就以本题目举个例子(如下代码),我们将两个箭头函数改为了一个,体感上就感觉整洁了很多,代码理解起来也更加方便。
// 之前
return (promiseFunc) => {
return new Promise((resolve, reject) => {
// 基础点1:队列中保存resolve、reject,从而保证能在queueShift中使用
queue.push({ request: promiseFunc, resolve, reject });
queueShift();
});
}
// 改造为使用Promise.withResolvers()
return (promiseFunc) => {
const { promise, resolve, reject } = Promise.withResolvers();
queue.push({ request: promiseFunc, resolve, reject });
queueShift();
return promise;
}
面试官视角
- 使用递归,完成并发限制的部分 ➡️ 能用递归解决问题,理解并发限制的实现
- 能想到将resolve也放到队列中处理 ➡️ 思维敏捷
- 针对边界值做了处理 ➡️ 注重细节,代码风格好
- 使用了Promise.withResolvers() ➡️ 视野开阔,愿意了解新技术
4 累积:实现一个异步加
题目:实现一个异步加函数,接收一个数组,但是只能用提供的异步add方法来相加。并考虑在最短时间内取得异步加的最终结果
// 提供的add函数
function add(a, b) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(a + b);
}, 1000);
})
}
function sum(arr) {
// 你的代码
}
sum([1,2,3,4,5,6,7]).then((res) => {
console.log(res);
})
// 期望输出如下内容
// 28
答案示例
// 基础解法
async function sum(arr) {
// 使用Promise.withResolvers()可以大幅提升代码可读性
const { promise, resolve, reject } = Promise.withResolvers();
let sum = 0;
// 这里不能使用forEach,否则sum会成为快照
for (let index = 0; index < arr.length; index++) {
const item = arr[index];
sum = await add(item, sum);
if (index === arr.length - 1) {
resolve(sum);
}
}
return promise;
}
// 最优解
async function sum(arr) {
if (!arr.length) return 0;
if (arr.length === 1) return arr[0];
if (arr.length === 2) return add(...arr);
// 基础点1:使用二分法来提升计算速度
const mid = Math.floor(arr.length / 2);
const left = sum(arr.slice(0, mid));
const right = sum(arr.slice(mid, arr.length))
// 基础点2:用Promise.all来保证各条分支返回时机一致
const result = await Promise.all([left, right]);
return add(...result);
}
这道题的卡点在于如何将sum值的依次累加起来。基础解法尽管简单,但是有两点还是可以提及一下的:
- 在基础解法中,我们可以逐渐体会到Promise.withResolvers()在提升代码可读性上的巨大优势
- 在循环遍历中,forEach的循环的每一项其实是一个匿名函数。因此,当你想要处理一些累加或者break的情况时,要提前注意到这个小逻辑,不要因为这个问题耽误宝贵的笔试答题时间。
在最优解中,难点不在于二分法,而在于异步函数与递归的结合。如果没遇到过类似的情况,在笔试紧张的环节一开始上来可能会有点懵逼。
在笔试中,如果遇到没做过类似题目的笔试题,要记得:
宁慢3分钟,不抢1秒钟。
笔试中难免遇到自己没有做过类型的题目,最忌讳的就是直接动笔,写自己能马上想到的。要知道大部分笔试题都是1~2个卡点的,如果想不清楚就直接作答,就会陷入到卡点->调整自己的代码->又遇到新的卡点的死循环,从而导致笔试失利。而最优答题策略应该是:
笔试最优答题策略:
- 先确定这道题的考察类型:是逻辑、指针、哈希、栈、递归、回溯...?心里有个数,唤起自己对于类似题目的记忆
- 再分析题目的最难卡点:是不知道怎么保存状态,还是递归的逻辑不知道怎么写
- 再考虑这个卡点的解决方案(重中之重):解决不了这个卡点这个题目是做不出来的,所以一定要先想清楚通过哪几步来解决这个卡点,并定义出大纲
- 然后根据大纲来撰写代码:有的放矢,又快又好
- 最后检查各种边界值,并添加处理逻辑:一般只检查数值区间(即RangeError),不检查类型(默认类型由Ts来保证)
面试官视角
- 使await来完成异步加的功能 ➡️ 有一定的异步意识(不加分)
- 使用二分+递归来解决问题 ➡️ 熟练使用二分法,能用递归解决问题
- 针对边界值做了处理 ➡️ 注重细节,代码风格好
5 异序:请求乱序返回,只能按照顺序接收结果
题目:实现一个异步调度器scheduler,保证后面的请求返回时就不再返回前面的请求,否则返回前面的请求。完善下面代码中的scheduler方法,使得以下程序能正确输出
const request1 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1);
}, 3000);
});
const request2 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(2);
}, 2000);
});
const request3 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve(3);
}, 1000);
});
const request4 = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(4);
}, 2000);
});
const reqArr = [request1, request2, request3, request4];
function scheduler(arr) {
// 你的代码
}
scheduler(reqArr);
// 期望输出如下内容
// 3
// 4
答案示例
function scheduler(arr) {
if (!arr.length) return;
let curIndex = 0;
let queue = [];
// 基础点1: 封装一个可以抛弃的函数,从而可以主动将请求抛弃
function abortReq(req) {
const { promise, resolve, reject } = Promise.withResolvers();
req().then(res => {
resolve(res);
}).catch(err => {
reject(err);
});
return { promise, resolve, reject };
}
for (let index = 0; index < arr.length; index++) {
const item = arr[index];
const { promise, resolve, reject } = abortReq(item);
// 基础点2: 用数组将每个封装好的请求及处理态保存起来,待消费
const reqItem = {
promise,
resolve,
reject,
status: 'pending',
}
queue.push(reqItem);
promise.then(res => {
console.log(res);
reqItem.status = 'resolved';
if (index >= curIndex) {
curIndex = index;
}
// 基础点3: 直接找到队列中比这个请求序号小的请求作为乱序请求,全部抛弃
queue.forEach((element, index) => {
// 基础点4: 添加status状态,减少判断
if (index < curIndex && element.status === 'pending') {
element.reject('abort');
element.status = 'rejected';
}
});
}).catch(err => {
if (err === 'abort') return;
console.log(err);
element.status = 'rejected';
});
}
}
在第四部分中,我们分析了笔试最优答题策略,这里不妨就应用一下:
- 考察类型:这道题考查的是异步函数中响应乱序的问题
- 最难卡点:如何定义响应乱序,以及当响应乱序后,之前的发出的请求如何抛弃掉
- 解决方案:
- 设置一个curIndex的变量来保存当前处理到的请求序号,比这个序号小的请求都属于乱序
- 封装一个能抛弃函数,来包裹每一个请求函数
- 撰写代码:发现还需要一个队列来保存请求队列,添加上
- 检查边界值:
- 对于arr的边界值进行处理
- 发现乱序请求可能被多次抛弃,添加status进行剪枝
这样就很有逻辑性的写出了整个题目。另外,了解相关响应乱序的背景能让你在处理类似问题中更游刃有余。
关于响应乱序:在分布式系统中,响应乱序是一种经常出现的情况
导致响应乱序的原因有:
- 网络延迟:不同的网络路径可能导致消息到达时间不同。
- 节点负载:某些节点处理请求的速度可能较慢或较快,造成处理完成时间不一致。
- 异步处理:分布式系统通常使用异步通信,可能导致请求与响应不在同一时间完成。
实际的常出现响应乱序的场景有:
- HTTP的多路复用
- IM系统中的请求消息传递
通用的处理方式有:
- 序列号:给每个请求分配一个唯一的序列号,响应中包含该序列号,通过排序来确保处理顺序。
- 结果合并:将乱序的结果在处理前进行合并或重组,以便按正确的顺序返回。
- 状态机:实现状态机来管理系统的状态和转移,在接收到响应后,基于当前状态确定如何处理。
- 顺序保证协议:使用一些协议(如 FIFO、顺序一致性等)来确保消息的顺序。
面试官视角
- 能封装抛弃函数,并实现相应功能 ➡️ 对Promise理解深刻
- 针对边界值做了处理 ➡️ 注重细节,代码风格好
- 对不必要的处理进行了剪枝 ➡️ 代码能力强,游刃有余
总结
本文由浅入深的总结了5种常见的异步编程笔试题,相信看到这里的你一定能觉得有所帮助。还是那句话,纸上得来终觉浅,绝知此事要躬行——看一万遍不如自己写一遍。有什么疑问建议,也请不吝赐教。
点个关注,马上带来《2024前端力克笔试》的下一篇~
转载自:https://juejin.cn/post/7410321051782332450