How JavaScript Works?—— Event Loop

1,048 阅读5分钟

我们平常在写 setTimeout,onclick,ajax, Promise 的时候,通常称之为异步。拿 setTimeout 来说,定时任务会在合适的时机去执行。既然 JavaScript 是单线程,那是谁在定时,谁在判断是否应该去执行定时任务了?所谓异步执行和单线程是什么关系?

再看网上很常见的前端题:

console.log(1)

setTimeout(function() {
  console.log(2)
}, 0)

new Promise(function (resolve) {
  console.log(3)
  var i = 0
  while (i < 9999999) {
    i++
    }
    resolve()
  console.log(4)
}).then(function() {
  console.log(5)
})

console.log(6)

运行结果: 1,3,4,6, 5,2

如果你刚学 JavaScript ,可能会觉得很懵逼,为什么 setTimeout 0 不是马上执行?为什么 Promise then 比 setTimeout 快?如果你有点困惑,欢迎继续阅读下面的内容。

引擎和环境

Javascript 是由引擎和宿主环境构成,比如在 Chrome 浏览器里面就是 V8 引擎结合浏览器宿主环境。写页面的时候常用的 setTimeout,ajax, DOM 这些 API 是由宿主环境提供,引擎本身不负责这些功能。所以 Node.js Timer 里面的 setTimeout 和浏览器里的 setTimeout 不是一个东西。

V8 引擎只负责维护 Call Stack 和 Memory Heap,Memory Heap 就是平时内存申请发生的地方。Call Stack 是我们比较关注的点。
The JavaScript Engine

The JavaScript Engine

Call Stack

Call Stack 记录着当前代码执行位置,所以报错的时候我们会见到 stack 里面的信息。也就是走到错误这一步之前,经历了什么。

function foo () {
  throw new Error('Oops!')
}
function bar () {
  foo()
}
bar()

Oops

Oops

让程序爆炸——JS 是单线程的编程语言,单线程意味着,单位时间内只能做一件事。 Call Stack 顶端是 Javascript 当正前在运行的代码,并且称为 Stack,拥有 First In Last out 的特性。如果你写的 Function 没有被 pop 出栈,那其他后续的代码永远没有执行的机会。(然后你会收到 FBI 的红牌警告:Uncaught RangeError: Maximum call stack size exceeded)

function foo () {
  foo()
}
foo()
console.log('call me please')

Call Stack 的执行单位是 Task,Task 分两类 Macro Task 和 Micro Task。

Macro Task & Micro Task

(有人翻译叫宏任务和微任务。)

常见的 Task 分类:
Macro Tasks(task): setTimeout, setInterval, setImmediate, I/O, UI rendering
Micro Tasks: process.nextTick, Promises, Object.observe, MutationObserver

Task 执行的位置发生在 Call Stack 上,执行的顺序是这样的:

  1. 浏览器会先取一个 MacroTask 执行,过程中可能会创造新的 Task (Macro Task 或者 Micro Task)
  2. 取出所有所有 MicroTask 依次执行,知道当前 MicroTask queue 没有内容
  3. 取出下一个可执行 MacroTask ,继续第二步

周而复始,这个过程称为 Event Loop

问题解释

了解了上面这些内容,再来理解为什么执行结果是 1,3,4,6, 5,2

  1. 全局代码是一个 MacroTask ,开始执行 console.log(1)

    console:
    callstack: console.log(1)
    macrotasks: []
    microtasks:[]
    
  2. 遇上 setTimeout,生成一个 Macro Task 交给浏览器

    console: 1
    callstack: empty
    macrotasks: [setTimeout]
    microtasks:[]
    
  3. new Promise 内部代码立即执行,走到 console.log(3)

    console: 1
    callstack: console.log(3)
    macrotasks: [setTimeout]
    microtasks:[]
    
  4. 执行 resolve,生成 Micro Task 添加到 Micro Task 队列末端

    console: 1, 3
    callstack: resolve
    macrotasksqueue: [setTimeout]
    microtasksqueue:[promise]
    
  5. 继续执行,console.log(4)

    console: 1, 3
    callstack: console.log(4)
    macrotasksqueue: [setTimeout]
    microtasksqueue:[promise]
    
  6. 继续执行,console.log(6)

    console: 1, 3, 4
    callstack: console.log(6)
    macrotasksqueue: [setTimeout]
    microtasksqueue:[promise]
    
  7. 当前 Call Stack 为空,提取 Micro Task 所有任务开始执行

    console: 1, 3, 4, 6
    callstack: console.log(5)
    macrotasksqueue: [setTimeout]
    microtasksqueue:[]
    
  8. MicorTask 队列为空,取下一个 Macro Task

    console: 1, 3, 4, 6, 5
    callstack: console.log(2)
    macrotasksqueue: []
    microtasksqueue:[]
    
  9. 没有 Macro Task 执行完毕。

    console: 1, 3, 4, 6, 5, 2
    callstack: empty
    macrotasksqueue: []
    microtasksqueue:[]
    

拓展

理解这些内容,你对 JS 代码的执行顺序能有更准确的判断,以及阅读别人代码时能会心一笑。

比如 Vue nextTick 的实现。看过文档,你已经知道,Vue nextTick 方法会优先使用 Promise resolve,然后 fallback 到 setTimeout 0。

github-vue-next-tick

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

function microTimerFunc () {
  const p = Promise.resolve()
  p.then(flushCallbacks)
}
function macroTimerFunc () {
  setTimeout(flushCallbacks, 0)
}

export function nextTick (cb?: Function, ctx?: Object) {
  callbacks.push(() => {
     cb.call(ctx)
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
}

已删减兼容代码和无关内容。

在不了解运行顺序的情况下,容易产生困惑,pending 参数是用来干嘛的? 两个 nextTick 同时执行会发生什么?为什么要用 callbacks 把回调存起来,而不是每次 nextTick 执行一次 timerFunc ?现在好像有了比较清晰地认识。

对运行机制一直没有搞清楚,前几天 Arvit 大哥问我为什么使用 async/await 去执行‘复杂运算‘的时候,为什么 UI 会被阻塞,点击按钮无法响应。 由于并且没审好题,犯了理所当然的错误(很丢脸)。现在看来,没有响应是因为‘复杂运算‘占住了 Call Stack,一直没有被处理完。浏览器渲染,以及 DOM 事件回调都在 task queue 里面等待着。

Arvit 这类问题解决方案是:

  1. 不要用 Javascript 去进行‘复杂运算‘。
  2. 如果非得前端进行运算,可以尝试使用 Web Worker 在不阻塞页面渲染的情况下去跑。
  3. 存在递归调用,可以使用 setTimeout 0 执行下一次递归,(不能使用 Promise then,因为 Micro Task Queue 不清理完是不会执行下一个 Macro Task 的)

由于水平有限,也没有阅读过 V8 引擎源码,所以以上大部分内容均来自别人分享的文章。可能有很多认知上的问题,如有错误,请指出。

学习资料

如需转载,请注明出处: w3ctrain.com / 2018/02/01/javascript-event-loop/

我叫周晓楷

我现在是一名前端开发工程师,在编程的路上我还是个菜鸟,w3ctrain 是我用来记录学习和成长的地方。