一次性解决Promise所有面试题

2,743 阅读11分钟

一、与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()
	})
}];

这道题有三种写法:

  1. 使用async函数。当await后面的代码执行完毕才会继续执行下一次。
async function p(arr){
    for(let v of arr){
        await v;
    }
}

这里需要注意的一点是await只能出现在async函数中,所以这里遍历arrfor循环并没有用forEach。因为forEach里就是另一个函数隔开了外层的async函数,控制台会报错。

  1. 利用Promise.resolve()
function p(arr){
    let res = Promise.resolve();
    arr.forEach( v => {
		res = res.then(() => v());
    });
}
  1. 利用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队列代码就执行的问题了。workeat不涉及延迟执行,存入队列的形式就不用返回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接受两个参数onFulfilledonRejected,里面会拿到之前resolvereject传递的值。

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方法会等到resolvereject后才会执行。所以当状态是PENDING时,then并不会执行。这个就需要发布订阅模式解决,之前第三部分写过。需要两个队列(resolveListrejectList)去保存当前的任务,然后等到任务清空后才去执行。
  • 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.racePromise.all都是处理多个并发任务,但是race方法如其名——竞争。谁先改变状态resolvereject就先输出谁。参数输入的数组中不是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))
    );
}