跳到主要内容

3. 页面循环系统

问题1:简述:消息队列和任务循环系统

浏览器页面是由消息队列和事件循环系统来驱动的。

每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。

要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是 消息队列和事件循环系统

image-20220814221037733

事件循环机制

可以想象成线程是一个 for 循环语句,会一直循环执行。在线程运行过程中,一直等待事件触发,比如在等待用户输入的数字,一旦接收到用户输入信息,那么线程就会背激活,然后执行相加运算,最后输出结果。

image-20220814221044845

消息队列

image-20220814221059324

消息队列 是一个队列数据结构,存放了主线程要执行的任务。符合队列的先进先出特点。

渲染进程专门有一个 IO 线程 用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。

IO线程 往队列尾部添加任务,等待执行;主线程 从队列头部取出任务,并执行。

跨进程通信称为 IPC(Inter-Process Communication,进程间通信)。

利用 IPC,IO线程接收到来自网络进程发来的资源加载完成信息、来自浏览器进程发来的用户鼠标点击信息,然后添加到消息队列中,等待主线程将它们处理。

系统调用栈

是当循环系统在执行一个任务的时候,都要为这个任务维护一个 系统调用栈

这个系统调用栈类似于 JavaScript 的调用栈,只不过是 Chromium 的开发语言 C++ 来维护的。其完整的调用栈信息可以通过 chrome://tracing/ 来抓取。当然,你也可以通过 Performance 来抓取它核心的调用信息,如下图所示:

image-20220814221114838

这幅图记录了一个 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 执行流程:

在主线程在执行一个宏任务时,

  1. 产生的微任务会被添加到这个宏任务的微任务队列中;
  2. 产生的宏任务会被添加到消息队列的末端;

在执行完这个宏任务时,不是直接退出去执行下一个宏任务,而是:

  1. 查看这个宏任务的微任务队列中,是否有微任务等待执行。
  2. 如果有,则按序执行微任务队列中的任务。
    • 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;

直到当前微任务队列中所有任务被执行完毕,退出当前宏任务,去执行下一个宏任务。

3.1 检查点

在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

  • 如果在执行微任务过程中产生了新的微任务,则添加到 当前微任务队列 的队尾;V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。

3.2 举例:

image-20220814221126049

image-20220814221132679

上面两个图是在执行一个 ParseHTML 的宏任务。

在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。

  1. 在 JavaScript 执行中,分别通过 PromiseremoveChild 创建了两个微任务;
  2. 这两个微任务按序添加到微任务列表中;
  3. 当 JavaScript 执行结束,准备退出全局执行上下文时,到了 检查点,JavaScript 引擎会检查微任务列表;
  4. JS 引擎发现列表有微任务,,依次执行这两个微任务。
  5. JS 引擎继续检查微任务列表,发现队列清空之后,退出全局执行上下文。

细节:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。

  • 微任务的执行时长会影响到当前宏任务的时长。

    • 比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

2 解决高优先级的任务

在执行宏任务的过程中,如果有高优先级的任务需要先处理,则就把这个任务添加到当前宏任务的微任务队列中,这样既不会影响到对宏人物的继续执行(效率),有保证了高优先级任务先被执行(实施性)。

举例:

一个典型的场景是监控 DOM 节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用 JavaScript 设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

3 解决单个任务执行时间过长

所有的任务都是在 单线程 中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。可以参考下图:

image-20220814221139524

如果在执行动画过程中,其中有个 JavaScript 任务因执行时间过久,占用了动画单帧的时间,这样会给用户制造了卡顿的感觉。针对这种情况,JavaScript 可以通过 回调 功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。通过回调,

问题3:setTimeOut 是如何实现的?

setTimeOut 是一个定时器,用来指定某个函数在多少毫秒之后执行。

它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。

function showName(){  
console.log("MMMM")
}
var timerID = setTimeout(showName,200);

我们直到,消息队列中的任务是按序执行的;而通过定时器设置的回调函数需要在指定的时间间隔后被调用,无法直接放置在消息队列中。

为了保证回调函数能在指定时间内执行,Chrome 中还有一个 延迟队列,这个队列中维护了需顺从延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。

延迟队列

当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间。

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

当主进程处理完消息队列中的一个任务(完整的宏任务)之后,就开始处理延迟队列中的任务:

  1. 根据 当前时间发起时间 + 延迟时间,计算出到期的任务,
  2. 依次执行这些到期的任务,
  3. 到期任务执行完毕后,继续下一个循环,执行消息队列中下一个任务。

所以,延迟队列中的到期的任务,需要等待当前执行的任务执行完毕才能被执行,并不是一旦到期就立刻被执行。

setTimeOut 的注意事项:

  1. 如果主线程中当前任务执行时间过久,导致定时器设置的任务被延后执行

    • 最终导致回调函数执行比设定的预期值要久。
  2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。

    function cb() { setTimeout(cb, 0); }
    setTimeout(cb, 0);
    • 在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。
  3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒。

    • 目的是为了优化后台页面的加载损耗以及降低耗电量。
  4. 延时执行时间有最大值。

    • Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被 立即执行
  5. 使用 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 来更新页面内容。

整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。

image-20220814221157515

如何使用 XMLHttpRequest

XMLHttpRequest 工作流程图如下:

image-20220814221202741

请求代码:

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,用来监控出错信息,如果后台请求出错了,该函数会被调用;

第三步:打开请求。

第四步:配置基础的请求信息。

  1. 要通过 open 接口配置基础的请求信息。

    • 包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。
  2. 通过 xhr 内部属性类配置一些其他可选的请求信息,比如:

    • xhr.timeout = 3000 来配置超时时间;

    • xhr.responseType = "text" 配置服务器返回的格式为 text

    • xhr.setRequestHeader 添加自己专用的请求头属性。

    • 下表是更多格式信息:

      image-20220814221213105

第四步:发起请求。

发起网络请求后,

  1. 渲染进程会将请求发送给网络进程,网络进程负责资源的下载;
  2. 等网络进程接收到数据之后,通过 IPC 来通知渲染进程的 IO线程;
  3. IO线程接收到消息之后,会把 xhr 的回调函数封装成任务,并添加到消息队列尾部;
  4. 主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
    • 如果 网络请求出错了,就会执行 xhr.onerror
    • 如果 超时了,就会执行 xhr.ontimeout
    • 如果是 正常的数据接收,就会执行 onreadystatechange 来反馈相应的状态。

使用 XMLHttpRequest 避免的问题:

1 跨域问题

默认情况下,跨域请求是不被允许的:

image-20210922200948700

2 HTTPS 混合内容

  • HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。

image-20220814221227582