Promise面试灵魂十二问

1,025 阅读14分钟

本篇文章是我在最近准备面试时收集的关于Promsie的面试题,包含Promsie的核心观点是什么,手写简单的Promsie,字节跳动的Promsie并发请求控制Promsie与Async有何不同等大家可以看目录,后续还会逐步的完善,如果对大家有用还希望可以多多点赞👍

1.Promsie的核心观点是什么?


我们来想象这样一个场景,一秒后我们需要在控制台输出1,等这个1输出后再隔一秒输出2,2输出成功之后再输出3。这个场景是我看见的一道字节的面试题,很好实现:

	new myPromsie(resolve => {
            setTimeout(() => {
                resolve('111');
            },1000)
        })
        .then((data) => {
            console.log(data);
            return new myPromsie(resolve => {
                setTimeout(() => {
                    resolve('222');
                },1000)
            })
        }).then(data => {
            console.log(data);
            return '333'
        }).then(data => {
            console.log(data);
        })


这个场景其实常常是用在发送异步请求的情况,一个请求成功后再发送另一个请求,这其就是从本质上解决了回调地狱的问题,所以回到最初的问题,Promise的核心观点是什么?异步链式调用

其实这里我只说了点皮毛,如果再深入Promsie的核心思想,可能要牵涉到函数式编程,函子等概念,能力有限就不说了。

当然Promise还有一些特点如:

  1. 回调函数延时绑定
  2. 返回值穿透
  3. 错误冒泡等


大家也可以好好理解,说完了核心观点接下了我们就需要来看看这个核心观点究竟是怎么实现的,我想下面会给你答案。

2.可以手写一个简单的Promise吗?


这里参照ssh大佬的最简实现Promise,支持异步链式调用,但是在这里为了方便大家的理解我决定还是画一个流程图来说明整个执行过程,首先大家可以根据注释先一行一行的捋一下整个执行的过程,如果想看更加详细的手写Promise这里推荐【三元】大神的JS灵魂之问下

先来看一个净爽版:

        function myPromsie(fn){
            this.callbacks = [];
            const resolve = (value) => {
                setTimeout(() => {
                    this.data = value;
                    this.callbacks.forEach(callback => {
                        callback();
                    })
                })
            }
            fn(resolve);
        }
        myPromsie.prototype.then = function(onResolved) {
            return new myPromsie((resolve) => {
                this.callbacks.push(() => {
                    let retValue = onResolved(this.data);
                    if(retValue instanceof myPromsie) {
                        retValue.then(resolve);
                    }else {
                        resolve(retValue);
                    }
                })
            })
        }


有困惑的同学可以看注释版:

        function myPromsie(fn){
            // callbacks:用来存放所有then的回调,为什么要存放?
            // 因为只有resolve状态我们才会执行then中成功的回调
          	// 成功的回调不是一定执行的,所以我们先要将其存放起来,之后通过resolve来控制
            this.callbacks = [];
          	// 声明resolve方法
            const resolve = (value) => {
              	// 这里用setTimeout是为了模仿异步微任务,真正的微任务只有通过浏览器底层才可以调用
                setTimeout(() => {
                  	// 将我们调用resolve时传递的参数保存在构造函数里,为什么要保存起来?
                  	// 为了后面在then的回调中可以得到
                    this.data = value;
                  	// 这里就好理解了我们在调用resolve时便需要触发then中的回调了
                  	// 那么问题又来了我们知道JS是一行一行执行的,此时callbacks还是空的又怎么执行里面的回调呢?
                  	// 大家注意看这里我们采用的setTimeout异步任务,
                  	// 虽然没有延时时间但在执行时其还是会被放在宏任务队列里,等待同步任务执行完再执行
                    this.callbacks.forEach(callback => {
                        callback();
                    })
                })
            }
            // 调用我们Promsie中传入的回调函数,回调函数可以接收resolve作为参数,这个参数本质就是上面声明的resolve方法
            fn(resolve);
        }
				// 给myPromsie原型上添加then方法,实例调用then方法时首先会在自身上查找then方法,
				// 然后沿着原型链__proto__去构造函数的原型对象上查找
				// onResolved就是传入then中的回调
        myPromsie.prototype.then = function(onResolved) {
          	// 为了链式调用我们的then方法需要返回一个新的Promsie对象
            return new myPromsie((resolve) => {
              	// 在这里我们需要将回调函数放入数组中等待调用
                this.callbacks.push(() => {
                  	// 调用then传入的回调函数,并将之前保存传入resolve的参数传给他
                  	// 注意这里的retValue是then中的返回值,这里有两种情况
                    let retValue = onResolved(this.data);
                    // 如果then中返回的是Promise的情况比较复杂,后面我专门画一个流程图专门来说
                    if(retValue instanceof myPromsie) {
                        retValue.then(resolve);
                    }else {
                      	// 上一个then的返回值作为reslove的参数传递给下一个then方法
                        resolve(retValue);
                    }
                })
            })
        }

3.Promsie难点是什么?


关于Promsie难点就在于then的返回值是Promise对象的情况,为了说明then中返回的是一个Promsie的情况,我们先抽象的思考一下我们要完成什么

这里吹一下水,其实编程中很多方法在具体实现之前,应该先想想要做什么,为了某个功能然后再去一点点的实现它,我想即使是Vue这样的框架,尤雨溪在写它的时候也一定是首先思考我要完成什么功能,怎么样更快捷,而不是直接写代码,在日常开发中我总是忽略这一点,有些时候就是死记代码,这里提醒自己一下。

在then方法的内部本质上是返回了一个新的Promsie对象,我们将其称之为thenPromsie,之后需要通过调用这个thenPromsie的reslove方法,来触发执行下一个then中的回调函数

如果then内部返回值是一个值的情况很好解决,直接调用thenPromsie的resolve方法便好,那么如果返回值是一个Promsie对象的情况我们要根据这个Promise对象是否被resolve而决定thenPromsie是否被resolve,继而决定是否执行下一个then中的回调函数。

那么问题就明朗了,如何监听需要返回的Promise对象是否被resolve呢?如果一个Promise对象执行了resolve方法,那么其then中的回调函数将会执行,对应的是这句话:

let retValue = onResolved(this.data);


这里的retValue就是用户需要返回的Promsie对象,所以我们将thenPromsie中的resolve方法,传入用户Promise.then方法的回调里,一旦用户的Promise被resolve,用户Promise.then方法里的回调onResolved便会执行,从而执行thenPromsie中的resolve方法,从而执行下一个then中的回调。也就是这句我称之天才代码的含义:

retValue.then(resolve);

根据流程图我想大家一定可以理解了。

4.为什么Promsie要引入微任务?


因为同步任务和宏任务都不行。

  • 同步任务:难道代码执行一半去发送一个Ajax请求然后等待响应吗?
  • 宏任务:我们知道发送Ajax,操作DOM,定时器都属于宏任务,假设此时我们增加了100个DOM难道等待DOM操作完成之后我们再来发送下一个Ajax请求(异步链式调用情况)?

5.可以手写Promise.all吗?


直接看代码吧,实现过程还是比较简单的:

  • 我们知道Promsie.all和上面then一样返回的也是新的Promsie对象
  • for循环遍历所有传入的Promise对象,并传入Promise.resolve方法
  • Promise.resolve之后then的回调中将所有的data保存在数组
  • 当遍历完所有传入的Promise对象之后,将保存的data传递给需要返回出去的resolve
  • 否则直接将错误reject
	PromisemyAll = function(promiseArr){
            return new Promise(function(resolve,reject){
                let result = [];
                let index = 0;
                let len = promiseArr.length;
              	if(len === 0) {
                    resolve(result);
                    return
                }
                for(let i = 0;i<len;i++){
                    // 这里不直接promiseArr[i].then是为了防止传入的不是Promsie对象的情况
                    Promise.resolve(promiseArr[i]).then(data => {
                        result[i] = data;
                        index++;
                        if(index === len) resolve(result);
                    }).catch(err => {
                        reject(err);
                    })
                }
            })
        }

6.可以手写Promsie.race吗?

比较简单,碰到第一个元素直接resolve返回便好,直接看代码:

        PromisemyRace = function(promiseArr){
            return new Promise(function(resolve,reject){
                let len = promiseArr.length;
                if(len === 0) return
                for(let i = 0;i<len;i++){
                    // 这里不直接promiseArr[i].then是为了防止传入的不是Promsie对象的情况
                    Promise.resolve(promiseArr[i]).then(data => {
                        resolve(data)
                        return
                    }).catch(err => {
                        reject(err);
                        return
                    })
                }
            })
        }

7.Promise中用了什么设计模式?

能问这种问题的公司我想应该是大厂,而且也应该是面高级了,本人能力不够,有兴趣的同学直接看这篇文章吧从设计模式角度分析Promise:手写Promise并不难

8.Promise都有哪些状态?


Promise接收两个参数,resolve,reject,其有三种状态Pending,fulfilled,rejected,当任何方法都不执行时其是Pending状态(未完成)。

resolve('resolve')方法在异步操作成功时调用,并将异步操作的结果作为参数返回出去。之后其会变为fulfilled
状态(完成)。

rejected('error')方法在异步操作失败时调用,并将异步操作的失败信息作为参数返回出去,之后其会变为rejected状态(失败)。

9.如何实现Ajax并发请求控制



想象一个场景,如果我们有10万了请求要发送,如果一次性全部发送,可能导致服务器或者计算机过载,我们可以限制一个同时发送请求的最大值,当所有的请求都发送完之后我们将请求的结果放在一个数组里,一次性返回出去,如这道题。


要求:
实现一个批量请求函数 multiRequest(urls, maxNum),要求如下:
• 要求最大并发数 maxNum
• 每当有一个请求返回,就留下一个空位,可以增加新的请求
• 所有请求完成后,结果按照 urls 里面的顺序依次打出


利用while循环,控制第一次发送请求的数量,每个请求发送完通过递归发送比较简单直接看代码:

        function multiRequest(urls = [],maxNum){
            const len = urls.length;
            const result = new Array(len).fill(false);
            let count = 0;
            return new Promise((resolve,reject) => {
                // 最多同时发送maxNum个请求
                while(count < maxNum) {
                    sendRequest();
                }
                function sendRequest(){
                    let curCount = count;
                    count++;
                    if(curCount >= len) {
                        !result.includes(false) && resolve(result);
                        return
                    }
                    let curUrl = urls[curCount];
                    console.log(`开始发送第 ${curCount}个请求 ${curUrl}`,new Date().toLocaleString());
                    fetch(curUrl).then((data) => {
                        console.log(`第 ${curCount}个请求:${curUrl}成功了!`,new Date().toLocaleString());
                        result[curCount] = data;
                        if(count < len) {
                            sendRequest();
                        }
                    }).catch(err => {
                        console.log(`第 ${curCount}个请求:${curUrl}失败了!`,new Date().toLocaleString());
                        result[curCount] = err;
                        if(count < len) {
                            sendRequest();
                        }
                    })
                }
            })
        }

串行与并行:

补充两个简单概念:串行与并行

  • 串行:一个异步请求完了之后在进行下一个请求
  • 并行:多个异步请求同时进行


串行:

var p = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('1000')
      resolve()
    }, 1000)
  })
}
var p1 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('2000')
      resolve()
    }, 2000)
  })
}
var p2 = function () {
  return new Promise(function (resolve, reject) {
    setTimeout(() => {
      console.log('3000')
      resolve()
    }, 3000)
  })
}


p().then(() => {
  return p1()
}).then(() => {
  return p2()
}).then(() => {
  console.log('end')
})


并行:

        let p = [1000,2000,3000].map(time => {
            return new Promise((resolve,reject) => {
                setTimeout(function(){
                    resolve(time+'xxxxxxx')
                },time)
            })
        })
        Promise.all(p).then((args) => {
            console.log(...args);
        })

10.一个任务并发控制器,要求每次都有两个任务在执行

我相信如果看了上面那道题之后对这道题一定会有些思路,这种并发控制的题不外乎是通过while循环控制最大并发数,利用递归使任务持续执行

题目描述:

//支持并发的调度器, 最多允许2两任务进行处理
const scheduler = new Scheduler(2)
scheduler.addTask(1, '1');   // 1s后输出’1'
scheduler.addTask(2, '2');  // 2s后输出’2'
scheduler.addTask(1, '3');  // 2s后输出’3'
scheduler.addTask(1, '4');  // 3s后输出’4'
scheduler.start();


一种方案:

        class Scheduler{
            constructor(maxTask){
                // 同时并行任务的最大数量
                this.maxTask = maxTask;
                // 存储添加的任务
                this.tasks = [];
                // 任务总数
                this.count = 0;
            }
            addTask(timer,data){
                this.tasks.push({
                    'timer':timer,
                    'data':data
                })
                this.count += 1; 
            }
            next(){
                let p = new Promise((resolve,reject) => {
                    let obj = this.tasks.shift();
                    this.count -= 1;
                    setTimeout(() => {
                        resolve(obj.data)
                    },parseInt(obj.timer * 1000))
                })
                p.then(data => {
                    console.log(new Date().toLocaleString(),data);
                    if(this.tasks.length !== 0) {
                        // 利用递归控制任务持续执行
                        this.next();
                    }
                })
            }
            start(){
                // 核心:利用while循环控制最大并发数
                while(this.maxTask) {
                    this.next();
                    this.maxTask -= 1
                }
            }
        }

11.Promise和async你觉得差异点是什么?

要回答这道题我们首先应该来思考一下面试官要考察什么?我认为主要还是分别考察面试者对于Promsie与async的理解,以及这两者在异步操作方面的表现。这里就先重点说一些async。

asyncGenerator函数的语法糖,不同的是Generator函数是手动调用的,而async函数是await执行完之后才会自动执行下一个await前面的语句,无论await前面是异步方法还是同步方法。

await后面可以跟很多值,如基本数据类型、(字符,数值,布尔等会被自动转换为立即resolved的Promsie对象)Promise对象。

async内部是异步执行的,无论await后面跟的是同步任务还是异步任务,最终async函数会返回一个Promise对象,所以async函数可以看成是多个异步操作包装成的Promise对象,async让Promsie的使用更顺滑。

为了回答两者在异步操作方面的表现,我想起我之前在
阮一峰的ES6
上面看到的一个例子。

如果我们需要按照顺序来异步加载许多数据,如果我们使用Promise的话,我们需要使用map来遍历我们发送我们的请求,之后使用reduce通过then方法来将所有的Promsie连接起来。 如果我们使用async的话一个for循环便可以解决,代码大大的被简化。总结一句也就是说async适用于依次发送大量请求的情况。


也就是说如果我们要串行发送很多请求,在使用Promise的情况下非常麻烦,如下面这段代码。

function logInOrder(urls) {
  // 远程读取所有URL
  const textPromises = urls.map(url => {
    return fetch(url).then(response => response.text());
  });

  // 按次序输出
  textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise)
      .then(text => console.log(text));
  }, Promise.resolve());
}

上面代码使用fetch方法,同时远程读取一组 URL。每个fetch操作都返回一个 Promise 对象,放入textPromises数组。然后,reduce方法依次处理每个 Promise 对象,然后使用then,将所有 Promise 对象连起来,因此就可以依次输出结果。 这种写法不太直观,可读性比较差。下面是 async 函数实现。


我们可以想想如果我们使用async的话,我们只要使用一个for of循环读取所有的url,在循环内部使用await控制循环暂停,来发送请求便好。

async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}


上面代码确实大大简化,问题是所有远程操作都是继发。只有前一个 URL 返回结果,才会去读取下一个 URL,这样做效率很差,非常浪费时间。我们需要的是并发发出远程请求。

async function logInOrder(urls) {
  // 并发读取远程URL
  const textPromises = urls.map(async url => {
    const response = await fetch(url);
    return response.text();
  });

  // 按次序输出
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}

上面代码中,虽然map方法的参数是async函数,但它是并发执行的,因为只有async函数内部是继发执行,外部不受影响。后面的for..of循环内部使用了await,因此实现了按顺序输出。


说实话为什么map里的回调是async函数,但是事实上是并发执行的,我做了一些实验确实是这样,这一点我还没有很理解,知道的兄弟可以给我在下面留言让我看看。

所以最后对于问题我认为主要回答两个点:

  • async函数本质可以看成是多个异步操作包装成的Promise对象
  • async函数在处理多个异步串行请求时更方便

12.new Promise返回的实例和实例then方法执行后返回的promise是一个吗

这个问题我相信看了上面我手写简单Promise的同学应该很容易回答上来,实例then返回的是一个新的Promise对象,这个新Promsie的状态由then中的返回值决定,如果then内部return一个Promsie对象,那么返回的Promsie对象状态便是return的Promise的状态,如果return一段字符串那么便是一个resolve状态的Promsie对象。


最后如果大家觉得有用,更多前端精彩文章还请大家关注我的微信公众号[南橘前端]
我们一起学习鸭!!!