JavaScript 的执行顺序 回调队列

本文以JavaScript 执行为主线展开,包括 回调队列,DOM 渲染。

JavaScript 是单线程自上而下依次执行的(Web Worker 可以作为子线程独立于 JavaScript 主线程执行耗时计算解决JS单线程遇到耗时计算卡顿) ,先执行同步任务,遇到异步任务会丢到回调队列(call back Queue)中排队执行。

进程与线程

现代浏览器的每一个标签页都是一个进程,每个进程有独立的内存空间,多进程架构使得每个标签页或扩展程序可以在独立的进程中运行。这样可以提高浏览器的稳定性,因为一个标签页或扩展程序的崩溃比如内存溢出不会影响其他标签页或浏览器本身的运行。而且多进程架构还使得浏览器可以充分利用多核处理器的能力。

线程是进程里的运行单元,一个进程里的多个线程可以共享内存空间,多线程能够让多核CPU 的每个核利用起来。

但对于单核CPU 最好使用单线程,因为单核 CPU 只能让每个线程轮流运行一小段时间,这种线程跳转会增加额外的切换开销。

JavaScript 采用的主要是单线程运行,但是他也有多线程能力, 比如 Web Works 是独立于主线程的子线程,它设计用来执行一些耗时计算,避免主线程的拥堵,在 Web Worker 子线程完成计算后,发消息把结果返回给主线程。

内核与JavaScript 引擎

不同浏览器有不同的内核,Chrome 的webkit, Firefox 的 Gecko。

国内360等双核浏览器用的就是是webkit 和 IE 的Trident,像银行,政府的一些网站(可能有个IE only 名单)就会采用IE Trident 内核,这些网站只能运行在 IE 上,其它网站采用webkit 内核,所以这类浏览器主要解决兼容问题。

内核的主要组成部分有:渲染引擎,JavaScript 引擎(比如Chrome 的V8),Web APIs。

任务队列

浏览器里里有一个运行JavaScript 的环境 – 叫JavaScript 引擎,比如 Chrome 使用的是 V8 引擎,它可以解释并执行JavaScript 代码,Node.js 的运行环境也是 V8 引擎,它同时还提供一个内置模块和 API 给程序员调用。

Event Loop 🔄 不断的轮询 Call Stack 是否为空,当Call Stack为空时就把回调队列的任务取出来放到调用栈执行。

回调队列的任务分微作务和宏任务两种,微任务优先执行。

Web APIs

如果你下载了 V8 引擎的代码,你去搜索 setTimeout, DOM, XMLHttpRequest, fetch 这些东西,它们并不在里面,它们属于Web APIs,同样使用 V8 引擎的 node.js 里就没有这些 API 给你调用。

当引擎遇到 setTimeout 代码时:

  1. 引擎将代码送到 Call Stack
  2. Call Stack setTimeout 设定的回调函数和定时时间推给对应 Web API
  3. 计时器开始计时
  4. setTimeout 设定的时间到了,Web API 再把 setTimeout 设定的回调函数丢进宏任务队列
  5. 排队等待
  6. 轮到这个宏任务了,Event loop 把它交给 Call Stack

为什么不能保证定时任务定时执行?

定时器运行在子线程中,到了预定的时间,他会把回调任务定时推到任务队列中,回调任务最终要在主线程中执行,假如推到任务队列时主线程还在执行耗时任务,那么就要继续等待了,也就是相对于你定的时间延后执行了。

另外alert 会暂停主线程的执行

setTimeout(function() { console.log('run1') }, 1000)
setTimeout(function() { console.log('run2') }, 2000)
alert('alert')

在弹出alert 时,定时器任务不会放到主线程中执行,上面的run1 和run2会在关闭 alert(当然要在2s后) 后立即打印出来,据说在早期的浏览器,还会暂停子线程的执行,也就是关闭 alert 后,再等1s 和 2s 后打印出来。

当引擎遇到事件监听代码(addEventListener)时:

  1. 引擎将代码送到 Call Stack
  2. Call Stack 把它丢给Web API
  3. 等待触发事件
  4. 事件被触发,Web API 再把回调函数丢进宏任务队列并等待再次被触发或移除监听(removeEventListener)
  5. 排队等待
  6. 轮到这个宏任务了,Event loop 把它交给 Call Stack

Node.js 有另外的API:

Call Stack 与 Heap

JS 引擎包含一个Call Stack(调用栈)和一个Heap(堆),Heap 用来给对象分配内存,Stack 用来处理函数调用。

当 JS 引擎拿到我们的脚本后,它做的第一件事就是为我们代码中的数据设置内存。

Promises 和 Async/Await

.then() 中的回调函数在 Promise 被解决后被调用
.catch() 中的回调函数在 Promise 被拒绝或发生错误后被调用

如果Promise 没有被 resolve 或reject,保持pending 状态会导致内存泄漏

.then() 和 .catch 本身都返回 Promise 对象,所以能形成 Promise 链。
当 引擎遇到 Promise 方法,Promise 对象本身是同步执行,当Promise 对象状态变为 fulfilled 或 rejected,那么 .then 方法中的回调函数会被丢进微任务队列中。
遇到 await 关键字时,async 右边的表达式会先执行,然后把剩余的代码封装成一个新的Promise 对象丢到微任务队列中排队。

Promise.all() 只要一个状态变为rejected,则整个 Promise.all() 的状态也将被拒绝,并且拒绝的值将是被拒绝的 Promise 对象的拒绝原因(rejection reason)

JS 操作 DOM 是同步的,但 DOM 的渲染通常是异步的

看这个要循环很久的耗时代码。

msg.textContent = 'Hello World'
for(let i=0; i<10000000000; i++) {
  let j = i * i
}
这个循环执行了15秒后才在页面上看到Hello World,为什么?

JS 首先执行 msg.textContent = ‘hello world’,修改内存中的 DOM 是同步的,但浏览器的运行逻辑并不是你在JS 里修改下DOM 就去立马去渲染,然后执行下一行JS 代码(查看《浏览器是怎么工作的》)。

通常是执行完脚本(Evaluate Script)上应该执行的全部代码,然后就会有Recalculate Style 构建 Render Tree,然后Layer 阶段构建盒模型,然后Paint , Commit 生成一个由多个图层(Layer)组成的位图(Bitmap),最后Composite 合成最终的位图,显示在屏幕上,而这个过程是耗时耗资源的。

如果执行一行操作DOM 代码,就渲染一下,对资源的消耗是不经济的,自然会卡顿。

React 和 Vue 都是使用虚拟 DOM 技术通过优秀的 diff 算法大大提高页面的渲染性能。

Ref:

https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif