面试题 LazyMan 引发的对 async & await 的思考

411 阅读3分钟

LazyMan 是什么

来看一下 LazyMan 的调用方式:

new LazyMan('Tony').eat('lunch').eat('dinner').sleep(10).sleepFirst(5).eat('junk food');

以及预期的运行结果:

// Hi I am Tony
// 等待了5秒...
// I am eating lunch
// I am eating dinner
// 等待了10秒...
// I am eating junk food

观察可知,LazyMan 要实现的几个功能有:

  1. 构造时接收名字,并打印.
  2. 链式调用.
  3. .eat(food):直接打印.
  4. .sleep(t):真实的等待了 t 秒.
  5. .sleepFirst(t):与 sleep 基本功能 一样,但它必须紧跟着打印名字之后执行。

async & await 简单介绍

要解决这道题,我们需要先了解一下 async & await,其原理在我之前的博客中有介绍过。其基本使用方式如下:

async function read() {
      let content = await xxx
      ...
}

await 后面的代码会被 Promise.resolve() 进行解析,通过 .then() 方法接收的 onFulFilled 回调函数将 promise 的成功结果通过 next 赋值给 contentpromise是失败的话,需要用 try & catch,此处不做考虑),同时,next 使得接下来的代码得以运行。

结论就是: await 右边的 promiseresolve 过的) 在状态变成 fulFilled 之后,通过 .then 并执行 onFulFilled 回调(放入微任务队列),才会让接下来的代码执行。

async & await 中的 setTimeout

之所以要讨论这个内容,是因为我之前以为 await 会等待任何的异步代码执行完成,我太想当然了。 我们知道 setTimeout 是宏任务,次于微任务执行。来看一个例子,

async function fn() {
    let res = await new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(1)
            resolve(2)
        }, 1000)
    })
    console.log(res)
}

fn()

打印结果 因为 await 需要等待的 promise 状态变成 fulFilled 才能往下执行,所以必须等待 setTimeout 中的回调被执行完成,才能调用 resolve(2)

接下来,我们将 resolve(2) 放在 setTimeout 外面

async function fn() {
    let res = await new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(1)
        }, 1000)
        resolve(2)
    })
    console.log(res)
}

fn()

打印结果 其中 2 是很快就打印出来的,而 1 是等待了一秒之后打印的。 再极端一点,我们不给 promise 改变状态的机会

async function fn() {
    try {
        let res = await new Promise((resolve, reject) => {
            setTimeout(() => {
                console.log(1)
            }, 1000)
            // resolve(2)
        })
        console.log(res)
    } catch (err) {
        console.log(err)
    }
}

fn()

结果 我们可以看到,只要 promise 没有状态,等到宏任务执行完了,await 下面的代码也执行不了,而且这也不会抛出一种错误,所以尽量不要这样操作。

得出结论:await 等待的是 promise 的状态变化,而不是等待任何异步的代码执行完成! 这其实在第二部分对原理的分析中可以很明确的知道,虽然我之前了解过原理,也只是想当然的去思考,千万不要想当然!

LazyMan 的实现

理解了上面的内容之后,对于实现一个 LazyMan 还是不难的。要点就是要将 promsie 状态的变化放在 setTimeout 中,这样才能让 awaitsetTimeout 进行等待。

class LazyMan {
    constructor(name) {
        this.name = name
        this.stack = []
        this.timer = null
        this.sayHi()
    }
    sayHi() {
        console.log('Hi I am ' + this.name)
    }
    eat(food) {
        this.stack.push(() => {
            console.log('I am eating ' + food)
        })
        return this.next()
    }
    sleep(time) {
        this.stack.push(() => {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log('等待了' + time + '秒...')
                    resolve()
                }, time * 1000)
            })
        })
        return this.next()
    }
    sleepFirst(time) {
        this.stack.unshift(() => {
            return new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log('等待了' + time + '秒...')
                    resolve()
                }, time * 1000)
            })
        })
        return this.next()
    }
    next() {
        clearTimeout(this.timer)
        //只是设置了定时器,放在宏任务队列中
        this.timer = setTimeout(async () => {
            for (let i = 0; i < this.stack.length; i++) {
                await this.stack[i]()
            }
        }, 0)
        return this
    }
}

对其他的细节做一下解释:

  1. 链式调用,返回当前实例即可,这里不用像 Promise 那样 new 一个。
  2. 因为不知道链式调用的次数(不用链式也可以),所以每链式调用之后就要对队列中的函数进行执行,当下一次调用的时候,会将计时器清空,因为同步代码优于宏任务执行,所以计时器设置为 0 也不会在链式输入读完之前执行,通过这种防抖的方式,能保证在最后一次链式输入之后,队列中的任务能一次执行。
  3. sleepFirst 只需要直接放入到队列的最前端即可(不考虑多个 sleepFirst 调用)。