其一

群里看到的一道事件循环的题:

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
  setTimeout(() => {
    console.log('timer1')
  }, 0)
}
async function async2() {
  setTimeout(() => {
    console.log('timer2')
  }, 0)
  console.log("async2");
}
async1();
setTimeout(() => {
  console.log('timer3')
}, 0)
console.log("start")

看到这里,你可以猜想一下输出结果是什么 …

正确答案应该是:

async1 start
async2
start
async1 end
timer2
timer3
timer1

第一次做的时候我其实猜错了,我以为答案应该是:

async1 start
async2
async1 end
start
timer2
timer1
timer3

这里的关键其实是搞清楚 await async2() 做了什么事情。我以为在 async1 内部,async2 被调用之后,就会继续往后执行,因此是先打印 async1 end ,再回到主栈打印 start。然而 async2 里面包含了一个异步操作,在异步操作得到结果之前,其实是会跳出当前 async1 函数的执行栈,优先去执行同步任务的,所以这里其实会先执行 start,再去执行 async1 end。具体地说:

async function f(){
    await p1
    // some operations
}
// 可以近似理解为
function f(){
    return Promise.resolve(p1).then(res => {
        //some operations
    })
}

所以原来的代码可以近似理解为:

async function async1() {
  console.log("async1 start");
  return Promise.resolve(async2()).then(res => {
      console.log("async1 end");
      setTimeout(() => {
        console.log('timer1')
      },0)
  })  
}
async function async2() {
  setTimeout(() => {
    console.log('timer2')
  }, 0)
  console.log("async2");
}
async1();
setTimeout(() => {
  console.log('timer3')
}, 0)
console.log("start")

接下来就可以像以前分析事件循环那样去理解了:

第一轮事件循环:

① 宏任务:整个代码块作为宏任务执行,调用 async1 函数,进入函数执行栈。打印 async1 startasync2 位于 Promise 的执行器中,因此立即执行,遇到了定时器 timer2,把其回调函数分发到宏任务队列,之后打印 async2;Promise 的 then 的回调函数被分发到微任务队列。 async1 执行完毕,返回到主栈,遇到了定时器 timer3,其回调函数被分发到宏任务队列。接着打印 start,主栈清空。

② 微任务:微任务队列中有 then 的回调函数,进入主栈并执行,打印 async1 end,之后遇到定时器 timer1,其回调函数注册到被分发到宏任务队列。之后,微任务队列中无任务,第二轮事件循环结束

第二轮事件循环:

① 宏任务:根据之前进队列的顺序,宏任务队列中依次有 timer2timer3timer1 这几个定时器的回调函数。timer2 的回调函数进入主栈并执行,打印 timer2

② 微任务:微任务队列中无任务,第二轮事件循环结束

第三轮事件循环:

① 宏任务:宏任务队列中依次有 timer3timer1 这两个定时器的回调函数。timer3 的回调函数进入主栈并执行,打印 timer3

② 微任务:微任务队列中无任务,第三轮事件循环结束

第四轮事件循环:

① 宏任务:宏任务队列中只有 timer1 这个定时器的回调函数。timer1 的回调函数进入主栈并执行,打印 timer1

② 微任务:微任务队列中无任务,第四轮事件循环结束

其二

之后又看到这么一段代码(Nodejs):

let fs = require('fs')
function readFile(fileName) {
  return new Promise(function (resolve,reject) {
    fs.readFile(fileName,function (error,data) {
      if(error) return reject(error)
      resolve(data)
    })
  })
}
async function readAll(paths){
  const promises = paths.map(async path => {
     const res = await readFile(path)
     return res.toString()
  })
  for(promise of promises){
      console.log(4)
      console.log(await promise)
  }
}
readAll(['1.txt','2.txt','3.txt']) 

这段代码异步读取本地文件,假设三个 txt 的内容分别是 123,最后会输出什么呢?

答案是 4 1 4 2 4 3。确实是道很简单的题 … 问的不过是 asyncawait 的常规用法,而且语义上相比 Promise 已经清晰不少了。不过我的老毛病又犯了,想着想着就绕了进去:既然 for...of 是个同步操作,打印 4 的时候不应该能拿到异步读取文件的结果,所以我以为应该是输出:

4
Promise <pending>
4
Promise <pending>
4
Promise <pending>

不过从实际结果来看,却很像是同步打印 4 和文件内容,为什么会这样呢? —— 其实只是看起来像而已!毕竟 async await 所做的就是让我们用同步的方式编写异步代码,但其实,在第一次打印 4 之后,往后的打印操作其实是被放在一个异步的回调里面的

这么说可能不清楚,不妨仿照第一道题的方式,看能不能用同样的方法分析这段代码。前面说过,await 后面的操作可以放在一个 then 的回调里,所以可以把 readAll 函数近似改写为:

async function readAll(paths){
  const promises = paths.map(async path => {
     return Promise(readFile(path)).then(res => {
         return res.toString()
     })
  })
  	console.log(4)
  	console.log(await promise[0])
    console.log(4)
  	console.log(await promise[1])
    console.log(4)
  	console.log(await promise[2])
  }
}

再进一步改写为:

async function readAll(paths){
  const promises = paths.map(async path => {
     return Promise(readFile(path)).then(res => {
         return res.toString()
     })
  })
  	console.log(4)
    return Promise.resolve(promise[0]).then(res => {
        console.log(res)
        console.log(4)
        return Promise.resolve(promise[1]).then(res => {
            console.log(res)
            console.log(4)
            return Promise.resolve(promise[2]).then(res => {
                console.log(res)
            })
        })
    })
  }
}

这是按照前面规则改写的结果,但是好像又退回了 callback hell 的恶心形式,所以这里要再改为 Promise 的链式调用:

async function readAll(paths){
  const promises = paths.map(async path => {
    return Promise.resolve(readFile(path)).then(res => {
      const r = res
      return r.toString()
    })    
  })
  console.log(4)
  return Promise.resolve(promises[0]).then(res => {
    console.log(res)
    console.log(4)
    return Promise.resolve(promises[1])
  }).then(res => {
    console.log(res)
    console.log(4)
    return Promise.resolve(promises[2])
  }).then(res => {
    console.log(res)
  })
}

然后我们再按照事件循环去分析:

第一轮事件循环:

① 宏任务: 整个代码块作为宏任务执行,调用 readAll 函数,进入函数执行栈;通过 map 迭代数组,每一次迭代会立即执行 Promise 中的执行器,进而执行 readFile 函数,由于 resolve 是位于异步回调函数中(尚未执行),所以这里返回的是一个处于 pending 状态的 Promise;此外,每一次迭代也会将 then 中的回调分发到微任务队列。在回调执行前,我们是拿不到文件内容的。 readAll 执行完毕,返回到主栈,第一次打印 4。接着遇到了 Promise.resolve(promises[0]),这里实际上也是一个处于 pending 状态的 Promise,调用它的 then 方法的时候,会把回调分发到微任务队列。这里有三个 then ,每一个 then 里的回调都会依次进入微任务队列。

② 微任务:现在的微任务队列中其实有 6 个任务,依次是返回文件内容的三个回调函数,以及上面所说的三个 then 的回调。从队头任务开始,6 个任务依次进入主栈并顺序执行。这里就会发现,前三个任务的执行负责返回三个文件的内容,后三个任务的执行,每次都会打印一个文件的内容和一个 4。所以我们看到的打印就是:

4
1
4
2
4
3

看起来就很像是同步打印 4 和文件内容,不过其实里面做的仍然是异步操作,这就是 async await 一个强大的地方了。

当然,这里即便用前面带有嵌套回调的代码来分析,也会得出相同的结果,其实这里要保证的主要就是进微任务队列的顺序。不过,为什么一开始会猜想出错误的结果呢?= = 其实是因为忘记对 for...of 中的 await 进行转化(被自己菜哭)。

如果非要得到那个错误的结果的话,代码其实是这样的:

async function readAll(paths){
  const promises = paths.map(async path => {
    return Promise.resolve(readFile(path)).then(res => {
      const r = res
      return r.toString()
    })    
  })
  for(promise of promises){
     console.log(4)
     console.log(promise)
  }
}

注意这里的 promise 前面没有加上 await,所以 for...of 里面的代码就是彻底的同步代码了,在每一次打印 4 之后,promise 也是紧跟着打印出来的,经过前面的分析可以知道,这个时候的 promise 还处于 pending 状态,所以打印结果自然会是:

4
Promise <pending>
4
Promise <pending>
4
Promise <pending>

其实本身是简单的代码,非要往复杂的方向想、来回绕,反而浪费了不少时间。回想起来,在以前的学习中也做过不少这种钻牛角尖的事情,看来今后还是应该尽量去克服这个毛病。不过在思考这两段代码的时候,感觉慢慢地也体会到了书里所说的一些东西,这应该也算是一个小小的收获吧。