No4.浏览器中的页面循环系统

前段时间在《极客时间》上学了一个专栏,通篇略过,干货不少,但理解相当不够透彻,于是计划用几周的时间,对本专栏内容用作者的总结以及自己的相对逐字理解,来个通篇的文字记录学习,书读百遍,其义自现。
本篇是这个专栏的第四章:《浏览器中的页面循环系统》。本章分为六节。

15|消息队列和事件循环:页面是怎么“活”起来的?


本节主要专门介绍页面的事件循环系统,希望通过几段总结能对页面的事件循环系统有一个整体上的理解。

使用单线程处理安排好的任务

单线程处理的流程就是把所有任务代码按照顺序写进主线程里,等线程运行时,这些任务按照顺序在线程中执行,等所有任务执行完成,线程自动退出。

在线程运行过程中处理任务

当然并非所有任务都可以使用单线程处理,有时我们需要在线程运行的过程中处理任务。
那么要想在线程运行过程中,能接受并执行新的任务,就需要采用事件循环机制。
相较与单线程处理任务,此线程做了两点改进:

  • 引入了循环机制。(比如一个实现方式是添加for循环。线程一直循环执行)。
  • 引入了事件。
处理其他线程发送过来的任务

如何设计好一个线程模型,能让其能够接受其他线程发送的消息呢?
一个通用的模式是消息队列:「消息队列是一种数据结构、可以存放要执行的任务。它符合队列“先进先出”的特点。」
有了队列之后继续改进步骤如下:

  • 添加一个消息队列。
  • IO线程中产生的新任务添加进消息队列尾部。
  • 渲染主进程会循环地从消息队列头部中读取任务,执行任务。
处理其他进程发送过来的任务

渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面的“处理其他线程发送的任务”一样。

消息队列中的任务类型

消息队列中的任务都有哪些呢?
输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

页面使用单线程的缺点
  • 第一个问题是如何处理高优先级的任务。
    由于优先级的问题使得微任务应用而生,微任务是如何权衡效率和实时性的呢?
    通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题.等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题
  • 第二个是如何解决单个任务执行时长过久的问题.
    针对这种情况,JavaScript 可以通过回调功能来规避这种问题,也就是让要执行的 JavaScript 任务滞后执行。
总结

如果有一些确定好的任务,可以使用一个单线程来按照顺序处理这些任务,这是第一版线程模型。
要在线程执行过程中接收并处理新的任务,就需要引入循环语句和事件系统,这是第二版线程模型。
如果要接收其他线程发送过来的任务,就需要引入消息队列,这是第三版线程模型。
如果其他进程想要发送任务给页面主线程,那么先通过 IPC 把任务发送给渲染进程的 IO 线程,IO 线程再把任务发送给页面主线程。
消息队列机制并不是太灵活,为了适应效率和实时性,引入了微任务。

16 | WebAPI : setTimeout是如何实现的


浏览器怎么实现setTimeout

通过上一小节的学习,我们知道:对于一些事件执行的过程是:这些事件先被添加到消息队列,然后事件循环系统就会按照消息队列中的顺序来执行事件。也就是说,执行一段异步任务,需要先将任务添加到消息队列中。
不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,你不能将定时器的回调函数直接添加到消息队列中。
从Chromium队列的部分源码中我们知道,在Chrome中除了正常使用的消息队列外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和Chromium内部一些需要延迟执行的任务。
由于消息队列排队和一些系统级别的限制,通过setTimeout设置的回调任务并非总是可以实时的执行,这样就不能满足一些实时性要求较高的需求。

使用setTimeout的一些注意事项
  • 如果当前任务执行时间过久,会影响延迟到期定时器任务的执行。
  • 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒。
  • 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒.
  • 延时执行时间有最大值:大约 24.8 天
  • 使用 setTimeout 设置的回调函数中的 this 不符合直觉.

17 | WebAPI:XMLHttpRequest是怎么实现的?


在深入讲解 XMLHttpRequest 之前,我们得先介绍下同步回调异步回调这两个概念.

回调函数 VS 系统调用栈

回调函数:将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。

同步回调函数代码:

1
2
3
4
5
6
7
8
9
10
11
12
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

异步回调函数代码:

1
2
3
4
5
6
7
8
9
10

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)
XMLHttpRequest运作机制

对回调函数有了一个认知后,那么接着我们来分析下从发起请求到接收数据的完整流程:

首先从XMLHttpRequest的用法开始:

  • 第一步:创建XMLHttpRequest对象。
  • 第二步:为xhr对象注册回调函数。
  • 第三步:配置基础的请求信息。
  • 第四步:发起请求。
XMLRequest使用过程中的“坑”
  • 跨域问题
  • HTTPS混合内容的问题:这是指HTTPS页面中包含了不符合HTTPS安全要求的内容,比如包含了HTTP资源。
小结

setTimeout 是直接将延迟任务添加到延迟队列中,而 XMLHttpRequest 发起请求,是由浏览器的其他进程或者线程去执行,然后再将执行结果利用 IPC 的方式通知渲染进程,之后渲染进程再将对应的消息添加到消息队列中。

18 | 宏任务和微任务:不是所有的任务都是一个待遇


前面我们已经知道微任务可以在实时性和效率之间做一个有效的权衡。微任务已被广泛应用,比如Promise以及以Promise为基础开发出来的很多其他的技术。
宏任务与微任务的区别:

宏任务

页面中的大部分任务都是在主线程上执行的。如渲染事件、用户交互事件、JavaScript脚本执行事件、网络请求等等。这些在消息队列中的任务称为宏任务。
虽然宏任务可以满足我们大部门的日常需求,但是有时对时间精度要求较高的需求,宏任务就难以胜任了。

微任务

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
产生微任务的两种方式:

  • 第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。
    通过微任务的工作流程,我们可以得出如下结论:
  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当然宏任务的执行时长,因此写代码的时候一定要注意微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务早于宏任务执行。
监听DOM变化演变

微任务应用在了MutationObserver中,MutationObserver是用来监听DOM变化的一套方法。 监听DOM变化一直是前端工程师一项非常核心的需求。
下面是监听DOM变化演变的简单总结:

  • 早起观测DOM变化就是轮询检测。比如使用 setTimeout 或者 setInterval 来定时检测 DOM 是否有改变。无疑这种方式实时性不好,效率还低效。
  • 2000年的时候引入了Mutation Event,Mutation Event采用了观察者的设计模式,当DOM有变动时立即出发相应的事件。此方式属于同步回调。虽然这种方式解决了实时性问题,但是因为会产生较大性能开销、导致页面性能出现问题,被反对使用并逐步从web标准事件中删除。
  • MutationObserver替代MutationEvent,相较于Event方式,Observer采用了一次触发异步回调。且采用微任务的处理,使得实时性与性能功能都得到有效提高。

19 | Promise:使用Promise,告别回调函数

微任务的另一个应用:Promise。
本节简单介绍JavaScript引入Promise的动机,以及解决问题的几个核心关键点。
讲到动机,也就是说Promise解决了什么问题。众所周知,他解决的是异步编码风格的问题。

页面编程的一大特点就是:异步编程,下面分析异步编程的代码风格进化。

  • 之前的代码编码风格,一段代码可能会出现五次回调,这种回调导致代码逻辑不连贯、不连线,不符合人的直觉。
  • 然后开发人员们通过封装异步代码,让处理流程变得线性,但是这种处理方式如果嵌套了太多的回调函数就容易陷入回调地狱。
  • 陷入回调地狱的后代码看上去很乱主要是两点:嵌套调用和任务不确定性(成功或者失败)。于是Promise出现,解决了这两个问题。
Promise:消灭嵌套调用和多次错误处理

Promise通过两步解决嵌套回调问题:

  • 首先,Promise实现了回调函数的延时绑定(.then)
  • 其次,将回调函数返回值穿透到最外层。

Promise处理异常:
通过最后一个catch,将所有对象合并到一个函数来处理之前的所有异常。

Promise与微任务

Promise 之所以要使用微任务是由 Promise 回调函数延迟绑定技术导致的。

20 | async/await:使用同步的方式去写异步代码

当Promise解决回调地狱代码风格的同时,我们发现写很多的then函数,还是有些不太容易阅读。
基于这个原因,ES7引入了async/await,这是JavaScript异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力。并且使得代码逻辑更加清晰。

本节首先介绍生成器(Generator)是如何工作的,接着介绍了Generator的底层实现机制–协程。
这是因为async/await使用了Generator和Promise两种技术。所以紧接着通过Generator和Promise来分析async/await到底是如何通过以同步方式来编写异步代码的。

生成器 VS 协程

生成器函数:生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的。
具体使用方式就是:在生成器函数内部执行一段代码,若遇到yiled关键字,那JS引擎将返回该关键字后面的内容且暂停该函数执行,外部函数通过next方法恢复函数的执行。
那么JavaScript引擎V8是如何实现一个函数的暂停和恢复的?

搞懂它的暂停和恢复,需要首先了解协程的概念。协程是一种比线程更加轻量级的存在。可以把协程看作是跑在线程上的任务,一个线程可以存在多个协程。但在线程上同时只能执行一个协程。
在JS中,生成器就是协程的一种实现方式。

asnyc/await

为了更近一步改进生成器代码,ES7引入了async/awit,实现了更加直观简洁的代码。
async/aswit技术背后的实现就是Promise和生成器应用。往底层说就是微服务和协程应用。

async: 是一个通过异步执行并隐式返回Promise作为结果的函数。
await: 我们知道了 async 函数返回的是一个 Promise 对象,那下面我们再结合文中这段代码来看看 await 到底是什么。

1
2
3
4
5
6
7
8
9
10
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)
//输出结果:0 3 100 2

async/await 无疑是异步编程领域非常大的一个革新,也是未来的一个主流的编程风格。其实,除了 JavaScript,Python、Dart、C# 等语言也都引入了 async/await,使用它不仅能让代码更加整洁美观,而且还能确保该函数始终都能返回 Promise。

liugezhou wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
Enjoy Yourself EveryDay!