0%

浏览器工作原理学习笔记(三)-页面循环系统

本系列为极客时间上的《浏览器工作原理与实践》专栏的学习笔记

消息队列和事件循环

每个渲染进程都会有一个主线程,用来处理各种任务:解析 DOM、执行 JavaScript、响应用户的交互事件、执行动画等。如果要有序的处理这些任务,就必须有一个专门的调度系统来协调,在浏览器中,这个调度系统就是消息队列和事件循环。

主要的执行原理是这样的:主线程维护一个循环程序,这个程序主要做两件事:

  1. 从消息队列中取任务(task)

  2. 执行任务(task)

也就是说,主线程的任务全部来自于消息队列,这个队列是一个数据结构,符合先进先出的特性。刚刚列举的那些任务,很多来自于 IO 线程,都要先 push 到消息队列中。

那如果是其他进程(比如网络进程、浏览器进程)想要让渲染主线程执行任务呢?

其他进程的任务会先发送给渲染进程中的 IO 线程,接收到消息之后,再组装成任务发送到消息队列。

当页面主线程执行完成之后,又该如何保证页面主线程能够安全退出呢?

Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了,那么就直接中断当前的所有任务,退出线程。

缺点

使用该模型处理任务的缺点有以下两点:

  • 无法处理优先级更高的任务
  • 无法解决单个任务执行时间过长的问题

无法处理优先级更高的任务:

因为所有的任务都是放在消息队列中,遵从先进先出的规则,即使放入一个优先级更高的任务,也会因为前面排队多个任务而延迟执行。

解决办法是引入了宏任务和微任务的概念。每一个宏任务都会有一个微任务列表,当前宏任务执行完成之后,并不会着急执行下一个宏任务,而是先检查一下微任务列表,如果有微任务的话,优先执行微任务。所以,对于一些优先级更高的任务,可以放到当前执行宏任务的微任务列表中。

无法解决单个任务执行时间过长的问题:

一是从应用程序的角度去解决,尽量避免单个计算量比较大,耗时长的任务。
二是可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。

WebAPI

事件循环的典型应用就是一些 WebAPI 的实现,主要介绍 setTimeout 和 XMLHttpRequest 的实现。

setTimeout

setTimeout 是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个编号,通过这个编号可以取消这个定时器。

事件循环中的消息队列是按照顺序执行的,因此,setTimeout 产生的任务不能直接放到消息队列中。Chrome 为了维护定时器和内部一些需要延迟执行的任务,定义了一个延迟执行队列。

为了执行延迟队列中的任务,事件循环程序从消息队列中拿出一个任务并执行后,会去拿延迟队列中的任务。延迟队列任务取出规则是:根据延迟任务的发起时间和延迟时间计算出到期时间,取出到期任务依次执行。

调用 clearTimeout(timer_id) 可以取消某个定时任务,内部实现也很简单。通过 timer_id 在延迟队列中查找到对应的任务,将其删掉就好了。

需要注意的点:

  • 如果定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果调用时间间隔小于 4 毫秒,会被设置成 4 毫秒。所以一些实时性较高的需求就不适合用 setTimeout 了
  • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒。这个主要是为了降低损耗
  • 延时执行时间有最大值,大于 2147483647 毫秒时,会发生溢出,相当于延时值被设置为 0 了
  • setTimeout 执行的回调函数,this 指向的是全局环境,严格模式下是 undefined

XMLHttpRequest

补充说明:当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。这个系统调用栈类似于 JavaScript 的调用栈,只不过系统调用栈是 Chromium 的开发语言 C++ 来维护的。

以下是 XMLHttpRequest 工作流程图:

Markdown

主要的流程如下:

渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用 xhr 注册的回调函数。

宏任务和微任务

宏任务指的是消息队列中任务,比如:解析 HTML、执行 JavaScript 、用户交互事件等。

因为开发者无法控制消息队列中任务的添加时间和执行时间,所以对于一些实时性要求较高的应用来说,宏任务就没有办法满足需求了。而且在执行任务的过程中,中间也有可能会插入其他的一些任务,打乱了开发者的控制流程。

为了解决这个问题,引入了微任务。那么什么是微任务?

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。具体一点讲就是:V8 引擎在执行 JS 脚本的时候,会创建一个全局执行上下文,同时也会在内部创建一个微任务队列(开发者通过 JS 访问不到)。在当前宏任务执行的过程中产生的微任务,都会加入到这个微任务队列中。在当前宏任务中的 JS 快要执行完成时,V8 引擎会检查微任务队列,按照顺序去执行队列中的微任务。同时,在执行微任务的过程中如果又产生了微任务,也会添加到当前的微任务队列中。

那么,怎样会产生微任务?

  • 使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 使用 Promise

Promise

页面编程中常见的方式是异步编程,但是这样的编程方式有以下几个缺点:

  • 逻辑不连续,反人类直觉
  • 处理复杂业务逻辑时,充斥着嵌套多层的回调函数
  • 需要处理每一个回调函数的失败情况

Promise 为了解决这些问题,用了以下几个技术:

  • 回调函数延迟绑定以及回调函数的返回值穿透
  • 任务失败的对象具有冒泡性质
  • Promise 的任务结果处理函数,是利用微任务来执行的。