一、与Event Loop一起出题
Promise属于微任务,会和宏任务setTimeout一起出题。还要注意async函数的特点。
这一类题比较典型,必须得会,不然面试铁定凉凉。
console.log("script start");
async function async1() {
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2 end");
}
async1();
setTimeout(function () {
console.log("setTimeout");
}, 0);
new Promise(resolve => {
console.log("Promise");
resolve();
})
.then(function () {
console.log("promise1");
})
.then(function () {
console.log("promise2");
});
console.log("script end");
//script start / async2 end / promise / script end/ async1 end / promise1 / promise2 / setTimeout
注意async函数里await右边的语句会立即执行,下面的代码进行等待状态,await返回值以后才会继续执行下一条语句。
二、实现Promise链式调用
1.鹅厂初级题
本人亲身经历鹅厂面试原题:写一个函数实现arr的串行调用,让arr依次输出run1/run2/run3。必须按照arr的顺序执行,需要用Promise的状态去实现先后顺序(resolve或者reject函数执行状态改变后才能执行下一个)。
这个其实也算是Promise源码实现(后面会写)的一个小小的基石。
let arr = [()=>{
return new Promise(res=>{
console.log("run1", Date.now());
res()
})
},()=>{
return new Promise(res=>{
console.log("run2", Date.now());
res()
})
},()=>{
return new Promise(res=>{
console.log("run3", Date.now());
res()
})
}];
这道题有三种写法:
- 使用
async函数。当await后面的代码执行完毕才会继续执行下一次。
async function p(arr){
for(let v of arr){
await v;
}
}
这里需要注意的一点是await只能出现在async函数中,所以这里遍历arr用for循环并没有用forEach。因为forEach里就是另一个函数隔开了外层的async函数,控制台会报错。
- 利用
Promise.resolve()
function p(arr){
let res = Promise.resolve();
arr.forEach( v => {
res = res.then(() => v());
});
}
- 利用
reduce函数的性质
function p(arr){
arr.reduce( (pre, cur) => {
return pre.then(() => cur())
}, Promise.resolve());
}
2.字节终极题
下面要去写的东西基于上面实现的函数,但是比上面更复杂,需要去设计发布订阅模式。会做这类题那么写Promise源码就不会一脸懵逼了。
// 实现一个链式调用的串行Queue类
new Queue()
.task(1000, () => {
console.log(1)
})
.task(2000, () => {
console.log(2)
})
.task(1000, () => {
console.log(3)
})
.start() //调用start后才可以开始
task里的时间说明这道题必须串行链式调用,不然等待1000毫秒的任务会比2000毫秒先执行。
首先,通过函数的调用形式,可以先把基本框架写出来:
class Queue{
constructor(){
}
task(wait, fn){
//code
//task里一定要return this,不然不能链式调用task.这里的this是Queue类
return this;
}
start(){
}
}
//ES5
function Queue() {
this.task = function (wait, fn) {
//code
return this;
}
this.start = function () {
}
}
然后就是去逐步实现里面的内容:(我自己习惯ES6,就用ES6写了)
分析:调用task不立即执行(订阅),等到start才执行(发布)。所以我们要想办法将task任务储存在一个数组taskList中,然后等到start被调用就把数组中的任务按照顺序一一执行。但是要保证执行任务时,无论等待时间长短都要按照顺序执行。其实就和上一道鹅厂的题一样,只不过鹅厂已经把task数组写好了,然后让我们写start函数而已。
存入数组中的函数格式也和鹅厂题目中arr的格式差不多,必须返回一个Promise对象,不然不能实现Promise链式调用。
class Queue{
constructor(){
this.taskList = [];
}
task(wait, fn){
this.taskList.push(
() => new Promise(res => {
setTimeout(()=>{
fn();
resolve();
}, wait);
})
);
return this;
}
//start的写法有三种,就是上面鹅厂题那三种
start(){
//1.async
//2.Promise.resolve()
//3.reduce
}
}
//1.async
async start function() {
for(let v of this.taskList){
await v();
}
}
//2.Promise.resolve()
start() {
let res = Promise.resolve();
this.taskList.forEach( v => {
res = res.then(() => v());
})
}
//3.reduce大家自己照着上面写看能不能写出来
字节另一个题目,和上面的差不多。
实现一个chain函数,eat函数打印eat,work打印work,sleep函数休息。
chain().eat().sleep(5).work().eat().work().sleep(10);
先按照给出的示例将框架搭好:
function chain() {
this.eat = function () {
console.log("eat");
return this;
};
this.work = function () {
console.log("work");
return this;
};
this.sleep = function (time) {
return this;
};
return this; //实现函数不是类,类会自动return,函数需要手动
}
分析:其实和上一道的思路一致,需要一个taskList存储所有的任务。只是这次没有start函数,需要设计一个函数能够在主程序的代码执行完毕(也就是所有任务都加入队列后)才自动执行。这个重任当然交给宏任务setTimeout,当主线程代码执行完毕后才会执行宏任务,这样就不怕任务还没全部加入taskList队列代码就执行的问题了。work和eat不涉及延迟执行,存入队列的形式就不用返回Promise了,sleep需要返回Promise。
function chain() {
this.taskList = [];
this.eat = function () {
this.taskList.push(()=>{
console.log("eat");
});
return this;
};
this.work = function () {
this.taskList.push(()=>{
console.log("work");
});
return this;
};
this.sleep = function (time) {
this.taskList.push(
() =>
new Promise(res => {
setTimeout(res, time);
})
);
return this;
};
//自执行函数:setTimeout里加上执行函数(还是鹅厂那三种形式)
//这里我只写了第一种
setTimeout(async ()=>{//注意:这里必须为箭头函数,不然下面的this会变就取不到正确的taskList
for(let v of this.taskList){
await v();
}
}, 0);
return this; //实现函数不是类,类会自动return,函数需要手动
}
三、实现异步任务函数
这部分属于较难的一部分,需要并行处理异步函数就比链式调用要难很多了。
这一部分涉及到了Promise.all方法的写法,如果学会了就离手写Promise源码更进一步了。
第一题来自字节(PS:字节是真的喜欢出这种题):实现带并发限制的异步调度器Scheduler,保证同时运行的任务最多有两个。
题目给出的代码框架:
class Scheduler{
constructor(){
}
add(promiseCreator){
//code
}
//...
}
const timeout = time =>
new Promise(resolve => {
setTimeout(resolve, time);
});
const scheduler = new Scheduler();
const addTask = (time, order) => {
scheduler.add(() => timeout(time).then(() => console.log(order)));
};
addTask(1000, "1");
addTask(500, "2");
addTask(300, "3");
addTask(400, "4");
scheduler.start();
// output:2 3 1 4
题目已经给了add函数中的promiseCreator参数的具体形式,会返回一个Promise以便后续调用。但是比较棘手的部分是如何实现同时并行的任务最多两个。
分析:实现同时并行的任务最多两个可以拆开。首先,需要实现任务并行,然后限制并行的个数。在上一节我们通过返回Promise实现链式调用就是为了避免并行实现串行。然后,这个类有start函数,所以依然需要一个数组taskList去保存每一个任务,等到start调用后再按照要求依次执行队列中的任务。
先写一个不限制并行任务数量的Scheduler:
class Scheduler{
constructor(){
this.taskList = [];
}
add(promiseCreator){
//code
this.taskList.push(promiseCreator);
}
//...
start() {
while(this.taskList.length>0){
this.taskList.shift()();
}
}
}
如何要限制并行数量的话,需要一个变量runNumber去表示当前正在运行任务的数量,然后去控制这个数量一直保持在2个以内。为了代码的复用性,用maxNumber表示异步任务的最大数量。相应的while循环的条件就要改变了。
class Scheduler{
constructor(){
this.taskList = [];
this.runNumber = 0;
this.maxNumber = 2;
}
add(promiseCreator){
//code
this.taskList.push(promiseCreator);
}
//...
start() {
if(this.taskList.length === 0) return;
while(this.runNumber < this.maxNumber){
this.runNumber++;
this.taskList
.shift()()
.then(() => { //then表示任务完成
this.runNumber--;//任务数减少
this.start();//任务数减少就继续添加任务
})
}
}
}
关键的逻辑还是在then里。只要程序运行到then里表明任务完成,此时去操作runNumber。相应的,Promise.all也是这个逻辑。
四、按照顺序打印01234
常考的一道题是利用let的块级作用域去打印01234,也可以写sleep函数去打印。这里就不说这道题,说另一道难一点的题。
只能修改start函数去实现按照顺序打印01234。
function start(id){
//execute(id)
}
for (let i = 0; i < 5; i++) {
start(i);
}
function sleep() {
const duration = Math.floor(Math.random() * 500);
return new Promise(resolve => setTimeout(resolve, duration));
}
function execute(id) {
return sleep().then(() => {
console.log("id", id);
});
}
//插一个题外话:execute函数转为async函数语义会更明确
//还是ES6牛逼
async function execute(id){
await sleep();
console.log("id", id);
}
分析:sleep函数如其名,就是一个返回Promise的等待函数,等待的时间不定。execute函数表示等待一段时间之后执行输出id的操作。因为等待时间不定,要按照顺序输出还是必须链式调用。
这个问题比较棘手在于for循环里调用start函数,要想链式调用需要拿到上一次start函数返回的Promise。但是for循环里这种调用方式普通函数根本没办法拿到上一次的返回值,只有利用构造函数的this。
function start(id){
//execute(id)
this.p = this.p
? this.p.then(() => execute(id))
: execute(id);
}
五、手写Promise函数和相关方法
如果要按照Promise A+规范完整写出Promise至少需要几个小时,所以面试是不太可能让写太完美。面试官考这个就是考一些基本的思路,看看你对Promise的理解。
Promise的相关方法(Promise.resolve/Promise.catch/Promise.reject/Promise.all/Promise.race/Promise.finally)是非常有可能让写出完整代码,所以这部分要好好准备。
1.手写Promise
先搭好框架:
class _Promise {
constructor() {
}
then() {}
catch() {}
static resolve() {}
static reject() {}
static race() {}
static all() {}
finally() {}
}
如果在一个方法前加上static关键字, 就表示该方法不会被实例继承, 而是直接通过类来调用, 这就称为“静态方法。
(1) Promise的基本功能
首先实现Promise的基本功能:
let p = new Promise((resolve, reject) => {
//....
resolve(1); //成功或失败reject()
})
console.log(p); //p是一个Promise的实例对象,里面包含当前的状态和值
a.then(
val => {
console.log(val);
},
err => {
console.log(err);
}
); // 1
按照上面的调用过程和结果写出初步的Promise代码:
then接受两个参数onFulfilled和onRejected,里面会拿到之前resolve或reject传递的值。
const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";
class _Promise {
constructor(executor) {
// Promise的状态改变
this.state = PENDING;
//resolve的值
this.value = undefined;
//reject的原因
this.reason = undefined;
let resolve = value => {
// 保证Promise的状态改变后永远不会再变
if (this.state === PENDING) {
this.state = FULFILLED;
this.value = value;
}
};
let reject = reason => {
if (this.state === PENDING) {
this.state = REJECTED;
this.reason = reason;
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then() {
if (this.state === FULFILLED) onFulfilled(this.value);
if (this.state === REJECTED) onRejected(this.reason);
}
catch() {}
}
(2) then方法
then方法最重要的是处理异步。如果Promise内部有异步代码,then方法会等到resolve或reject后才会执行。所以当状态是PENDING时,then并不会执行。这个就需要发布订阅模式解决,之前第三部分写过。需要两个队列(resolveList和rejectList)去保存当前的任务,然后等到任务清空后才去执行。then属于微任务,微任务没法模拟,这里用宏任务setTimeout模拟。catch方法其实就是then方法参数没有onFulfilled部分,只有onRejected参数。
const PENDING = "pending";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";
class _Promise {
constructor(executor) {
// Promise的状态改变
this.state = PENDING;
//resolve的值
this.value = undefined;
//reject的原因
this.reason = undefined;
this.onResolveList = [];
this.onRejectList = [];
let resolve = value => {
// 保证Promise的状态改变后永远不会再变
if (this.state === PENDING) {
this.state = FULFILLED;
this.value = value;
//发布
this.onResolveList.forEach(fn => fn(this.value));
}
};
let reject = reason => {
if (this.state === PENDING) {
this.state = REJECTED;
this.reason = reason;
this.onRejectList.forEach(fn => fn(this.reason));
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
if(this.state === PENDING) {
setTimeout(()=>{
try{
//订阅
this.onResolveList.push((val)=>{
onFulfilled(val);
resolve(val);
})
}catch(err){
reject(err);
}
});
setTimeout(()=>{
try{
this.onRejectList.push((reason)=>{
onRejected(reason);
})
}catch(err){
reject(err);
}
});
}
if (this.state === FULFILLED) onFulfilled(this.value);
if (this.state === REJECTED) onRejected(this.reason);
}
catch(onRejected) {
return this.then(null, onRejected);
}
}
按照Promise A+的规范,还需要继续写下去。因为then方法可以接收非函数方法的值,只不过这个值不起作用。我这里就不写,再写下去你面试时间要超2个小时了。(其实主要是我不会了...
2.Promise的方法
每个Promise方法返回的都是Promise对象,所以都要return new Promise(....)
(1) Promise.resolve
Promise.resolve存在的意义就是让非Promise类型转变为Promise对象。但是在转换之前需要先判断参数是否是Promsie对象,是就直接返回,不是再做转换。
static resolve(p){
return p instanceof Promise
? p
: new Promise(resolve => resolve(p));
}
(2) Promise.reject
static reject(err){
return new Promise((resolve, reject) => {
reject(err);
})
}
(3) Promsie.race
Promise.race和Promise.all都是处理多个并发任务,但是race方法如其名——竞争。谁先改变状态resolve或reject就先输出谁。参数输入的数组中不是Promise实例的任务会被转换为Promise
static race(promiseArr) {
return new Promise((resolve, reject) => {
promiseArr.forEach( p => {
Promise.resolve(p).then(
val => resolve(val),
err => reject(err)
);
});
});
}
(4) Promise.all
只要写出Promise.race的话,all就很简单啦。all方法也是如其名,要全部状态都为成功才会resolve出最后的结果,只要有一个失败就直接以失败结束。
Promise.all的特点:
- 所有任务成功后返回带有任务结果的数组。
- 如果有一个任务被
reject,直接返回reject。 - 数组中不是
Promise实例的任务会被转换为Promise。
这个比之前写固定数量的异步任务执行要简单,思路和之前差不多。需要一个count计数,如果执行完毕就resolve,还需要一个数组result存储最终的结果。
static all(promiseArr){
const len = promiseArr.length,
result = new Array(len);
let count = 0;
return new Promise((resolve, reject) => {
promiseArr.forEach((p, i) => {
Promise.resolve(p).then((val) => {
result[i]=val;
count++;
if(count === len) resolve(result);
},
err => reject(err);
);
});
});
}
(5) finally
finally方法用于指定不管 Promise 对象最后状态如何都会执行的操作。该方法是 ES2018 引入标准的。
可以用于异步操作的一些善后的操作,比如清除定时器、解绑一些东西,这时候不需要知道Promise确定后的值。所以finally里的反应函数不接收任何参数。
finally本质上是then方法的特例。只是then是需要状态改变后的结果而finally不需要。
finally(fn){
let P = this.constructor;
return this.then(
value => P.resolve(fn()).then(() => value),
reason => P.resolve(fn()).then(() => P.reject(reason))
);
}