3. 页面循环系统
问题1:简述:消息队列和任务循环系统
浏览器页面是由消息队列和事件循环系统来驱动的。
每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。
要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是 消息队列和事件循环系统。
事件循环机制
可以想象成线程是一个 for
循环语句,会一直循环执行。在线程运行过程中,一直等待事件触发,比如在等待用户输入的数字,一旦接收到用户输入信息,那么线程就会背激活,然后执行相加运算,最后输出结果。
消息队列
消息队列 是一个队列数据结构,存放了主线程要执行的任务。符合队列的先进先出特点。
渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。
IO线程 往队列尾部添加任务,等待执行;主线程 从队列头部取出任务,并执行。
跨进程通信称为 IPC
(Inter-Process Communication,进程间通信)。
利用 IPC,IO线程接收到来自网络进程发来的资源加载完成信息、来自浏览器进程发来的用户鼠标点击信息,然后添加到消息队列中,等待主线程将它们处理。
系统调用栈
是当循环系统在执行一个任务的时候,都要为这个任务维护一个 系统调用栈。
这个系统调用栈类似于 JavaScript 的调用栈,只不过是 Chromium 的开发语言 C++ 来维护的。其完整的调用栈信息可以通过 chrome://tracing/
来抓取。当然,你也可以通过 Performance
来抓取它核心的调用信息,如下图所示:
这幅图记录了一个 Parse HTML 的任务执行过程,其中黄色的条目表示执行 JavaScript 的过程,其他颜色的条目表示浏览器内部系统的执行过程。
Parse HTML 任务在执行过程中会遇到一系列的子过程:比如在解析页面的过程中遇到了 JavaScript 脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。然后又遇到了样式表,这时候又开始解析样式表……直到整个任务执行完成。
每个任务在执行过程中都有自己的调用栈。
问题2:为什么要设计宏任务和微任务?
宏任务和微任务的加入,使任务执行实现了 效率 和 实时性 的平衡。
1 什么是宏任务与微任务
在渲染进程中,把消息队列中的任务称为 宏任务,每个宏任务中都包含了一个 微任务队列。
1 宏任务
典型的触发宏任务的两种 WebAPI:
setTimeout
:在进程内,将延迟触发的回调函数放入延迟队列中,在每个宏任务执行完毕后遍历延迟队列,寻找到期的任务,并执行。XMLHttpRequest
:渲染进程的 IO 线程,通过 IPC 和网络进程沟通,通知网络进程去服务器请求资源。当返回资源后,渲染进程的 IO 线程把返回情况(成功?失败?故障?)封装为一个任务放在任务队列末尾。主线程执行该宏任务时,根据返回状态来调用对应的回调函数。
宏任务主要包括了:
- 渲染事件(如解析 DOM、计算布局、绘制);
- 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
- JavaScript 脚本执行事件;
- 网络请求完成、文件读写完成事件。
渲染进程内部会维护多个消息队列,在 Chrome 中主要有两个:延迟执行队列 和 消息队列。
2 微任务
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个 微任务队列。
在当前宏任务执行的过程中,有时候会产生多个微任务,按序保存在微任务队列中。
微任务的产生有两种:
- 使用
MutationObserver
监控某个 DOM 节点。目的是为了根据节点变化,通过 JavaScript 来修改节点、添加或删除部分子节点。当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。 - 使用
Promise
。当调用Promise.resolve()
或者Promise.reject()
的时候,产生微任务。
3 执行流程:
在主线程在执行一个宏任务时,
- 产生的微任务会被添加到这个宏任务的微任务队列中;
- 产生的宏任务会被添加到消息队列的末端;
在执行完这个宏任务时,不是直接退出去执行下一个宏任务,而是:
- 查看这个宏任务的微任务队列中,是否有微任务等待执行。
- 如果有,则按序执行微任务队列中的任务。
- 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;
直到当前微任务队列中所有任务被执行完毕,退出当前宏任务,去执行下一个宏任务。
3.1 检查点
在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。
- 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。
3.2 举例:
上面两个图是在执行一个 ParseHTML 的宏任务。
在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。
- 在 JavaScript 执行中,分别通过
Promise
和removeChild
创建了两个微任务; - 这两个微任务按序添加到微任务列表中;
- 当 JavaScript 执行结束,准备退出全局执行上下文时,到了 检查点,JavaScript 引擎会检查微任务列表;
- JS 引擎发现列表有微任务,,依次执行这两个微任务。
- JS 引擎继续检查微任务列表,发现队列清空之后,退出全局执行上下文。
细节:
微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
微任务的执行时长会影响到当前宏任务的时长。
- 比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。
在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
2 解决高优先级的任务
在执行宏任务的过程中,如果有高优先级的任务需要先处理,则就把这个任务添加到当前宏任务的微任务队列中,这样既不会影响到对宏人物的继续执行(效率),有保证了高优先级任务先被执行(实施性)。
举例:
一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。
3 解决单个任务执行时间过长
所有的任务都是在 单线程 中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:
如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉。针对这种情况,JavaScript 可以通过 回调 功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。通过回调,
问题3:setTimeOut
是如何实现的?
setTimeOut
是一个定时器,用来指定某个函数在多少毫秒之后执行。
它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。
function showName(){
console.log("MMMM")
}
var timerID = setTimeout(showName,200);
我们直到,消息队列中的任务是按序执行的;而通过定时器设置的回调函数需要在指定的时间间隔后被调用,无法直接放置在消息队列中。
为了保证回调函数能在指定时间内执行,Chrome 中还有一个 延迟队列,这个队列中维护了需顺从延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。
延迟队列
当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。
当通过 JavaScript 调用 setTimeout
设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间。
延迟队列是一个 hashmap 结构,等到执行这个结构的时候,会计算 hashmap 中的每个任务是否到期了,到期了就去执行,直到所有到期的任务都执行结束,才会进入下一轮循环:
当主进程处理完消息队列中的一个任务(完整的宏任务)之后,就开始处理延迟队列中的任务:
- 根据
当前时间
≤发起时间
+延迟时间
,计算出到期的任务, - 依次执行这些到期的任务,
- 到期任务执行完毕后,继续下一个循环,执行消息队列中下一个任务。
所以,延迟队列中的到期的任务,需要等待当前执行的任务执行完毕才能被执行,并不是一旦到期就立刻被执行。
setTimeOut 的注意事项:
如果主线程中当前任务执行时间过久,导致定时器设置的任务被延后执行
- 最终导致回调函数执行比设定的预期值要久。
如果
setTimeout
存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。function cb() { setTimeout(cb, 0); }
setTimeout(cb, 0);- 在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
未激活的页面,
setTimeout
执行最小间隔是 1000 毫秒。- 目的是为了优化后台页面的加载损耗以及降低耗电量。
延时执行时间有最大值。
- Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,
32bit
最大只能存放的数字是2147483647
毫秒,这就意味着,如果setTimeout
设置的延迟值大于2147483647
毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被 立即执行。
- Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,
使用
setTimeout
设置的回调函数中的this
不符合直觉。
问题4:同步回调、异步回调是什么意思?
每个任务在执行过程中都有自己的调用栈,
同步回调 就是在当前主函数的上下文中执行回调函数:
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
cb()
console.log('end do work')
}
doWork(callback)
// start do work
// i am do homework
// end do work
异步回调 是指回调函数在主函数之外执行,一般有两种方式:
- 第一种是把异步函数做成一个任务(宏任务),添加到消息队列的尾部;
- 第二种是把异步函数做成一个微任务,添加到当前任务的微任务队列中。
let callback = function(){
console.log('i am do homework')
}
function doWork(cb) {
console.log('start do work')
setTimeout(cb,1000) //异步回调
console.log('end do work')
}
doWork(callback)
// start do work
// end do work
// i am do homework
问题5:XMLHttpRequest
如何使用?
什么是 XMLHttpRequest
?
XMLHttpRequest
提供了从 Web 服务器获取数据的能力。
如果你想要更新某条数据,只需要通过 XMLHttpRequest
请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容。
整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。
如何使用 XMLHttpRequest
?
XMLHttpRequest 工作流程图如下:
请求代码:
function GetWebData(URL){
/**
* 1:新建XMLHttpRequest请求对象
*/
let xhr = new XMLHttpRequest()
/**
* 2:注册相关事件回调处理函数
*/
xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 0: //请求未初始化
console.log("请求未初始化")
break;
case 1://OPENED
console.log("OPENED")
break;
case 2://HEADERS_RECEIVED
console.log("HEADERS_RECEIVED")
break;
case 3://LOADING
console.log("LOADING")
break;
case 4://DONE
if(this.status == 200||this.status == 304){
console.log(this.responseText);
}
console.log("DONE")
break;
}
}
xhr.ontimeout = function(e) { console.log('ontimeout') }
xhr.onerror = function(e) { console.log('onerror') }
/**
* 3:打开请求
*/
xhr.open('Get', URL, true);//创建一个Get请求,采用异步
/**
* 4:配置参数
*/
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")
/**
* 5:发送请求
*/
xhr.send();
}
具体流程如下:
第一步:创建 XMLHttpRequest 对象 xhr。
第二步:为 xhr 对象注册回调函数。
通过异步回调,等待网络进程获取结果后,通过调用回调函数来告诉其执行结果。
XMLHttpRequest
的回调函数主要有下面几种:
onreadystatechange
,用来监控后台请求过程中的状态。比如监控 HTTP 头加载完成的消息、HTTP 响应体消息、数据加载完成的消息。0
:尚未初始化 对应XMLHttpRequest.UNSENT
1
:正在加载 对应XMLHttpRequest.OPENED
2
:加载完毕 对应XMLHttpRequest.HEADERS_RECEIVED
3
:正在处理 对应XMLHttpRequest.LOADING
4
:处理完毕 对应XMLHttpRequest.DONE
ontimeout
,用来监控超时请求,如果后台请求超时了,该函数会被调用;onerror
,用来监控出错信息,如果后台请求出错了,该函数会被调用;
第三步:打开请求。
第四步:配置基础的请求信息。
要通过
open
接口配置基础的请求信息。- 包括请求的地址、请求方法(是
get
还是post
)和请求方式(同步还是异步请求)。
- 包括请求的地址、请求方法(是
通过
xhr
内部属性类配置一些其他可选的请求信息,比如:xhr.timeout = 3000
来配置超时时间;xhr.responseType = "text"
配置服务器返回的格式为text
。xhr.setRequestHeader
添加自己专用的请求头属性。下表是更多格式信息:
第四步:发起请求。
发起网络请求后,
- 渲染进程会将请求发送给网络进程,网络进程负责资源的下载;
- 等网络进程接收到数据之后,通过 IPC 来通知渲染进程的 IO线程;
- IO线程接收到消息之后,会把
xhr
的回调函数封装成任务,并添加到消息队列尾部; - 主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
- 如果 网络请求出错了,就会执行
xhr.onerror
; - 如果 超时了,就会执行
xhr.ontimeout
; - 如果是 正常的数据接收,就会执行
onreadystatechange
来反馈相应的状态。
- 如果 网络请求出错了,就会执行
使用 XMLHttpRequest
避免的问题:
1 跨域问题
默认情况下,跨域请求是不被允许的:
2 HTTPS 混合内容
- HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。