浏览器页面的事件循环系统

概述

由于浏览器页面的渲染进程的主线程要执行很多任务,如DOM构建、样式计算、布局计算、绘制、JavaScript执行、接收输入事件等,并且这些任务因为需要交互性来不断产生,无法事先安排,因此需要一个任务调度系统来统筹规划这些任务的执行。在浏览器中,统筹调度任务的系统就是事件循环系统(Event-Loop)

事件与循环

事件循环系统在于事件和循环这两个概念

事件:接收外部的输入事件,产生任务
循环:线程执行完任务后并不退出,而是循环等待输入事件产生的任务并执行

其实这两个概念使用ES6的generator函数很好阐述,同样在redux-saga中也使用了generator来监听输入事件并自动执行任务

function* eventLoop() {
  // while循环保证线程循环执行
  while(true) {
    // yield保证了循环体在执行完watch()后停止执行
    // 等待接收外部输入事件(在ES6中使用eventLoop().next()方法恢复执行)
    const task = yield watch()
    // 一旦接收到外部输入事件产生的任务,则执行该任务
    executeTask(task)
  }
}

事件循环示意图

接收外部线程的输入事件(消息)

渲染主线程需要接收外部线程的输入事件或者叫消息,执行对应的任务。如IO线程发送给主线程的事件包括有资源加载完成事件、鼠标点击事件等等,主线程接收到IO线程发送的事件,就会执行对应的任务,如资源加载完成事件执行DOM解析任务,鼠标点击事件则执行JavaScript脚本中注册的点击处理函数等。
线程之间发送事件

消息队列(任务队列task queue)

主线程维护一个消息队列,用来存储其他线程发送的事件对应的任务。只要接收到了新的输入事件,就将对应的任务添加到消息队列中,等待主线程执行。主线程会不断循环从消息队列中取出任务并执行任务。
消息队列示意图
下面的代码解释了其中的原理,不过有点粗糙的是,主线程实际上可以在执行代码的过程中接收其他线程的事件,并将任务添加到消息队列中,下面的代码并未实现这一点,而是在清空消息队列后才会接收事件,这里要注意一下。

class Queue {
  constructor () {
    // event与task映射关系表
    this.eventMapToTask = {
      // 存储了event与对应的task
    }
    // 主线程维护的消息队列,存储event对应的task
    this.taskQueue = []
  }
  // 添加任务到队列
  addTask(task) {
    this.queue.push(task)
  }
  // 从队列中取出任务
  takeTask() {
    const task = this.queue.shift()
    return task
  }
}
function* eventLoop() {
  // 实际上,这里应该保证queue是单例,为了简化,这里就不设置单例模式了
  const queue = new Queue()
  // while循环保证线程循环执行
  while(true) {
    // yield保证了循环体在执行完watch()后停止执行
    // 等待接收外部输入事件(在ES6中使用eventLoop().next()方法恢复执行)
    // 接收其他线程发送的事件
    const event = yield watch()
    // 将event对应的task加入队列中
    queue.addTask(queue.eventMapToTask[event])
    // 清空任务队列中的任务
    while(queue.taskQueue.lenght > 0) {
      // 取出队列中的任务
      const task = queue.takeTask()
      // 执行任务
      executeTask(task)
    }
  }
}
跨进程事件循环

在接收事件的这个过程实际上还有跨进程通信的参与,因为事件最开始是从浏览器的网络进程、浏览器主进程传递而来。
跨进程事件循环
因此,实际上由IO线程会维护消息队列更合适。这样上面的代码中,主线程可以不管理接收事件,只要不断循环执行消息队列取出的任务即可。

退出事件循环

主线程会设定一个退出的标志变量,在每次执行完一个任务时,检查退出标志变量。
在确定要退出页面时,退出标志变量为true,则中断当前任务,退出当前的事件循环,退出主线程。

事件循环系统处理高优先级任务

  • 问题:监控DOM变化,实时处理任务,如果将DOM变化的任务同步执行,则会影响当前任务的执行时间和效率,如果将DOM变化的任务异步执行,则导致实时性变差。

如何衡量效率和实时性?

微任务
  • 主线程包含了微任务队列micro task queue,用来存储那些需要优先处理的任务。
  • 消息队列中的任务称为宏任务macro task,每个宏任务都包含了微任务队列。
    • 在执行宏任务的过程中,如果DOM有变化,则将对应的任务添加到微任务队列,这样就不会影响该宏任务的执行时间和效率。
    • 执行完该宏任务后,并不着急去执行下一个宏任务,而是执行当前宏任务下增加的微任务,并清空所有微任务,然后再执行下一个宏任务。这样也保证了高优先级任务的执行实时性。
微任务的创建

在V8引擎创建全局执行上下文时,同时在内部创建一个微任务队列。在执行当前宏任务的过程中,产生的微任务则存储到当前宏任务绑定的微任务队列中。
每个宏任务都会关联自己的微任务队列。

微任务的产生时机
  • 使用MutationObserver监控某个DOM节点,DOM节点变化触发的回调函数被封装成微任务添加到微任务队列
  • 使用Promise API注册的回调函数,也会被封装成微任务。
微任务的执行时机
  • 通常情况下,在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点
  • 如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。

微任务执行时机1
微任务执行时机2

宏任务

消息队列中的任务,如

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。
    这些任务称为宏任务。

系统对于宏任务的调度策略不能满足对时间精度要求较高的需求

  • JavaScript无法准确地知道某个任务在消息队列中的位置,因而无法准确地控制任务的执行时间。
  • 在JavaScript添加的任务之间可能会有其他的系统安排的任务添加到消息队列。

定时器与延迟队列

浏览器的定时器API使用了setTimeout 和 setInterval,将任务延迟一段时间后添加到延迟队列中。

  • 延迟队列:浏览器维护的需要延迟执行的任务列表,与正常使用消息列表不同。延迟队列中包含了定时器指定的延迟任务和其他浏览器内部需要延迟执行的任务。
  • 定时器的执行:当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数、当前发起时间、延迟执行时间,创建好回调任务之后,再将该任务添加到延迟执行队列中。
    • 在事件循环中,主线程会在执行正常的消息队列任务后,对延迟队列根据发起时间和延迟执行时间计算出到期的任务 ,执行这些到期的任务,再继续下一个循环。
定时器注意事项
  • 当前任务执行时间太久会导致定时器任务的执行被进一步延迟
  • 如果定时器存在嵌套调用,则系统会设置最短时间间隔为4毫秒,也就是说,设置为0的延迟时间,最短也是4毫秒。一旦发生循环嵌套的定时器回调,则浏览器会判断该回调被阻塞,将回调的延迟时间拉长。
  • 未激活页面的定时器最小时间间隔是1000毫秒,这样做是为了优化后台页面的损耗。
  • 延迟有最大的时间间隔,不过这个一般用不到
  • 回调函数的this值指向全局对象,这一点要注意

回调函数与XMLHttpRequest

将一个函数作为参数传递给另一个函数,那作为参数的函数就称为回调函数

同步回调函数

回调函数在主函数返回之前执行,则为同步回调函数,同步回调函数在当前函数的执行栈中调用。

异步回调函数

回调函数在主函数的外部执行,称为异步回调函数,异步回调的调用分为两种

  • 异步函数作为宏任务添加到消息队列尾部
  • 异步函数作为微任务添加到微任务队列尾部
XMLHttpRequest

XMLHttpRequest执行流程如下图
XMLHttpRequest执行流程

requestAnimationFrame

在实现JavaScript高性能流畅动画的场景下,需要使用requestAnimationFrame这个API而不是setTimeout,requestAnimationFrame可以精确控制动画在每一帧渲染,而不会因为定时器的不精确导致帧率降低。

单队列队头阻塞

这里将消息队列与延迟队列暂时作为一个队列来看待。
当队头任务耗时长,且优先级低时,高优先级任务得不到实时响应,就会导致页面不流畅,用户体验变差的情况。

解决办法
第一步:引入高优先级队列

比如在交互阶段,下面几种任务都应该视为高优先级的任务:

  • 通过鼠标触发的点击任务、滚动页面任务;
  • 通过手势触发的页面缩放任务;
  • 通过 CSS、JavaScript 等操作触发的动画特效等任务。

可以引入多个队列,按优先级排序,将任务划分为不同的优先级,添加到不同的队列中。

按优先级的高低依次执行任务。

但这里有个缺点就是,这种划分打乱了任务产生的相对顺序

第二步,为不同类型的任务创建不同优先级的消息队列

为不同类型的任务创建不同优先级的消息队列,比如:

  • 可以创建输入事件的消息队列,用来存放输入事件。
  • 可以创建合成任务的消息队列,用来存放合成事件。
  • 可以创建默认消息队列,用来保存如资源加载的事件和定时器回调等事件。
  • 还可以创建一个空闲消息队列,用来存放 V8 的垃圾自动垃圾回收这一类实时性不高的事件。
    不同消息类型的不同优先级队列
    但这种方式还是有瑕疵,因为不够灵活,优先级被固定好了。如果在交互阶段是可以的,但在加载阶段,优先级需要调整,而不能先执行用户交互的任务,应该执行加载资源、合成页面等任务。
第三步,动态调度策略

动态调度策略的消息队列
在不同阶段下,动态调整各消息类型对应的消息队列的优先级。

但这里还需要进行微调,因为如果一直有新的高优先级任务加入队列,则其他低优先级队列得不到执行,称为任务饿死

第四步,执行权重

为队列设置了执行权重,在连续执行了一定个数的该优先级队列的任务后,中间会执行一次低优先级任务,缓解任务饿死。

requestAnimationFrame API与显示器

通常情况下,显示器从显卡的前缓冲区读取图片的频率与浏览器生成新图像到后缓冲区的频率不一致,或者说不同步,这会导致很多问题
显示器与浏览器生成图片不同步

  • 如果渲染进程生成的帧速比屏幕的刷新率慢,那么屏幕会在两帧中显示同一个画面,当这种断断续续的情况持续发生时,用户将会很明显地察觉到动画卡住了。
  • 如果渲染进程生成的帧速率实际上比屏幕刷新率快,那么也会出现一些视觉上的问题,比如当帧速率在 100fps 而刷新率只有 60Hz 的时候,GPU 所渲染的图像并非全都被显示出来,这就会造成丢帧现象。
  • 就算屏幕的刷新频率和 GPU 更新图片的频率一样,由于它们是两个不同的系统,所以屏幕生成帧的周期和 VSync 的周期也是很难同步起来的。

为了解决这些问题,就需要将显示器的时钟同步周期和浏览器生成页面的周期绑定起来。本质上,就是显示器与浏览器使用一个同步信号VSync来互相通知,绑定周期

  • 当显示器将一帧画面绘制完成后,并在准备读取下一帧之前,显示器会发出一个垂直同步信号(vertical synchronization)给 GPU,简称 VSync。当 GPU 接收到 VSync 信号后,会将 VSync 信号同步给浏览器进程,浏览器进程再将其同步到对应的渲染进程,渲染进程接收到 VSync 信号之后,就可以准备绘制新的一帧了。显示器与浏览器使用同步信号Vsync绑定周期
  • 当渲染进程接收到用户交互的任务后,接下来大概率是要进行绘制合成操作,因此我们可以设置,当在执行用户交互的任务时,将合成任务的优先级调整到最高。
  • 接下来,处理完成 DOM,计算好布局和绘制,就需要将信息提交给合成线程来合成最终图片了,然后合成线程进入工作状态。
  • 合成线程在工作了,那么我们就可以把下个合成任务的优先级调整为最低,并将页面解析、定时器等任务优先级提升
  • requestAnimationFrame API就是用来和 VSync 的时钟周期同步,在每一帧的开始优先执行回调函数,再执行渲染
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值