浏览器原理

前言

本文是学习李兵老师的《浏览器工作原理与实践》过程中记录笔记,详细链接见文末

进程vs线程

进程:一个应用程序的运行实例就是一个进程,详细来说就是:启动一个应用程序的时候,操作系统会为该程序分配一片内存空间,用来存放代码、数据和一个主线程,这样的运行环境就称为一个进程

线程:依附于进程,多个线程并行可以提高运行效率

进程和线程之间的几个特点

1.进程中的任意一个线程执行出错,都会导致进程崩溃

2.线程之间共享父进程的数据

3.当进程关闭的时候,整个进程的资源都会被回收

4.进程之间的内容相互隔离,如果需要进程间数据的通信,需要IPC(进程间通信)机制

单进程浏览浏览器

浏览器中所有功能模块都运行在一个进程里,比如各个网页的网络、插件、js运行环境等

单进程浏览器的缺点

1.不稳定

早期的浏览器需要通过插件来实现一些功能,这就造成了一个问题:如果一个插件的运行出现问题,这将导致整个浏览器崩溃

2.不流畅

所有的渲染模块、js执行环境以及插件都是运行在同一个线程中,这就意味着同一时刻只有一个模块可以执行,如果有一个类似于以下的无限循环的脚本

function freeze() {while (1) {console.log("freeze");}
}
freeze();

当这个脚本执行的时候,会独占整个线程并且不会退出,其它未执行任务将会一直等待,从而造成浏览器卡顿

除此之外,页面的内存泄漏也是单进程变慢的一个重要原因。通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢

3.不安全

这里依然可以从插件和页面脚本两个方面来解释该原因。插件可以使用 C/C++ 等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题

多进程浏览器

多进程浏览器框架

多进程浏览器如何解决单进程浏览器的各个缺点

1.解决不稳定的问题

各个进程之间相互隔离,即使一个页面或者插件崩溃时,影响到的只是当前页面,不会影响到其他页面,更不会造成整个浏览器的崩溃

2.解决不流畅的问题

同样,各个进程相互隔离,一个页面的js阻塞的只是自己的渲染进程,不会影响到别的页面的渲染进程,内存泄漏的问题就更简单了,当关闭一个页面的时候,该进程下的所有资源都会被回收

3.解决不安全的问题

多进程架构使用了安全沙箱,可以把沙箱看成是操作系统给进程上了一把锁,沙箱里面的程序可以运行,但是不能在硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如文档和桌面。Chrome 把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限

不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题

更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源

更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了

TCP/IP

在衡量 Web 页面性能的时候有一个重要的指标叫“FP(First Paint)”,是指从页面加载到首次开始绘制的时长。这个指标直接影响了用户的跳出率,更快的页面响应意味着更多的 PV、更高的参与度,以及更高的转化率。那什么影响 FP 指标呢?其中一个重要的因素是网络加载速度

IP

数据包要在互联网上进行传输,就要符合网际协议(Internet Protocol,简称 IP)标准

每个物理机都有一个唯一的IP地址,访问一个网站实际上就是访问这个网站的IP地址

简化的 IP 网络三层传输模型:

UDP

IP只是把数据包送到指定主机,并不知道需要讲数据送给什么应用程序,因此,需要基于IP之上能和应用打交道的协议,最常见的就是UDP(用户数据包协议,User Datagram Protocol)

IP通过IP地址将数据包送到对应的主机,UDP则是通过端口号把数据包分发给对应的应用程序,在传输中,网络层会给数据包加上IP头,而传输层则会给数据包加上UDP头

简化的 UDP 网络四层传输模型:

UDP的优缺点

缺点:在使用 UDP 发送数据时,有各种因素会导致数据包出错,虽然 UDP 可以校验数据是否正确,但是对于错误的数据包,UDP 并不提供重发机制,只是丢弃当前的包,而且 UDP 在发送之后也无法知道是否能达到目的地,所以UDP不能保证数据的可靠性

优点:UDP的传输速度非常快

TCP

UDP传输中存在两个问题:

1.数据包在传输过程中容易丢失

2.传输的时候大的数据包会被拆分成多个小数据包进行传输,这些小的数据包会在不同时间到达,UDP协议并不知道如何组装这些数据包,所以无法还原

基于这两个问题,TCP来了

TCP(传输控制协议,Transmission Control Protocol)是一种面向连接的,可靠的,基于字节流的传输协议

解决UDP传输存在的两个问题:

1.提供超时重传机制

2.TCP引入了数据包排序机制,用以保证接收的时候将乱序的数据包完整的还原

简化的 TCP 网络四层传输模型:

完整的TCP连接过程

一个TCP的完整生命周期:

首先,建立连接。三次握手是指建立连接的时候发送端和接收端需要发送三次数据包用以确认连接成功

传输数据阶段。在此阶段,接收端需要在接收到每个数据包的时候发送一个确认接收的数据包,发送端在发送之后规定时间内没有接收到对应的确认接收的数据包,则会进行重传。一个大文件被拆分成许多小数据包进行发送的时候,接收端会根据TCP头的排序信息对小数据包进行排序。

断开连接阶段。四次挥手保证双方都能断开连接

HTTP

HTTP 是一种允许浏览器向服务器获取资源的协议,是 Web 的基础,通常由浏览器发起请求,用来获取不同类型的文件,例如 HTML 文件、CSS 文件、JavaScript 文件、图片、视频等。此外,HTTP 也是浏览器使用最广的协议

浏览器发起HTTP请求的过程

输入一个URL都发生了什么(http://time.geekbang.org/index.html)

构建请求

首先浏览器会构建请求行信息

GET /index.html HTTP1.1

查找缓存

真正发起网络请求之前,浏览器会检查浏览器缓存中是否有请求相关的文件。其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术

如果发现浏览器缓存中有请求资源的副本,则会拦截请求,返回副本资源,并直接结束请求,不会去服务器请求资源

这样做有两个好处

1.缓解服务端压力,提升性能

2.对于网站来说,缓存是实现快速加载资源的重要部分

如果缓存查找失败,会进入网络请求过程

准备IP地址和端口

HTTP是基于TCP之上进行的

首先进行TCP连接,则需要对应的IP,而将域名映射为IP地址需要DNS(域名系统,Domain Name System)

所以,浏览器会先请求DNS返回域名对应的IP地址,而这个过程中,浏览器也提供了DNS数据缓存服务,如果某个域名已经解析过了,浏览器会保存解析结果,以便下次使用,这样又减少了一次请求。拿到IP之后就是获取端口号

等待TCP队列

如果当前域名的TCP连接数超过最大值,则会排队等待连接,没有超过则进行下一步TCP连接

TCP连接

发起HTTP请求

TCP连接之后,可以进行HTTP通信了

首先浏览器会向服务器发送请求行,如果请求方法是POST,那么准备的数据通过请求体发送

服务端处理HTTP请求过程

返回HTTP请求

服务器处理结束之后,向客户端返回数据,curl可以查看请求返回数据

curl -i  https://time.geekbang.org/

断开连接

一般服务端向客户端返回了请求数据就会关闭连接,如果浏览器或者服务器在头信息中携带了

Connection:Keep-Alive

那么TCP会保持连接状态,浏览器可以继续通过同一个TCP连接发送请求,可以提升加载资源速度

重定向

curl 来查看下请求 geekbang.org 会返回什么内容?

为什么很多站点第二次打开速度会很快?

第二次打开网页很快,是因为第一次加载的时候,缓存了一些耗时的数据,比如DNS缓存和页面资源缓存

页面资源缓存的处理过程:

从上图的第一次请求可以看出,当服务器返回 HTTP 响应头给浏览器时,浏览器是通过响应头中的 Cache-Control 字段来设置是否缓存该资源。通常,我们还需要为这个资源设置一个缓存过期时长,而这个时长是通过 Cache-Control 中的 Max-age 参数来设置的,比如上图设置的缓存过期时间是 2000 秒

Cache-Control:Max-age=2000

但如果缓存过期了,浏览器则会继续发起网络请求,并且在 HTTP 请求头中带上:

If-None-Match:"4f80f-13c-3a1xb12a"

服务器收到请求头后,会根据值来判断缓存资源是否有更新:

如果没有更新,就返回 304 状态码,相当于服务器告诉浏览器:“这个缓存可以继续使用,这次就不重复发送数据给你了。”

如果资源有更新,服务器就直接返回最新资源给浏览器

登录状态是如何保持的?

用户打开登录页面,填入用户名和密码,点击确定按钮。点击按钮会触发页面脚本生成用户登录信息,然后调用 POST 方法提交用户登录信息给服务器。

服务器接收到浏览器提交的信息之后,查询后台,验证用户登录信息是否正确,如果正确的话,会生成一段表示用户身份的字符串,并把该字符串写到响应头的 Set-Cookie 字段里,如下所示,然后把响应头发送给浏览器

Set-Cookie: UID=3431uad;

浏览器在接收到服务器的响应头后,开始解析响应头,如果遇到响应头里含有 Set-Cookie 字段的情况,浏览器就会把这个字段信息保存到本地。比如把“UID=3431uad”保存到本地

当用户再次访问时,浏览器会发起 HTTP 请求,但在发起请求之前,浏览器会读取之前保存的 Cookie 数据,并把数据写进请求头里的 Cookie 字段里(如下所示),然后浏览器再将请求头发送给服务器

Cookie: UID=3431uad;

服务器在收到 HTTP 请求头数据之后,就会查找请求头里面的“Cookie”字段信息,当查找到包含UID=3431uad的信息时,服务器查询后台,并判断该用户是已登录状态,然后生成含有该用户信息的页面数据,并把生成的数据发送给浏览器

浏览器在接收到该含有当前用户的页面数据后,就可以正确展示用户登录的状态信息了

Cookie 流程可以参考下图:

HTTP请求流程图

输入一个URL发生了什么?

1.输入URL,浏览器判断是搜索内容还是网址,如果是搜索内容搜索内容+默认搜索引擎地址合成新的URL,如果输入内容符合URL规则,则拼接协议合成合法的URL

2.输入完内容,敲下回车之后,浏览器导航栏呈现loading状态,停留在前一个页面,因为新的资源还没获得

3.浏览器进程合成请求行信息,通过IPC(进程间通信)发送给网络进程

4.网络进程拿到URL和相关信息,查找缓存中是否有该URL对应的资源,如果有,拦截请求,返回200和缓存的资源文件,否则,进入网络请求阶段

5.网络进程请求DNS查找URL对应的IP,如果之前DNS缓存过这个URL的信息,就会直接返回缓存信息,否则,发起请求获取解析出来的IP和端口号,如果没有端口号,HTTP默认80,HTTPS默认443,如果是HTTPS,还会建立TLS连接

6.拿到了IP和端口号,接下来进行TCP连接,在正式连接之前,会检查该域名下是否超过了6个TCP连接,如果超过,进入等待连接状态,如果没有超过,则发起正式连接

7.发起正式TCP连接,这个过程中,数据包会带上TCP头信息–包括源端口号、目的端口号和用于确保数据包顺序的序列信息,向网络层传输

8.网络层给数据包头部加上IP头信息,向物理层传输

9.物理层通过物理网络传输到目的主机

10.目的主机网络层接收到数据包,解析出IP头,剩下的数据包向上传输给传输层

11.目的主机传输层接收到数据包,解析出TCP头,根据端口信息把数据传输给应用层

12.应用层解析出请求头信息,如果需要重定向,返回响应数据的状态301或者302,同时在location字段中加上重定向的地址信息,浏览器会根据location发起一次新的连接,如果不是重定向,服务器会根据请求头的If-None-Match来判断缓存资源是否需要更新,如果不需要更新,返回304,告诉浏览器可以用之前的缓存数据,如果有更新,带着更新信息一起返回给浏览器,并在响应头中加入字段:Cache-Control:Max-age=2000

数据又顺着应用层-网络层-传输层-传输层-网络层-应用层返回到浏览器网络进程

13.数据传输完成,TCP四次挥手断开连接,但如果浏览器或服务器在HTTP头中带上了:Connection:Keep-Alive,TCP就会保持连接,不会断开

14.网络层获取到数据包解析出Content-Type,如果是字节流类型,就提交给下载管理器,导航流程结束,如果是text/html类型,就通知浏览器进程准备进行渲染

15.浏览器进程收到通知,根据当前页面和打开的新页面是否是同一站点判断是否新开一个渲染进程

16.确认之后,浏览器进程会发出**“提交文档”信息给渲染进程,渲染进程接收到消息后,打开和网络进程之间的管道,传输文档数据,传输完成后,渲染进程会告诉浏览器进程“确认提交**”

17.浏览器进程收到“确认提交”消息后,更新浏览器的状态,包括地址栏URL、前进后退的历史状态,并更新web页面

18.渲染进程对文档进行页面解析和子资源加载,HTML 通过HTML解析器转成DOM Tree(二叉树类似结构的东西),CSS按照CSS 规则和CSS解释器转成CSSOM TREE,两个tree结合,形成render tree(不包含HTML的具体元素和元素要画的具体位置),通过Layout可以计算出每个元素具体的宽高颜色位置,结合起来,开始绘制,最后显示在屏幕中新页面显示出来

JS性能优化

提升单次脚本的执行速度,避免 JavaScript 的长任务霸占主线程,这样可以使得页面快速响应交互;

避免大的内联脚本,因为在解析 HTML 的过程中,解析和编译也会占用主线程;

减少 JavaScript 文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。

消息队列和事件循环

消息队列和事件循环模型

从图中可以看出:

渲染主线程负责事件循环,事件的来源为消息队列

IO线程负责将各种事件添加到消息队列中

对于其他进程触发的事件,通过进程间通信告诉渲染进程中的IO线程

注意:由于是多个线程操作一个消息队列,所以在添加任务和取出任务的时候还会加上一个同步锁

如何安全退出

确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志

如果有退出标志,直接中断当前的所有任务,退出线程,可以参考以下代码:

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){for(;;){Task task = task_queue.takeTask();ProcessTask(task);if(!keep_running) //如果设置了退出标志,那么直接退出线程循环break; }
}

页面使用单线程的缺点

页面线程执行的任务都是来自于消息队列,而消息队列又要遵从“先进先出”的规则,所以有以下两个问题需要解决:

1.如何处理高优先级任务

微任务

一般消息队列的任务都被称为宏任务,每个宏任务里面都包含了一个微任务队列,当有高优先级任务触发时(比如DOM上的变化),该任务就会被添加到当前执行的宏任务的微任务队列中,这样就不会阻塞宏任务的继续执行,提高了执行率

当宏任务执行完成之后,不会立即去执行下一个宏任务,而是先执行微任务队列里面的任务,因为DOM变化的事件都保存在微任务队列中,这样就提高了实时性

2.如何解决单个任务执行时间过长的问题

因为所有的任务都是在单线程中执行的,所以一旦某个任务执行时间过长,就会阻塞后续任务的执行。

针对这种情况,JavaScript可以通过回调功能来解决这个问题,也就是让执行时间过长的任务滞后执行

垃圾回收

JavaScript的垃圾回收是通过垃圾回收器来进行的,分为栈区的垃圾回收和堆区的垃圾回收

调用栈中的垃圾是如何回收的

当一个函数执行完后,JavaScript引擎会通过下移ESP来销毁函数保存在调用栈中的执行上下文,可见下图

function foo(){var a = 1var b = {name:"极客邦"}function showName(){var c = 2var d = {name:"极客时间"}}showName()
}
foo()

堆中的垃圾是如何回收的

从上一个例子中可以知道,在showName的执行上下文被销毁之后,堆中还存有1003和1050这两个数据

V8中会把堆分为老生区和新生区,新生区中是生存时间短的数据,老生区中是生存时间较长的数据或占用空间较大的数据,针对这两个去,V8使用两个垃圾回收器来对应回收垃圾:

新生区:副垃圾回收器,老生区:主垃圾回收器

不管什么垃圾回收器,都有一套共同的流程,标记-回收-整理

回收流程:

新生区:新生区采用Scavenge算法,将新生区分为对象区域和空闲区域,新加入的对象都会存放在对象区域,在区域快要被写满的时候,执行垃圾回收。

首先对对象区域中的垃圾做标记,然后进入垃圾回收阶段

副垃圾回收器会把清理之后存活的数据复制到空闲区,同时还会把这些数据有序排列起来,这样操作之后,空闲区域就没有内存碎片了

最后将对象区域和空闲区域进行角色翻转,这样就完成了垃圾回收

值得注意的一个点是:如果新生区设置得太大了,每次清理时间就会边长,所以为了执行效率,新生区一般都会设置得比较小,就会很容易被写满,为了解决这个问题,JavaScript引擎采用了对象晋升策略:经过两次垃圾回收还存在的对象,会晋升到老生区

老生区:由于老生区的对象比较大,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间,所以主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的

首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据,还是上面那个例子,关于是否可到达,可参考下图:

接下来就是垃圾的清除过程。标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

全停顿

由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World),全停顿对新生区的影响不大,因为新生区较小,存活对象也少,但对老生区的影响就比较大了,老生区存放了较大的数据,如果清理过程中耗时过长,主线程又不能做其他的事情,造成页面卡顿就不好了

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法,如下图:

setTimeout是如何实现的

首先,我们知道浏览器要执行一个任务,需要先将任务添加到消息队列中,然后事件循环系统再按照顺序执行消息队列中的任务,先来看看一些典型的事件:

  • 当接收到 HTML 文档数据,渲染引擎就会将“解析 DOM”事件添加到消息队列中
  • 当用户改变了 Web 页面的窗口大小,渲染引擎就会将“重新布局”的事件添加到消息队列中
  • 当触发了 JavaScript 引擎垃圾回收机制,渲染引擎会将“垃圾回收”任务添加到消息队列中
  • 同样,如果要执行一段异步 JavaScript 代码,也是需要将执行任务添加到消息队列中

但是,设置定时器的回调需要在指定时间间隔内被调用,但是消息队列的任务是按照顺序执行的,换个说法,不能将定时器的回调函数直接放到消息队列中

针对这个问题,Chrome中除了正常的消息队列外,还维护了一个延迟队列,用以维护需要延迟执行的任务

使用setTimeout的一些注意事项

首先,如果当前任务执行时间过长,会影响定时器任务的执行,比如以下代码:

function bar() {console.log('bar')
}
function foo() {setTimeout(bar, 0);for (let i = 0; i < 5000; i++) {let i = 5+8+8+8console.log(i)}
}
foo()

这段代码中,foo函数里面设置了一个延时为0的回调任务,设置好回调之后,foo函数会执行5000次的循环

通过 setTimeout 设置的回调任务被放入了消息队列中并且等待下一次执行,由于当前任务的执行时间较长,一定会影响到下一次任务的执行时间

延时执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行

使用 setTimeout 设置的回调函数中的 this 不符合直觉

如果被 setTimeout 推迟执行的回调函数是某个对象的方法,那么该方法中的 this 关键字将指向全局环境,而不是定义时所在的那个对象,可以看看这段代码:

var name= 1;
var MyObj = {name: 2,showName: function(){console.log(this.name);}
}
setTimeout(MyObj.showName,1000)

这里输出的是 1,因为这段代码在编译的时候,执行上下文中的 this 会被设置为全局 window,如果是严格模式,会被设置为 undefined

解决办法:

1.将MyObj.showName放在匿名函数中执行

//箭头函数
setTimeout(() => {MyObj.showName()
}, 1000);
//或者function函数
setTimeout(function() {MyObj.showName();
}, 1000)

2.使用 bind 方法,将 showName 绑定在 MyObj 上面

setTimeout(MyObj.showName.bind(MyObj), 1000)

宏任务和微任务

随着浏览器的应用领域越来越广泛,消息队列中的粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术——微任务,微任务可以在实时性和效率之间做一个有效的权衡

宏任务

页面中的大部分任务都是在主线程上执行的,这些任务包括了:

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

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

宏任务可以满足我们日常中的很大部分需求,但如果遇到对时间精度要求很高的需求时,宏任务就顶不住了因为:

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间

所以宏任务的时间粒度比较大,执行时间是不能精准控制的

微任务

了解什么是微任务之前,先了解一下异步回调的两种主要方式:

  1. 把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数
  2. 在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的

那么微任务到底是什么呢?

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

我们知道,当执行一段JavaScript脚本的时候,V8会为其创建一个全局执行上下文,在这同时V8也会创建一个微任务队列,不过这个微任务队列是V8内部访问的,所以无法通过JavaScript访问到,也就是说每个宏任务都关联了一个微任务队列

微任务是怎么产生的

主要有两种方式

  1. 使用MutationObserver监控每个DOM节点,然后再通过JavaScript来操作这个节点时,当节点发生改变的时候,就产生了DOM节点变化记录的微任务
  2. 使用Promise,当调用Promise.resolve()和Promise.reject()的时候,也会产生微任务

这些微任务都会被JavaScript引擎按照执行顺序保存到微任务队列中

微任务是什么时候执行的

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

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行

结合下图进行理解:


该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。从图中可以看到,全局上下文中包含了微任务列表

由上文可以得出几个结论:

  1. 微任务与宏任务是绑定的,每个宏任务在执行时,都会产生一个微任务队列
  2. 微任务的执行时长会影响到当前宏任务的执行时长
  3. 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都会早于宏任务执行,因为微任务的执行一定是当前宏任务执行结束之前,而宏任务会被添加到消息队列,等待下一次事件循环,也就是说微任务没有执行完成,当前宏任务就不会执行完成,也就不会进入到下一轮

HTTP1\HTTP2\HTTP3

HTTP/0.9

HTTP/0.9主要用来实现小体积的html文件传输,实现上有以下三个特点:

  • 第一个是只有一个请求行,并没有 HTTP 请求头和请求体,因为只需要一个请求行就可以完整表达客户端的需求了
  • 第二个是服务器也没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了
  • 第三个是返回的文件内容是以 ASCII 字符流来传输的,因为都是 HTML 格式的文件,所以使用 ASCII 字节码来传输是最合适的

HTTP/1.0

HTTP/1.0 除了对多文件提供良好的支持外,还依据当时实际的需求引入了很多其他的特性,这些特性都是通过请求头和响应头来实现的。

下面我们来看看新增的几个典型的特性:

  • 有的请求服务器可能无法处理,或者处理出错,这时候就需要告诉浏览器服务器最终处理该请求的情况,这就引入了状态码。状态码是通过响应行的方式来通知浏览器的。
  • 为了减轻服务器的压力,在 HTTP/1.0 中提供了 Cache 机制,用来缓存已经下载过的数据。
  • 服务器需要统计客户端的基础信息,比如 Windows 和 macOS 的用户数量分别是多少,所以 HTTP/1.0 的请求头中还加入了用户代理的字段。

HTTP/1.1

对比HTTP/1.0 遇到了哪些主要的问题,来看看HTTP/1.1 是如何改进的

1.改进持久连接

HTTP/1.0 每进行一次 HTTP 通信,都需要经历建立 TCP 连接、传输 HTTP 数据和断开 TCP 连接三个阶段,在如今的需求中,页面需要下载大量的外部资源文件,每次都进行TCP连接、传输、断开,无疑是增大开销

为了解决这个问题,HTTP/1.1 中增加了持久连接的方法,它的特点是在一个 TCP 连接上可以传输多个 HTTP 请求,只要浏览器或者服务器没有明确断开连接,那么该 TCP 连接会一直保持

持久连接在 HTTP/1.1 中是默认开启的,所以你不需要专门为了持久连接去 HTTP 请求头设置信息,如果你不想要采用持久连接,可以在 HTTP 请求头中加上Connection: close

2.不成熟的 HTTP 管线化

持久连接虽然可以减少TCP多次连接、传输、断开的次数,但也产生了一个问题,单词连接传输过程中,需要等待前面的请求返回之后才可以进行下一次请求,如果某个请求耗时很长,就会阻塞后面的所有请求,这就是队头阻塞问题

HTTP/1.1 中试图通过管线化的技术来解决队头阻塞的问题。HTTP/1.1 中的管线化是指将多个 HTTP 请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求,FireFox、Chrome 都做过管线化的试验,但是由于各种原因,它们最终都放弃了管线化技术

3.提供虚拟主机的支持

在 HTTP/1.0 中,每个域名绑定了一个唯一的 IP 地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个 IP 地址。

因此,HTTP/1.1 的请求头中增加了 Host 字段,用来表示当前的域名地址,这样服务器就可以根据不同的 Host 值做不同的处理

4.对动态生成的内容提供了完美支持

在设计 HTTP/1.0 时,需要在响应头中设置完整的数据大小,如Content-Length: 901,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。

HTTP/1.1 通过引入 Chunk transfer 机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持

5.客户端 Cookie、安全机制

除此之外,HTTP/1.1 还引入了客户端 Cookie 机制和安全机制

HTTP/2

虽然HTTP/1.1已经做了大量优化,但依然有很多瓶颈,由此来说说HTTP/2

首先,我们知道 HTTP/1.1 为网络效率做了大量的优化,最核心的有如下三种方式:

  • 增加了持久连接
  • 浏览器为每个域名最多同时维护 6 个 TCP 持久连接
  • 使用 CDN 的实现域名分片机制

影响HTTP/1.1效率的三个因素:TCP 的慢启动、多条 TCP 连接竞争带宽和队头阻塞

针对这些问题,HTTP/2通过多路复用机制解决了这些问题

多路复用是通过在协议栈中添加二进制分帧层来实现的,有了二进制分帧层还能够实现请求的优先级、服务器推送、头部压缩等特性,从而大大提升了文件传输效率

虽然 HTTP/2 引入了二进制分帧层,不过 HTTP/2 的语义和 HTTP/1.1 依然是一样的,也就是说它们通信的语言并没有改变,比如开发者依然可以通过 Accept 请求头告诉服务器希望接收到什么类型的文件,依然可以使用 Cookie 来保持登录状态,依然可以使用 Cache 来缓存本地文件,这些都没有变,发生改变的只是传输方式

HTTP/3

HTTP/2依然有一些缺陷:

TCP的队头阻塞

首先看看什么是TCP队头阻塞,可以看看下图:

在 TCP 传输过程中,由于单个数据包的丢失而造成的阻塞称为 TCP 上的队头阻塞

那么他是如何影响到HTTP/2的多路复用的呢?

首先看看正常情况下的多路复用:

HTTP/2 中,多个请求是跑在一个 TCP 管道中的,如果其中任意一路数据流中出现了丢包的情况,那么就会阻塞该 TCP 连接中的所有请求。这不同于 HTTP/1.1,使用 HTTP/1.1 时,浏览器为每个域名开启了 6 个 TCP 连接,如果其中的 1 个 TCP 连接发生了队头阻塞,那么其他的 5 个连接依然可以继续传输数据。

所以随着丢包率的增加,HTTP/2 的传输效率也会越来越差。有测试数据表明,当系统达到了 2% 的丢包率时,HTTP/1.1 的传输效率反而比 HTTP/2 表现得更好

TCP 建立连接的延时

HTTP/1 和 HTTP/2 都是使用 TCP 协议来传输的,而如果使用 HTTPS 的话,还需要使用 TLS 协议进行安全传输,而使用 TLS 也需要一个握手过程,这样就需要有两个握手延迟过程。

在建立 TCP 连接的时候,需要和服务器进行三次握手来确认连接成功,也就是说需要在消耗完 1.5 个 RTT 之后才能进行数据传输。

进行 TLS 连接,TLS 有两个版本——TLS1.2 和 TLS1.3,每个版本建立连接所花的时间不同,大致是需要 1~2 个 RTT

总之,在传输数据之前,我们需要花掉 3~4 个 RTT。如果浏览器和服务器的物理距离较近,那么 1 个 RTT 的时间可能在 10 毫秒以内,也就是说总共要消耗掉 30~40 毫秒。这个时间也许用户还可以接受,但如果服务器相隔较远,那么 1 个 RTT 就可能需要 100 毫秒以上了,这种情况下整个握手过程需要 300~400 毫秒,这时用户就能明显地感受到“慢”了

TCP 协议僵化

总之就是TCP 协议存在队头阻塞和建立连接延迟等缺点,很难通过改进TCP来解决这些问题

QUIC协议

QUIC协议是基于 UDP 实现了类似于 TCP 的多路数据流、传输可靠性等功能的一个协议

通过上图可以看出,HTTP/3 中的 QUIC 协议集合了以下几点功能:

  • 实现了类似 TCP 的流量控制、传输可靠性的功能。虽然 UDP 不提供可靠性的传输,但 QUIC 在 UDP 的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些 TCP 中存在的特性。
  • 集成了 TLS 加密功能。目前 QUIC 使用的是 TLS1.3,相较于早期版本 TLS1.3 有更多的优点,其中最重要的一点是减少了握手所花费的 RTT 个数。
  • 实现了 HTTP/2 中的多路复用功能。和 TCP 不同,QUIC 实现了在同一物理连接上可以有多个独立的逻辑数据流(如下图)。实现了数据流的单独传输,就解决了 TCP 中队头阻塞的问题,QUIC 协议的多路复用:

  • 实现了快速握手功能。由于 QUIC 是基于 UDP 的,所以 QUIC 可以实现使用 0-RTT 或者 1-RTT 来建立连接,这意味着 QUIC 可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度

总结

以上是学习李兵老师的《浏览器工作原理与实践》记录下的笔记,详细学习:
《浏览器工作原理与实践》

《浏览器工作原理与实践》学习笔记相关推荐

  1. 第二行代码学习笔记——第六章:数据储存全方案——详解持久化技术

    本章要点 任何一个应用程序,总是不停的和数据打交道. 瞬时数据:指储存在内存当中,有可能因为程序关闭或其他原因导致内存被回收而丢失的数据. 数据持久化技术,为了解决关键性数据的丢失. 6.1 持久化技 ...

  2. 第一行代码学习笔记第二章——探究活动

    知识点目录 2.1 活动是什么 2.2 活动的基本用法 2.2.1 手动创建活动 2.2.2 创建和加载布局 2.2.3 在AndroidManifest文件中注册 2.2.4 在活动中使用Toast ...

  3. 第一行代码学习笔记第八章——运用手机多媒体

    知识点目录 8.1 将程序运行到手机上 8.2 使用通知 * 8.2.1 通知的基本使用 * 8.2.2 通知的进阶技巧 * 8.2.3 通知的高级功能 8.3 调用摄像头和相册 * 8.3.1 调用 ...

  4. 第一行代码学习笔记第六章——详解持久化技术

    知识点目录 6.1 持久化技术简介 6.2 文件存储 * 6.2.1 将数据存储到文件中 * 6.2.2 从文件中读取数据 6.3 SharedPreferences存储 * 6.3.1 将数据存储到 ...

  5. 第一行代码学习笔记第三章——UI开发的点点滴滴

    知识点目录 3.1 如何编写程序界面 3.2 常用控件的使用方法 * 3.2.1 TextView * 3.2.2 Button * 3.2.3 EditText * 3.2.4 ImageView ...

  6. 第一行代码学习笔记第十章——探究服务

    知识点目录 10.1 服务是什么 10.2 Android多线程编程 * 10.2.1 线程的基本用法 * 10.2.2 在子线程中更新UI * 10.2.3 解析异步消息处理机制 * 10.2.4 ...

  7. 第一行代码学习笔记第七章——探究内容提供器

    知识点目录 7.1 内容提供器简介 7.2 运行权限 * 7.2.1 Android权限机制详解 * 7.2.2 在程序运行时申请权限 7.3 访问其他程序中的数据 * 7.3.1 ContentRe ...

  8. 第一行代码学习笔记第五章——详解广播机制

    知识点目录 5.1 广播机制 5.2 接收系统广播 * 5.2.1 动态注册监听网络变化 * 5.2.2 静态注册实现开机广播 5.3 发送自定义广播 * 5.3.1 发送标准广播 * 5.3.2 发 ...

  9. 第一行代码学习笔记第九章——使用网络技术

    知识点目录 9.1 WebView的用法 9.2 使用HTTP协议访问网络 * 9.2.1 使用HttpURLConnection * 9.2.2 使用OkHttp 9.3 解析XML格式数据 * 9 ...

  10. 安卓教程----第一行代码学习笔记

    安卓概述 系统架构 Linux内核层,还包括各种底层驱动,如相机驱动.电源驱动等 系统运行库层,包含一些c/c++的库,如浏览器内核webkit.SQLlite.3D绘图openGL.用于java运行 ...

最新文章

  1. TNF诱导的关节破坏由IL-1介导
  2. 新元素之section,article,aside
  3. MySQL的4中隔离级别
  4. 交互式计算机图形学总结:第一章 图形系统和模型
  5. 消防荷载楼板按弹性还是塑性计算_现浇楼板裂缝处理办法全总结!
  6. Linux定时运行程序脚本
  7. 从Qt4 迁移到Qt5 winEvent代替为nativeEvent
  8. 【MySQL】MySQL warnings 的使用
  9. Apache AB 性能测试
  10. sentinel卫星_关于“哨兵6号”迈克尔弗里利希卫星的五条信息
  11. 图扑 Web 可视化引擎在仿真分析领域的应用
  12. 理解Windows操作系统的KMS与MAK密钥
  13. 卡莱特led显示屏调试教程_如何使用卡莱特软件点亮LED电子显示屏
  14. excel-柱状图不同柱子不同颜色设置
  15. 常用进制数转换(二进制、八进制、十进制、十六进制)【数电笔记】
  16. idea里的包移不动_IDEA 半天卡住buid(编译)不动——解决办法及定位思路
  17. H3C单臂路由的配置
  18. Java航班预订统计leetcode_1109
  19. [SDOI2013] 淘金
  20. 银内胆保温杯的功效和作用

热门文章

  1. UBOOT I2C读写详解(基于mini2440)
  2. 18天精读掌握《费曼物理学讲义卷一》 第1天 2019/6.12
  3. 13.3 跳格子游戏
  4. ccf-csp 2013-2015题目总结
  5. 马斯克的脑机接口,一块树莓派就能做出来?
  6. 小学计算机课的游戏橡皮小人,小学计算机科学课:两个女孩和男孩使用带增强现实软件的数码平板电脑,他们感到兴奋、充满惊奇、好奇。STEM 、游戏、学习中的儿童...
  7. 细说中国各省省名的由来(zt)
  8. 03.Hadoop之HDFS
  9. oracle 开启utl_tcp,关于Oracle的UTL_TCP
  10. 谷歌浏览器不能使用opener属性的问题和解决