JavaScript 微任务/宏任务踩坑


JavaScript 微任务/宏任务踩坑

场景

SegmentFault

在使用 async-await 时,吾辈总是习惯把它们当作同步,终于,现在踩到坑里去了。
使用 setTimeoutsetInterval 实现的基于 Promisewait 函数,然而测试边界情况的时候却发现了一些问题!

实现代码

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = param => {
  return new Promise(resolve => {
    if (typeof param === 'number') {
      setTimeout(resolve, param)
    } else if (typeof param === 'function') {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer)
          resolve()
        }
      }, 100)
    } else {
      resolve()
    }
  })
}

测试代码

;(async () => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false
  const add = async (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    if (taskIsRun) {
      console.log(i + ' 判断前: ')
      await wait(() => {
        return !taskIsRun
      })
      console.log(i + ' 判断后: ' + taskIsRun)
    }
    try {
      taskIsRun = true
      console.log(i + ' 执行前: ' + taskIsRun)
      await wait(100)
    } finally {
      console.log(i + ' 执行后: ')
      taskIsRun = false
    }
  }

  const start = Date.now()
  await Promise.all(
    Array(10)
      .fill(0)
      .map(add),
  )
  console.log(Date.now() - start)
})()

那么,先不要往下看,猜一下最后打印的大概会是多少呢?

实际执行结果

0 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

2 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

3 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

4 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

5 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

6 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

7 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

8 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

9 判断前:  ​​​​​at ​​​i + ' 判断前: '​​​ ​src/module/function/wait.js:29:6​

0 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

1 判断后: false ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

1 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

// 这儿的 1 执行前,结果 2 就已经判断通过并准备执行了???发生了什么?

2 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

2 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

3 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

3 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

4 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

4 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

5 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

5 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

6 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

6 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

7 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

7 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

8 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

8 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

9 判断后: true ​​​​​at ​​​i + ' 判断后: ' + taskIsRun​​​ ​src/module/function/wait.js:33:6​

9 执行前: true ​​​​​at ​​​i + ' 执行前: ' + taskIsRun​​​ ​src/module/function/wait.js:37:6​

1 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

2 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

3 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

4 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

5 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

6 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

7 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

8 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

9 执行后:  ​​​​​at ​​​i + ' 执行后: '​​​ ​src/module/function/wait.js:40:6​

307 ​​​​​at ​​​Date.now() - start​​​ ​src/module/function/wait.js:52:2​

可以看到,很神奇的是 判断后 => 执行前 => 判断后…=> 执行后…_,并不是预想中的 _判断后 => 执行前 => 执行后… 的循环,所以,到底发生了什么呢?

思考

这个问题卡了吾辈两天之久,直到吾辈在 StackOverflow 提出的另一个相关的问题被外国友人回答了,瞬间吾辈就想起了 – async-await 本质上还是异步

是的,为什么会出现 wait 一直在执行而后面的 taskIsRun = true 却并没有执行?因为 JavaScript 中的 async-await 虽然可以写出来很像同步代码的异步代码,但实际上还是异步的,原理还是基于 Promise

我们改造一下代码,将之使用原生 Promise 实现一下

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = param => {
  return new Promise(resolve => {
    if (typeof param === 'number') {
      setTimeout(resolve, param)
    } else if (typeof param === 'function') {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer)
          resolve()
        }
      }, 100)
    } else {
      resolve()
    }
  })
}
;(() => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false
  const add = (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    return Promise.resolve()
      .then(() => {
        if (taskIsRun) {
          console.log(i + ' 判断前: ')
          // 关键在于这里,实际上执行完成之后并不会到下一个 then,而是继续另一个 wait 的判断
          return wait(() => !taskIsRun).then(() => {
            console.log(i + ' 判断后: ' + taskIsRun)
          })
        }
      })
      .then(() => {
        taskIsRun = true
        console.log(i + ' 执行前: ' + taskIsRun)
        return wait(100)
      })
      .catch(() => {})
      .then(() => {
        console.log(i + ' 执行后: ')
        taskIsRun = false
      })
  }

  const start = Date.now()
  Promise.all(
    Array(10)
      .fill(0)
      .map(add),
  ).then(() => console.log(Date.now() - start))
})()

这个时候就可以看出来了,判断逻辑是处在一个 then 后继里面的。那么,执行完 console.log(i + ' 判断后: ' + taskIsRun) 之后,就一定会继续执行下面的 then 函数么?并不,这时候 wait 函数内部实现中的 setInterval 还在运转,实际上 nodejs 并不会优先继续 then 这种 microtask(微任务),而是会继续进行 setInterval 这种 macrotask(宏任务)。这是 nodejs 与浏览器实现不一致的地方,吾辈将这些代码复制到浏览器上,确实可以正常执行并得到预期的结果。

微任务与宏任务参考

if (taskIsRun) {
  console.log(i + ' 判断前: ')
  return wait(() => !taskIsRun).then(() => {
    console.log(i + ' 判断后: ' + taskIsRun)
  })
}

当然,nodejs 11 修复了这个问题,参考 https://github.com/nodejs/node/pull/22842。然而目前 NodeJS LTS 为 10,最新版本为 12,这个问题可能还要持续一段时间。

解决

那么,难道吾辈就必须等到 NodeJS LTS 最新版之后才能用 wait 么?或者说,吾辈就必须依赖于浏览器的 microtask/macrotask 么?并不,吾辈对之手动进行了处理即可!

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * @param {Number|Function} [param] 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
export const wait = param => {
  return new Promise(resolve => {
    if (typeof param === 'number') {
      setTimeout(resolve, param)
    } else if (typeof param === 'function') {
      const timer = setInterval(() => {
        if (param()) {
          clearInterval(timer)
          resolve()
        }
      }, 100)
    } else {
      resolve()
    }
  })
}
;(async () => {
  // 标识当前是否有异步函数 add 在运行了
  let taskIsRun = false
  const add = async (_v, i) => {
    // 如果已经有运行的 add 函数,则等待
    if (taskIsRun) {
      console.log(i + ' 判断前: ')
      await wait(() => {
        const result = !taskIsRun
        // 关键在于这里
        if (result) {
          taskIsRun = true
        }
        return result
      })
      console.log(i + ' 判断后: ' + taskIsRun)
    }
    try {
      taskIsRun = true
      console.log(i + ' 执行前: ' + taskIsRun)
      await wait(100)
    } finally {
      console.log(i + ' 执行后: ')
      taskIsRun = false
    }
  }

  const start = Date.now()
  await Promise.all(
    Array(10)
      .fill(0)
      .map(add),
  )
  console.log(Date.now() - start)
})()

吾辈在 wait 函数中,即 setInterval 循环调用的函数中对 taskIsRun 进行了修改,而不是在 wait 后面,即 then 之后的 microtask 中进行修改,结果便一切如同吾辈所期待的一样了!


文章作者: rxliuli
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 rxliuli !
 上一篇
JavaScript 处理树数据结构 JavaScript 处理树数据结构
JavaScript 处理树结构数据场景即便在前端,也有很多时候需要操作 树结构 的情况,最典型的场景莫过于 _无限级分类_。之前吾辈曾经遇到过这种场景,但当时没有多想直接手撕 JavaScript 列表转树了,并没有想到进行封装。后来遇到
2019-05-23 rxliuli
下一篇 
JavaScript 防抖和节流 JavaScript 防抖和节流
JavaScript 防抖和节流场景网络上已经存在了大量的有关 防抖 和 节流 的文章,为何吾辈还要再写一篇呢?事实上,防抖和节流,吾辈在使用中发现了一些奇怪的问题,并经过了数次的修改,这里主要分享一下吾辈遇到的问题以及是如何解决的。 为什
2019-05-09 rxliuli
  目录