页面循环系统.png

从上图可以看出:

渲染主线程相当于一个 for 循环,不断读取消息队列中的任务并执行。
IO 线程通过 IPC 与其他进程通信,接受其他进程传过来的任务并添加到消息队列中。

除了图上画的之外,渲染进程中还有预解析线程、合成线程等往消息队列中添加任务。

浏览器页面是由消息队列和事件循环系统来驱动的。当循环系统执行一个任务的时候,都要为这个任务维护一个系统调用栈,通过 perfomance 面板可以看到系统调用栈的记录。
消息循环系统调用栈记录.png

如何保证主线程能够安全退出?

设置一个 boolean 退出标志,主线程循环读取执行任务时,若发现标志为 true, 则直接退出线程循环。

消息队列中的任务类型

  • 内部类型:如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JS 定时器等。
  • 与页面相关的事件:JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

这些事件都在主线程中执行,在编写代码时,要衡量这些事件所占用的时长,并想办法解决单个任务占用线程过久的问题。

页面使用单线程的缺点

单线程,即每次需要等待上一个任务执行完成,才能执行下一个。
就会带来以下问题:

如何处理高优先级的任务?

最常见的就是监控 DOM 变化。
如果用 JS 设计一套监听接口,当 DOM 频繁变化时,当前任务的执行时间就会被拉长。

如果将 DOM 变化当作异步的消息事件添加到队列的尾部,就会影响监控的实时性。

针对这种情况,就推出了微任务

消息队列中的任务,我们称之为宏任务。每个宏任务中都包含一个微任务队列。如果 DOM 有变化,就将其添加到微任务队列中。等宏任务中的主要事件执行完后,就会执行当前宏任务中的微任务队列中的任务,而不是执行下个宏任务。

如何解决单个任务执行时长过久的问题?

因为所有任务都在单线程中执行,如果一个执行时间过长就会影响后面的任务,从而造成卡顿。
解决方法:使用回调功能。

setTimeout 实现机制

Chrome 中除了正常使用的消息队列外,还有一个延迟队列。当 JS 创建定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

延迟队列中的任务会在消息队列的一个任务执行完成之后执行。

  • 延迟队列的添加和执行伪代码:
  1. // 声明延迟队列
  2. DelayedIncomingQueue delayed_incoming_queue;
  3. struct DelayTask { ... }
  4. DelayTask timeTask;
  5. timerTask.cbf = showName; // 将回调函数赋给延迟队列中任务的 cbf 属性
  6. timerTask.startTime = getCurrentTime() // 获取当前时间
  7. timerTask.delayTime = 200 // 设置延迟时间
  8. delayed_incoming_queue.push(timerTask) // 将延迟任务添加到延迟队列
  9. void MainThread() {
  10. for(;;) {
  11. // 执行消息队列中的任务
  12. ProcessTask(task_queue.takeTask())
  13. // 执行延迟队列中的任务
  14. ProcessDelayTask()
  15. // 若设置了退出标志,直接退出线程循环
  16. if (!keep_running) {
  17. break;
  18. }
  19. }
  20. }

延迟队列实际上是一个 hashmap 结构,等到执行这个结构的时候,会计算 hashmap 中的每个任务是否到期,到期了就去执行,直到所有到期任务都执行结束,才会进入下一轮循环。

使用 setTimeout 注意事项

  1. 从上面伪代码可以看出,如果当前任务执行过久,会影响延迟队列中任务的执行。
  2. 若 setTimeout 存在签套调用,则系统会设置最短时间间隔为 4ms.
  1. function cb() {
  2. setTimeout(cb, 0);
  3. }
  4. setTimeout(cb, 0);

循环嵌套调用setTimeout.png
从上图可以看出,前 5 次调用 setTimeout 的时间间隔较小,后面每次调用最小间隔是 4ms.所以,一些实时性较高的需求,比如动画就不能用 setTimeout 来实现了。

  1. 未激活的页面,setTimeout 执行的最小间隔是 1000ms
    如果当前的 tab 页没有被激活,那么定时器的最小间隔会被设置为 1000ms,目的是优化后台页面加载损耗及降低耗电量。
  2. 延时执行时间有最大值。
    Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,这导致定时器会被立即执行。
  1. function showName() {
  2. console.log("your name");
  3. }
  4. setTimeout(showName, 2147483648);
  1. setTimeout 中的 this 不符合直觉

回调

每个任务在执行过程中都有自己的系统调用栈,同步回调就是当前主函数执行上下文中的回调函数;异步函数的回调则是在主函数之外执行,有两种方式:

  1. 把异步函数做成一个任务,添加到队列尾部
  2. 把异步函数添加到微任务队列,在当前任务末尾执行

同步回调

  1. let cb = () => console.log('doing work')
  2. function doWork(cb) {
  3. console.log('start)
  4. cb()
  5. console.log('end')
  6. }
  7. doWork(cb)

异步回调

  1. let cb = () => console.log('doing work')
  2. function doWork(cb) {
  3. console.log('start)
  4. setTimeout(cb, 0)
  5. console.log('end')
  6. }
  7. doWork(cb)

XMLHttpRequest 运作机制

XMLHttpRequest工作流程图.png

完整流程

  1. function getData(url) {
  2. // 1. 创建请求对象
  3. let xhr = new XMLHttpRequest()
  4. // 2. 注册相关事件回调处理函数
  5. xhr.onreadystatechange = () => { ... }
  6. xhr.ontimeout = (e) => { ... }
  7. xhr.onerror = (e) => { ... }
  8. // 3. 打开请求
  9. xhr.open('GET', url, true) // true 为采用异步
  10. // 4. 配置参数
  11. xhr.timeout = 3000 // 设置超时时间
  12. xhr.responseType = 'text' // 设置响应的数据格式 text,json,document,blob,arraybuffer
  13. xhr.setRequestHeader('x-test', 'wulala') // 自定义请求头
  14. // 5. 发送请求
  15. xhr.send()
  16. }

注意问题

  1. 跨域问题
  2. HTTPS 混合内容问题(https 下请求 http 协议的图片、内容等)

宏任务

通常我们把从消息队列中取出的任务称为宏任务。
包括以下几种事件:
渲染事件:如解析 DOM、计算布局、绘制;
用户交互事件:如鼠标点击、滚动页面、放大缩小等;
JS 脚本执行事件;
网络请求完成、文件读写完成事件。

微任务

微任务是一个需要异步执行的函数,执行时机在主函数执行结束后、当前宏任务结束之前。

在执行一段 JS 脚本是,V8 会为其创建一个全局执行上下文,同时会在内部创建一个微任务队列,用来存放当前宏任务产生的微任务。

为什么会有微任务?

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>...</head>
  4. <body>
  5. ...
  6. <script>
  7. function timerCallback2() {
  8. console.log(2);
  9. }
  10. function timerCallback() {
  11. console.log(1);
  12. setTimeout(timerCallback2, 0)
  13. }
  14. setTimeout(timerCallback, 0)
  15. </script>
  16. </body>
  17. </html>

performance 记录.png
从上图可以看到,宏任务 settimeout 的时间粒度大,执行的时间间隔不可控。对一些高实时性的需求就不符合。

如何产生微任务?

现代浏览器的两种产生方式:

  1. 使用 MutationObserver 监控 DOM 节点,然后通过 JS 来修改这个节点或添加、删除子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  2. 使用 Promise,当调用 Promise.resolve()Promise.reject() 时,也会产生微任务。

如何执行微任务?

在 JS 引擎准备退出全局执行上下文并清空调用栈的时候,JS 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。

如果在执行微任务队列的过程中产生新的微任务,也会添加到本次宏任务的微任务队列中,知道队列为空才算执行结束。
微任务的执行和添加-1.png微任务的执行和添加-2.png

监听 DOM 变化方法演变

  1. 通过setTimeout, setInterval 轮询检测页面 DOM 是否改变。
    缺点:
    浪费很多无用功去检查 DOM,低效;
    间隔不好把控,DOM 变化响应不及时。
  2. 使用 Mutation Event 观察者模式,当 DOM 有变动立刻触发,属于同步回调。
    缺点:
    每次 DOM 变动,渲染引擎都会调 JS,性能开销大,易卡顿。
  3. 使用 MutationObserver 将响应函数改为异步调用,等多次 DOM 变化后,一次触发异步调用,并且还有一个数据结构来记录这期间所有 DOM 的变化。
  • 解决了性能问题,如何保证消息通知的及时性呢?
    微任务,在 DOM 节点发生变化时,渲染引擎将变化记录封装成微任务加入微任务队列。

综上所述,MutatuonObserver 采用了 “异步 + 微任务”。
异步:解决性能问题
微任务:解决实时性问题

精选问答

  • 宏任务与微任务
    宏任务是开会分配的工作内容,微任务是工作过程中被临时安排的内容。
  • requestAnimationFrame 实现动画效果为什么比 setTimeout 好?
    使用 requestAnimationFrame 不需要设置具体的时间,由系统来决定回调函数的执行时间,requestAnimationFrame 里面的回调函数是在页面刷新之前执行,它跟着屏幕的刷新频率走,保证每个刷新间隔只执行一次,内如果页面未激活的话,requestAnimationFrame 也会停止渲染,这样既可以保证页面的流畅性,又能节省主线程执行函数的开销。
    若 js 脚本阻塞页面刷新,requestAnimationFrame 也不一定确保 16ms 执行一次。
  • IPC 是什么?
    进程间通信,比如浏览器进程需要网络进程下载数据,浏览器进程就是通过 IPC 告诉网络进程需要下载哪些数据,网络进程接收到之后才会开启下载流程。