摘要:极客时间《浏览器工作原理与实践》 浏览器中的页面循环系统学习笔记,消息队列和事件循环、setTimeout、XMLHttpRequest、宏任务和微任务、Promise、async/awate
消息队列和事件循环
页面是单线程的,如果想要在渲染主线程运行过程中接收并执行新的任务,就需要采用事件循环机制。同时,想要处理其他线程发送的任务的话,通用模式是使用消息队列。此外,还想处理其他进程发送过来的任务的话,需要专门的 IO 线程用来接收其他进程传进来的消息,IO 线程将这些消息组装成任务发送给渲染主线程,后续骤就和“处理其他线程发送的任务”相同。
Chrome 页面主线程有一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。如果设置了这个退出标志,就直接中断当前的所有任务,退出线程。
单线程的缺点
1、如何处理优先级高的任务
消息队列机制并不是太灵活,为了适应效率和实时性,浏览器使用宏任务和微任务。消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列。在一个宏任务执行的过程中,如果有优先级高的任务出现,就会加入到该宏任务的微任务队列中,并不会影响到宏任务的继续执行,提高了执行效率;当宏任务中的主要功能都完成后,渲染引擎会先执行宏任务中的微任务,这又解决了实时性问题。如果微任务的执行产生新的微任务,新的微任务会加入到当前微任务队列的队伍。
2、如何解决单个任务执行时长过久的问题
浏览器某个任务执行时间过久会给用户卡顿的感觉,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
setTimeout
setTimeout 是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时还可以通过该编号来取消这个定时器。浏览器页面是由消息队列和事件循环系统来驱动的。在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列(实际上是 hashmap 结构),这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。两个队列的任务都是宏任务,都有自己的微任务栈。如果要取消某个定时器的操作,只需要在延时队列中,通过 ID 查找到对应的任务,再将其从队列中删除。
注意事项:
1、如果当前任务执行时间过久,会影响定时器任务的执行。因为定时器设置的是宏任务,只有前一个宏任务完全执行完后,才会执行下一个宏任务
2、如果 setTimeout 存在嵌套调用(超过 5 层),那么系统会设置最短时间间隔为 4 毫秒
3、未激活的页面,setTimeout 执行最小间隔是 1000 毫秒
4、延时执行时间有最大值。浏览器大多是以 32 个 bit 来存储延时值的,32 位除去首位的符号位,剩下全是 1,即 32bit 最大能存放的数字是 2^0+2^1+…+2^30=2^31-1=2147483647
5、使用 setTimeout 设置的回调函数中的 this 不符合直觉。具体查看this
XMLHttpRequest
回调函数与系统调用栈
将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。回调函数在主函数返回之前执行的情况叫同步回调,而回调函数在主函数外部执行的称为异步回调(比如使用 setTimeout)。消息队列和主线程循环机制保证了页面有条不紊地运行,当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈,系统调用栈类似于 JavaScript 的调用栈:
整个 Parse HTML 是一个完整的任务,在执行过程中的脚本解析、样式表解析都是该任务的子过程,其下拉的长条就是执行过程中调用栈的信息。每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数。异步回调一般有两种方式:1、把异步函数做成一个任务(宏任务),添加到信息队列尾部;2、把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
XMLHttpRequest 的运作机制
1 | // 使用 XMLHttpRequest 请求数据 |
responseType 用来配置服务器返回数据的格式:
调用 xhr.send 之后才会发起网络请求,具体流程:渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
会遇到的问题
1、跨域
这个问题前端经常会遇到,请看同源策略与跨域方法
2、HTTPS 混合内容的问题
HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等都属于混合内容。浏览器会针对 HTTPS 混合内容显示警告,用来向用户表明此 HTTPS 页面包含不安全的资源。
宏任务与微任务
宏任务
页面中的大部分任务都是在主线程上执行的,为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制。渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。消息队列中的任务就称为宏任务。
宏任务的时间粒度比较大,如果前一个任务的执行时间过久,那么就会影响到后面任务的执行。因而宏任务的执行时间间隔是不能精确控制的,对一些高实时性的需求就不太符合。
微任务
前面介绍的回调函数有两种:
1、异步回调:把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。
2、同步回调:在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。当 JavaScript 执行一段脚本是,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。
产生微任务有两种方式:
1、使用 MutationObserver 监控某个 DOM 节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
2、使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 时会产生微任务
通常情况下,在当前宏任务中的 JavaScript 快执行完成时,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。
微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列,并且微任务的执行时长会影响到当前宏任务的时长,只有当前宏任务的微任务队列清空后,该宏任务才会结束。如果在一个宏任务中分别创建一个宏任务和一个微任务,无论什么情况下,微任务都早于宏任务执行。因为微任务是在当前宏任务结束之前的检查点开始执行,执行完过后,才会执行下一个宏任务。
监听 DOM 变化方法演变
最开始是轮询,每隔一定的时间判断是否有 DOM 发生变化,但时间间隔设置过长,DOM 变化相应不够及时,设置过短,又会使页面浪费工作量其检查 DOM 而影响其他任务。
2000 年引入采用了观察者的设计模式的 Mutation Event,当 DOM 有变动时就会立刻触发相应的事件,这种方式属于同步回调,会导致页面性能问题。
DOM4 开始推荐使用 MutationObserver。它将响应函数改成异步调用,等多次 DOM 变化后,一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的 DOM 变化。DOM 变化时,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。
MutationObserver 采用了“异步 + 微任务”的策略:
1、通过异步操作解决了同步操作的性能问题;
2、通过微任务解决了实时性的问题。
Promise
Web 页面的单线程架构决定了异步回调,而异步编程使代码逻辑不连续。
1 | // 成功、失败执行状态的回调函数 |
发起一次请求会有五个回调,回调会导致代码的逻辑不连贯、不线性
简单封装
1 | // [in] request,请求信息,请求头,延时值,返回类型等 |
当遇到复杂的项目,嵌套过多的回调就出现新问题:回调地狱。后面的请求需要在上一个请求的回调函数中执行,而且上一个请求还不确定能否成功。这时,就需要想办法解决两个问题:1、消灭嵌套调用;2、合并多个请求的错误处理。
1 | // 重构 nFetch |
从调用方式来看,先使用 nFetch 创建的 Promise 对象 n1,然后才使用 Promise 的 then 方法来绑定回调函数。在 new Promise 时,Promise 的构造函数会被执行。然后 Promise 的构造函数会调用 Promise 的参数 executor 函数,之后在 executor 中执行了 resolve。然而此时还未使用 then 绑定回调函数,直接调用会报错,那么只能延迟回调,这里使用微任务实现了回调函数的延时调用。回调函数中再将得到的数据创建成对应的 Promise 对象,然后通过 return 语句将其带有返回值的 Promise 对象穿透到最外层。这样就摆脱了嵌套调用。代码中的多个 Promise 对象,无论哪个对象里面抛出异常,都可以通过最后一个对象的 catch 来捕获(挖坑)。
async/await
async/await 是 Generator 的语法糖,使用了 Promise 和 Generator 两种技术,往低层说就是微任务和协程应用。它使异步处理的逻辑都可以使用同步代码的方式来实现,而且还支持 try catch 来捕获异常,这使得代码逻辑更加清晰。
Generator
生成器函数 Generator 是一个带星号函数,而且是可以暂停执行和恢复执行的。
Generator 的使用方法:
1、在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
2、外部函数可以通过 next 方法恢复函数的执行。
在 JavaScript 中,生成器是协程的一种实现方式。
1 | // 生成器函数 |
协程
协程是一种比线程更加轻量级的存在,一个线程可以拥有多个协程。而且协程不是被操作系统内核所管理,它完全是由程序所控制。又因为协程没有切换线程的开销,所以非常高效。如果从 A 协程启动 B 协程,那么 A 协程就被称为 B 协程的父协程。子协程与父协程在主线程上是交互执行的,而不是并发执行的,切换是通过 yield 关键字和 next 方法配合完成。切换协程时,JavaScript 引擎会保存当前协程的调用栈信息,然后切换调用栈即可。上面一段代码的过程可以理解为:
使用生成器和 Promise 再次封装请求代码:
1 | // 生成器函数 |
执行过程:
1、执行 let gen = foo() 创建 gen 协程
2、在父协程中执行 gen.next() 方法把主线程的控制权交给 gen 协程
3、gen 协程获取到主线程的控制权后,调用 fetch 函数创建了一个 Promise 对象 response1,然后通过 yield 暂停 gen 协程的执行,并将 response1 返回给父协程。
4、父协程恢复执行后,调用 response1.then 方法等待请求结果
5、等 fetch 发起的请求完成之后,调用 then 中的回调函数,then 中的回调函数拿到结果之后,调用 gen.next 放弃主线程的控制权,将控制权交 gen 协程继续执行下个请求。
通常把执行生成器的代码封装成一个函数,这个函数被称为执行器。例如:生成器配合执行器
1 | // 生成器函数 |
async
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。也就是说,async 声明的函数返回的是一个 Promise 对象。
await
1 | async function foo() { |
分析执行过程:
1、执行 console.log(0) 打印 0。
2、调用 async 标记的 foo 函数,主线程将控制权交给 foo 协程,并保存当前调用栈信息。
3、执行 console.log(1) 打印 1。
4、执行 await 100,会默认创建一个 Promise 对象:
1 | let promise_ = new Promise((resolve, reject) => { |
在创建 promise_ 对象的过程中,在 executor 函数中会调用 resolve 函数,JavaScript 引擎将调用 resolve 函数这个任务提交到微任务队列。
5、JavaScript 引擎暂停当前协程的执行,将主线程的控制权转交给父协程执行,并将 promise_ 对象返回给父协程。
6、父协程会先调用 promise_.then 来监控 promise 状态的改变,然后继续执行父协程的流程
7、执行 console.log(3) 打印 3。
8、父协程将执行结束,进入微任务的检查点,然后执行微任务队列。
9、执行微任务 resolve(100),触发 promise_.then 中的回调函数:
1 | promise_.then((value)=>{ // 回调函数被激活 value 为 100 |
10、foo 协程激活之后,会把得到的 value 值赋给了变量 a。
11、执行 console.log(a) 打印 100。
12、执行 console.log(2) 打印 2。
13、foo 协程执行完毕,将控制权归还给父协程。
任务调度
假设将普通的消息队列和延迟队列当成一个消息队列,在这种单消息队列架构下,如果低优先级的任务过多、用时过长,那么高优先级的任务就会被阻塞,即存在着低优先级任务阻塞高优先级任务的情况。
Chromium 团队解决思路:
第一次迭代:引入一个高优先级队列
有了高优先级队列,渲染进程就会将紧急的任务添加到高优先级队列中,不紧急的任务就放到低优先级队列中。然后渲染进程中再引入一个任务调度器,由它负责先将高优先级队列中的任务取出,高优先队列为空后,再从低优先级队列中取任务。甚至可以引入多个优先级不同的队列,然后使用任务调度器依据优先级的高低按顺序来取出并执行任务。但是大多数任务是需要保持相对顺序来执行,否则页面渲染会出现问题。
第二次迭代:根据消息类型来实现消息队列
为了使任务按照顺序执行,可以为不同类型的任务创建不同优先级的消息队列。但是这样依然存在问题:这几种消息队列的优先级都是固定的,而页面的加载阶段和交互阶段对于不同类型的事件有不同的侧重,如果使用相同的优先级策略,交互阶段可能没问题,但是页面的加载速度会被拖慢。
第三次迭代:动态调度策略
静态的任务调度策略过于死板,需要根据实际情况,动态调整消息队列的优先级。
通常显示器的帧率是 60Hz,当显示器将一帧画面绘制完成之后,在读取下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization)给 GPU,简称 VSync。当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再同步到对应的渲染进程。渲染进程得到 VSync 信号后,就开始准备绘制新的一帧图像。
当在执行用户交互的任务时,先将合成任务的优先级调整到最高。如果开始执行合成任务,将生成的绘制列表提交给合成线程,合成线程开始工作后,就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升。在合成完成之后,合成线程会提交给渲染主线程提交完成合成的消息。如果此时距离下个 VSync 时钟周期(1/60≈16.67 毫秒)还有一段时间,就会进入一个空闲阶段,那么就可以在空闲阶段执行非紧急任务。
第四次迭代:任务饿死
动态调度策略仍然有一个问题:如果一直有高优先级的任务入队,那么低优先级的任务就不会被执行,这被称为任务饿死。解决方法是给每个队列设置了执行权重,如果连续执行了一定个数的高优先级任务,那么中间会执行一次低优先级的任务。
VSync
VSync 和系统的时钟不同步就会造成掉帧、卡顿、不连贯等问题:
1、如果渲染进程生成的帧速比屏幕的刷新率慢,那么屏幕会在两帧中显示同一个画面,会造成卡顿
2、如果渲染进程生成的帧速率实际上比屏幕刷新率快,GPU 所渲染的图像并非全都被显示出来,会造成掉帧
3、如果屏幕的刷新频率和 GPU 更新图片的频率一样,但它们仍是两个不同的系统,屏幕生成帧的周期和 VSync 的周期很难同步
因此,为了将显示器的时钟同步周期和浏览器生成页面的周期绑定起来,也就有了显示器将一帧画面绘制完成后将 VSync 信号发送给 GPU,GPU 再同步给浏览器进程等等操作。
CSS 动画是由渲染进程自动处理的,所以渲染进程会让 CSS 渲染每帧动画的过程与 VSync 的时钟保持一致, 这样就能保证 CSS 动画的高效率执行。JavaScript 是由用户控制的,如果使用 setTimeout 来触发动画,其绘制时机很难与 VSync 的时钟保持一致。因而引入了 window.requestAnimationFrame 方法,它与 VSync 的时钟周期同步,会在每一帧的开头先执行 window.requestAnimationFrame 的回调任务。