深入浏览器之浏览器中的进程与线程

·  阅读 1183
深入浏览器之浏览器中的进程与线程

说在前面

本文章的视频解说已上传

作为一个平台,现代浏览器是专门设计用来快速、高效、安全地交付 Web 应用的。 事实上,在其表面之下,现代浏览器完全是一个囊括数百个组件的操作系统,包括进程管理、安全沙箱、分层的优化缓存、JavaScript 虚拟机、图形渲染和 GPU 管道、存储系统、传感器、音频与视频、网络机制,等等。

显然,浏览器乃至运行在其中的应用的性能,取决于若干组件:解析、布局、 HTML 与 CSS 的样式计算、JavaScript 执行速度、渲染管道,当然还有网络相关各层协议的配合。其中每个组件的角色都很重要,所以保证各个组件之间合理高效地互相配合工作也是浏览器至关重要的工作。

在这里首先感谢李兵老师精彩的技术课程,本篇文章以李兵老师的《浏览器工作原理与实践》为技术知识的主要来源,从浏览器中的进程与线程的角度来深入分析浏览器的工作原理。

进程与线程

进程(process)和线程(thread)是操作系统的基本概念,但是它们比较抽象,不容易掌握。

为了不把概念性的东西说的太枯燥,这里我引用了阮一峰老师进程与线程的文章,用一个形象的例子来说明。

首先,计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻运行着。

假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个CPU一次只能运行一个任务。

进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。

一个车间里,可以有很多工人。他们协同完成一个任务。线程就好比车间里的工人。一个进程可以包括多个线程。

车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存(共享内存中间的各种协调机制这里不详细说明)。

好了,到这里我们大概说清了进程与线程的关系,这时我们再用专业点的语言来解释一下:

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程

线程是处理具体任务的,多线程可以并行处理任务。但是线程是不能单独存在的,它是由进程来启动和管理的。

img1

从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。

特点

总结来说,进程和线程之间的关系有以下 4 个特点。

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

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

  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。

    当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。

  4. 进程之间的内容相互隔离。

    进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。

单进程浏览器

顾名思义,单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。 其实早在 2007 年之前,市面上浏览器都是单进程的。

img

我们可以结合上面进程与线程的特点,来分析一下单线程的问题。

首先是进程中的任意一线程执行出错,都会导致整个进程的崩溃。这导致了单线程浏览器不稳定的问题。

早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。

除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。

第二点,当一个进程关闭之后,操作系统会回收进程所占用的内存。 那么就是说单进程浏览器在关闭之前,操作系统都不会完全回收进程所占用的内存,只能依靠浏览器的内存回收机制。

但是,通常浏览器的内核都是非常复杂的,运行一个复杂点的页面再关闭页面,会存在内存不能完全回收的情况,这样导致的问题是使用时间越长,内存占用越高,浏览器会变得越慢。

还有就是所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。那么当一个循环的JS脚本运行时,会独占一整个线程,这样就导致其他运行在该线程中的任务没有机会被执行,又因为页面渲染都放在了一个线程里面运行,所以页面会失去响应,变卡顿。这也就是为什么早年的浏览器一个页面卡主,整个浏览器都会卡主的原因了。

综上所述,导致了单线程浏览器的第二个问题:不流畅

最后一点,线程之间共享进程中的数据,进程之间的内容相互隔离。也就是说单线程浏览器中的页面是可以通过一些手段获取到浏览器的所有权限,然后再通过浏览器攻击到操作系统的。这就导致了单线程浏览器不安全的问题。

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

至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。总而言之,就是浏览器对于脚本与插件的运行没有一个隔离环境的存在,导致了各种安全问题的出现。

总结一下,单线程浏览器有以下三个主要问题:

  1. 不稳定
  2. 不流畅
  3. 不安全

多进程浏览器

好在现代浏览器已经解决了这些问题,是如何解决的呢?这就得聊聊我们这个“多进程浏览器时代”了。

早期多进程架构

先看看下面这张图,这是 2008 年 Chrome 发布时的进程架构。

img

从图中可以看出,Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。

我们先看看如何解决不稳定的问题。 由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题。

接下来再来看看不流畅的问题是如何解决的。 同样,JavaScript 也是运行在渲染进程中的,所以即使 JavaScript 阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的。所以当我们再在 Chrome 中运行上面那个死循环的脚本时,没有响应的仅仅是当前的页面。

对于内存泄漏的解决方法那就更简单了,因为当关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。

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

好了,分析完早期的 Chrome 浏览器后,相信你已经了解了浏览器采用多进程架构的必要性。

目前多进程架构

不过 Chrome 的发展是滚滚向前的,相较之前,目前的架构又有了很多新的变化。我们先看看最新的 Chrome 进程架构,可以参考下图:

img

从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

下面我们来逐个分析下这几个进程的功能。

  • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程。其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

讲到这里,你应该就可以知道打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;如果打开的页面有运行插件的话,还需要再加上 1 个插件进程

  • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

对于上面这两个问题,Chrome 团队一直在寻求一种弹性方案,既可以解决资源占用高的问题,也可以解决复杂的体系架构的问题。

未来面向服务的架构

为了解决这些问题,在 2016 年,Chrome 官方团队使用“面向服务的架构”(Services Oriented Architecture,简称 SOA)的思想设计了新的 Chrome 架构。也就是说 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,原来的各种模块会被重构成独立的服务(Service),每个服务(Service)都可以在独立的进程中运行,访问服务(Service)必须使用定义好的接口,通过 IPC 来通信,从而构建一个更内聚、松耦合、易于维护和扩展的系统,更好实现 Chrome 简单、稳定、高速、安全的目标。

Chrome 最终要把 UI、数据库、文件、设备、网络等模块重构为基础服务,类似操作系统底层服务,下面是 Chrome“面向服务的架构”的进程模型图:

img

同时 Chrome 还提供灵活的弹性架构,在强大性能设备上会以多进程的方式运行基础服务,但是如果在资源受限的设备上(如下图),Chrome 会将很多服务整合到一个进程中,从而节省内存占用。

img

页面加载中的进程

现在我们已经大致了解浏览器进程的发展过程,接下来结合我之前的《深入浏览器之页面加载中的计算机网络》一文,来详细看看页面加载中的进程间的配合。

首先我们回顾一下页面加载的流程:

img

然后再看一下下面这张图:

img

到这里,我们再回头看一下目前多进程架构中总结的各进程主要功能,这里值得我们注意的是渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么 Chrome 会让渲染进程运行在安全沙箱里,就是为了保证系统的安全。

先大概说一下,上图流程:

  1. 用户输入url并回车。浏览器进程根据用户输入的信息判断是搜索还是网址,如果是搜索内容,就将搜索内容+默认搜索引擎合成新的URL;如果用户输入的内容符合URL规则,浏览器进程就会根据URL协议,在这段内容上加上协议合成合法的URL。
  2. 浏览器导航栏显示loading状态,但是页面还是呈现之前的页面不变,因为新页面的响应数据还没有获得。
  3. 浏览器进程构建请求行信息,通过进程间通信(IPC)把url请求发送给网络进程
  4. 网络进程接收到url请求后检查本地缓存是否缓存了该请求资源,如果有则将该资源返回给浏览器进程
  5. 如果没有,网络进程向web服务器发起http请求(网络请求),请求流程如下:
    1. 进行DNS解析,获取服务器ip地址(先查找DNS缓存,再发起DNS网络请求)
    2. 利用ip地址和服务器建立tcp连接(TCP三次握手建立的连接并不是真实的物理连接,而是虚连接,连接的本质就是在客户端与服务端开辟本次连接所需要的资源(内存、进程等))
    3. 完成构建请求信息并发送请求(调用Socket利用TCP通过三次握手连接建立后,之前准备好的HTTP请求报文被送入发送队列,接下来就交给了TCP完成后续过程)
    4. 服务器响应后,网络进程接收响应头和响应信息,并解析响应内容
  6. 网络进程解析响应流程:
    1. 检查状态码,如果是301/302,则需要重定向,从Location自动中读取地址,重新进行第三步,如果是200,则继续处理请求。
    2. 检查响应类型Content-Type,如果是字节流类型,则将该请求提交给下载管理器,该导航流程结束,不再进行后续的渲染,如果是html等资源则将其转发给浏览器进程
  7. 浏览器进程接收到网络进程的响应头数据之后,检查当前url是否和之前打开的渲染进程根域名是否相同,如果相同,则复用原来的进程,如果不同,则开启新的渲染进程(这个文章后面还会展开说)。
  8. 渲染进程准备好后,浏览器进程发送CommitNavigation消息到渲染进程,发送CommitNavigation时会携带响应头、等基本信息。渲染进程接收到消息和网络进程建立传输数据的“管道”。
  9. 渲染进程接收完数据后,向浏览器进程发送“确认提交”。
  10. 浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏url、前进后退的历史状态、更新web页面。

那么下面我们再来详细解说一下上面的流程。

用户输入

当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload 事件的机会。

beforeunload 事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过 beforeunload 事件来取消导航,让浏览器不再执行任何后续工作。

当前页面没有监听 beforeunload 事件或者同意了继续后续流程,那么浏览器便进入下图的状态:

image-20211231152220297.png

从图中可以看出,当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换页面。因为需要等待提交文档阶段,页面内容才会被替换。

URL 请求过程

接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。那具体流程是怎样的呢?

首先,网络进程会根据强制缓存规则查找本地是否缓存了当前URL是否存在强缓存如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么再查找是否存在协商缓存信息,如果有把协商缓存信息写入请求头中,否则直接进入网络请求流程。关于协商缓存与强制缓存的详细过程,可以查看这里

img

接着请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。关于DNS解析也很好理解,由于 IP 地址具有不方便记忆并且不能显示地址组织的名称和性质等缺点,人们设计出了域名,并通过域名解析协议(DNS,Domain Name System)来将域名和 IP 地址相互映射,使人更方便地访问互联网,而不用去记住能够被机器直接读取的 IP 地址数串。所以域名解析就是找到URL背后对应的IP地址,让路由器知道到哪去找目标服务器。关于详细DNS解析过程,可以查看这里

img

如果请求协议是 HTTPS,那么还需要建立 TLS 连接。TLS连接就是在HTTP和TCP中间的协议,核心作用就是加密HTTP报文给TCP传输或者解密HTTP报文传给上层应用使用。关于HTTPS详情,可以查看这里

img

接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息

服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。

重定向

在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。

比如我们在终端中输入如下指令:

curl -I http://bilibili.com
复制代码

我们会获得一个如下响应报文:

HTTP/1.1 301 Moved Permanently
Server: Tengine
Date: Sat, 01 Jan 2022 12:30:21 GMT
Content-Type: text/html
Content-Length: 239
Connection: keep-alive
Location: https://www.bilibili.com/
复制代码

B站服务器会通过重定向的方式把所有 HTTP 请求转换为 HTTPS 请求。也就是说你使用 HTTP 向B站服务器请求时,服务器会返回一个包含有 301 或者 302 状态码响应头,并把响应头的 Location 字段中填上 HTTPS 的地址,这就是告诉了浏览器要重新导航到新的地址上。

那我们再试试直接访问Location中的HTTPS的网站是什么样的响应报文:

HTTP/2 200 
date: Sat, 01 Jan 2022 12:33:38 GMT
content-type: text/html; charset=utf-8
support: nantianmen
set-cookie: innersign=0; path=/; domain=.bilibili.com
set-cookie: buvid3=4BC19AF-7335-E979-74C3-AA00D1411DC017954infc; path=/; expires=Sun, 01 Jan 2023 12:33:37 GMT; domain=.bilibili.com
cache-control: no-cache
gear: 1
vary: Origin,Accept-Encoding
idc: shjd
expires: Sat, 01 Jan 2022 12:33:37 GMT
x-cache-webcdn: MISS from blzone07
x-cache-time: 0
x-origin-time: no-cache, must-revalidate, max-age=0, no-store
x-save-date: Sat, 01 Jan 2022 12:33:38 GMT
复制代码

从图中可以看出,服务器返回的响应头的状态码是 200,这是告诉浏览器一切正常,可以继续往下处理该请求了。

好了,以上是重定向内容的介绍。现在你应该理解了,在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。

协商缓存

前面我们还提到过,如果在浏览器根据URL查找缓存的过程中发现了协商缓存信息,那么请求时会带上相关头,返回的响应头会存在 304 的状态码,表明协商缓存生效,这时候浏览器还是会从缓存中获取资源。具体流程如下图所示:

img

响应数据类型处理

URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?

答案是 Content-TypeContent-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容

这里我们可以回头看一下上面B站的响应体,响应头中的 Content-type 字段的值是 text/html,这就是告诉浏览器,服务器返回的数据是 HTML 格式。还有我们正常请求后端的接口,很多是application/json类型的,这样的数据类型浏览器就会当成是普通数据由网络进程传递给渲染进程。再比如说application/octet-stream类型的,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。

所以,不同 Content-Type 的后续处理流程也截然不同。如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是 HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。

准备渲染进程

默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下,浏览器会让多个页面直接运行在同一个渲染进程中。

这里我找了一些网站,对比一下LeeCode和B站的任务管理器截图:

image-20220112200814407.png

image-20220112201006192.png

其中,LeeCode的三个网页都是同属于一个进程66661的,而B站的三个网页分别都有各自的进程。

那什么情况下多个页面会同时运行在一个渲染进程中呢?

Chrome 的默认策略是,每个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫 process-per-site-instance

这个策略的依据具体来说就是以下两点:

  1. 如果两个标签页都位于同一个浏览上下文组,且属于同一站点,那么这两个标签页会被浏览器分配到同一个渲染进程中。
  2. 如果这两个条件不能同时满足,那么这两个标签页会分别使用不同的渲染进程来渲染。

同一站点

首先了解下什么是同一站点。具体地讲,我们将“同一站点”定义为根域名加上协议,还包含了该根域名下的所有子域名和不同的端口,比如下面这三个:

https://space.bilibili.com
https://www.bilibili.com
https://www.bilibili.com:443
复制代码

它们都是属于同一站点,因为它们的协议都是 HTTPS,而且根域名也都是 bilibili.com。

浏览组上下文

再看看浏览组上下文,要搞清楚这个,我们要先来分析下浏览器标签页之间的连接关系。

我们知道,浏览器标签页之间是可以通过 JavaScript 脚本来连接的,通常情况下有如下几种连接方式:

第一种是通过标签来和新标签建立连接:

<a  href="https://baidu.com" target="_blank" class="">百度</a>
复制代码

点击该链接会打开新的百度标签页,新标签页中的 window.opener 的值就是指向原来标签页中的 window,这样就可以在新的百度标签页中通过 opener 来操作上个标签页了。这样我们可以说,这两个标签页是有连接的。

另外,还可以通过 JavaScript 中的 window.open 方法来和新标签页建立连接,演示代码如下所示:

new_window = window.open("https://baidu.com")
复制代码

通过上面这种方式,可以在当前标签页中通过 new_window 来控制新标签页,还可以在新标签页中通过 window.opener 来控制当前标签页。所以我们也可以说,如果从 A 标签页中通过 window.open 的方式打开 B 标签页,那么 A 和 B 标签页也是有连接的。

其实通过上述两种方式打开的新标签页,不论这两个标签页是否属于同一站点,他们之间都能通过 opener 来建立连接,所以他们之间是有联系的。在 WhatWG 规范中,把这一类具有相互连接关系的标签页称为浏览上下文组 ( browsing context group)。

既然提到浏览上下文组,接下来就是浏览上下文,通常情况下,我们把一个标签页所包含的内容,诸如 window 对象,历史记录,滚动条位置等信息称为浏览上下文。这些通过脚本相互连接起来的浏览上下文就是浏览上下文组。

也就是说,如果在A的标签页中,通过链接打开了多个新的标签页,不管这几个新的标签页是否是同一站点,他们都和A的标签页构成了浏览上下文组,因为这些标签页中的 opener 都指向了A标签页。

noopener 和 noreferrer

了解完前面的知识,我们再看一下LeeCode的任务进程,三个页面确实是同一个进程,符合我们上面分析的结果。但是看下B站的三个标签页,却使用了三个不同的进程,这和我们预期的完全不同。

其实问题也很好解释,默认的 a 标签链接,如果设置 target=_blank时,则在新窗口能够通过全局对象的 opener 属性拿到原 tab 的引用,此时可能会引发黑客攻击等危险。

具体来说就是:

  • 你自己的网页 A 有个链接是打开另外一个三方地址 B
  • B 网页通过 window.opener 获取到 A 网页的 window 对象, 进而可以使得 A 页面跳转到一个钓鱼页面 window.opener.location.href ="abc.com", 用户没注意地址发生了跳转, 在该页面输入了用户名密码后则发生信息泄露

解决这个问题的方式也很简单,就是在 a 标签上加上ref属性noopener 和 noreferrer

引入 rel="noopener" 属性, 这样新打开的页面便获取不到来源页面的 window 对象了, 此时 window.opener 的值是 null

noopener 类似, 设置了 rel="noreferrer" 后新开页面也无法获取来源页面的 window 以进行攻击, 同时, 新开页面中还无法获取 document.referrer 信息, 该信息包含了来源页面的地址。

通常 noopenernoreferrer 会同时设置, rel="noopener noreferrer"。这是考虑到兼容性, 因为一些老旧浏览器不支持 noopener

iframe

其实我们都能想到还有一个最特殊的情况,就是iframe标签。简单总结就是,如果标签页中的 iframe 和标签页是同一站点,并且有连接关系,那么标签页依然会和当前标签页运行在同一个渲染进程中,如果 iframe 和标签页不属于同一站点,那么 iframe 会运行在单独的渲染进程中。

用一张图来分析就是:

img

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

提交文档

所谓提交文档,就是指浏览器进程将网络进程接收到的 HTML 数据提交给渲染进程,具体流程是这样的:

  • 首先当浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息;
  • 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;
  • 管道建立完成后,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传给 HTML 解析器,解析器动态接收字节流,并将其解析为 DOM。
  • 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程;
  • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。

所以这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。

渲染阶段

一旦文档被提交,渲染进程便开始页面解析和子资源加载了。 为了能更好地理解下文,你可以先结合下图快速抓住 HTML、CSS 和 JavaScript 的含义:

img

由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做渲染流水线,其大致流程如下图所示:

img

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

过程很多,但是我们都可以从下面三个方面去理解每一个阶段:

  • 开始每个子阶段都有其输入的内容
  • 然后每个子阶段有其处理过程
  • 最终每个子阶段会生成输出内容

理解了这三部分内容,能让你更加清晰地理解每个子阶段。

构建DOM树

因为浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。何为DOM树,可以参考下图加以理解:

img

从图中可以看出,构建 DOM 树的输入内容是一个非常简单的 HTML 文件,然后经由 HTML 解析器解析,最终输出树状结构的 DOM。同样我们可以在控制台中的Console标签中输入document后回车,这样我们也能看到一个完整的DOM树。

DOM 和 HTML 内容几乎是一样的,但是和 HTML 不同的是,DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。

样式计算

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式,这个阶段大体可分为三步来完成。

把 CSS 转换为浏览器能够理解的结构

img

从图中可以看出,CSS 样式来源主要有三种:

  • 通过 link 引用的外部 CSS 文件
  • <style>标记内的 CSS
  • 元素的 style 属性内嵌的 CSS

和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets

为了加深理解,你可以在 Chrome 控制台中查看其结构,只需要在控制台中输入 document.styleSheets,然后就看到如下图所示的结构:

img

从图中可以看出,这个样式表包含了很多种样式,已经把那三种来源的样式都包含进去了,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。

转换样式表中的属性值,使其标准化

现在我们已经把现有的 CSS 文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作

要理解什么是属性值标准化,你可以看下面这样一段 CSS 文本:

body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
复制代码

可以看到上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。下图就是标准化的结果:

img

计算出 DOM 树中每个节点的具体样式

现在样式的属性已被标准化了,接下来就需要计算 DOM 树中每个节点的样式属性了,这就涉及到 CSS 的继承规则和层叠规则了。

首先是 CSS 继承。CSS 继承就是每个 DOM 节点都包含有父节点的样式。 这么说可能有点抽象,我们可以结合具体例子,看下面这样一张样式表是如何应用到 DOM 节点上的。

body { font-size: 20px }
p {color:blue;}
span  {display: none}
div {font-weight: bold;color:red}
div  p {color:green;}
复制代码

这张样式表最终应用到 DOM 节点的效果如下图所示:

img

从图中可以看出,所有子节点都继承了父节点样式。比如 body 节点的 font-size 属性是 20,那 body 节点下面的所有节点的 font-size 都等于 20。

这里需要特别提下 UserAgent 样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是 UserAgent 样式。

img

样式计算过程中的第二个规则是样式层叠。层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。

总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。

布局阶段

现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做布局。

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算

创建布局树

DOM 树还含有很多不可见的元素,比如 head 标签,还有使用了 display:none 属性的元素。所以在显示之前,还要额外地构建一棵只包含可见元素布局树。

我们结合下图来看看布局树的构造过程:

img

从上图可以看出,DOM 树中所有不可见的节点都没有包含到布局树中。

为了构建布局树,浏览器大体上完成了下面这些工作:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中
  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树
布局计算

现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。

针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

分层

到这里我们先总结一下前三个阶段:

在 HTML 页面内容被提交给渲染引擎之后,渲染引擎首先将 HTML 解析为浏览器可以理解的 DOM;然后根据 CSS 样式表,计算出 DOM 树所有节点的样式;接着又计算每个元素的几何坐标位置,并将这些信息保存在布局树中。

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?

答案依然是否定的。

因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。 如果你熟悉 PS,相信你会很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。

要想直观地理解什么是图层,你可以打开 Chrome 的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况。

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:

img

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

第一点,拥有层叠上下文属性的元素会被提升为单独的一层。

页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。你可以结合下图来直观感受下:

img

从图中可以看出,明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。

第二点,需要剪裁(clip)的地方也会被创建为图层。

不过首先你需要了解什么是剪裁,结合下面的 HTML 代码:


<style>
      div {
            width: 200;
            height: 200;
            overflow:auto;
            background: gray;
        } 
</style>
<body>
    <div >
        <p>所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:</p>
        <p>从上图我们可以看到,document层上有A和B层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
        <p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p> 
    </div>
</body>
复制代码

在这里我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域,下图是运行时的执行结果:

img

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。你可以参考下图:

img

所以说,元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的。

试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?

通常,你会把你的绘制操作分解为三步:

  1. 绘制蓝色背景
  2. 在中间绘制一个红色的圆
  3. 再在圆上绘制绿色三角形

渲染引擎实现图层的绘制与之类似,会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

img

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。

你也可以打开“开发者工具”的Layers标签,选择document层,来实际体验下绘制列表,如下图所示:

img

在该图中,区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。

栅格化操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系:

img

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?

那我们得先来看看什么是视口,屏幕上页面的可见区域就叫视口(ViewPort),通俗点来说就是,一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口。

在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:

img

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。 渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

img

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:

img

从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令DrawQuad,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。

渲染流水线大总结

好了,我们现在已经分析完了整个渲染流程,从 HTML 到 DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:

img

结合上图,一个完整的渲染流程大致可总结为如下:

  1. 渲染进程将 HTML 内容转换为能够读懂的 DOM树 结构。
  2. 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
  8. 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。

渲染阶段与重排、重绘、合成

有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念——“重排”、“重绘”和“合成”。理解了这三个概念对于你后续 Web 的性能优化会有很大帮助。

重排

如果你通过 JavaScript 或者 CSS 修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的。 具体可以参考下图:

img

重绘

如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。 具体可以参考下图:

img

合成

如果更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图:

img

在上图中,我们使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

总结

综上所述,我们可以得出,在优化 Web 性能的方法中,减少重绘、重排是一种很好的优化方式。这里总结一些减少重排重绘的方法:

  1. 使用 class 操作样式,而不是频繁操作 style
  2. 避免使用 table 布局
  3. 批量 dom 操作,例如 createDocumentFragment,或者使用框架,例如 React
  4. window resize 事件防抖
  5. dom 属性的读写要分离
  6. will-change: transform 做优化

渲染进程中的线程

前面我们讲到了每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是消息队列和事件循环系统

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

从最简单的场景讲起,如果存在一系列任务代码需要执行,那我们需要把所有任务代码按照顺序写进主线程里,等线程执行时,这些任务会按照顺序在线程中依次被执行;等所有任务执行完成之后,线程会自动退出。如图所示:

img

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

但并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的。比如在线程执行过程中,又接收到了一个新的任务要求计算10+2,那上面那种方式就无法处理这种情况了。

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制。主要改动有如下两点:

  • 第一点引入了循环机制,具体实现方式是在线程语句最后添加了一个 for 循环语句,线程会一直循环执行。
  • 第二点是引入了事件,可以在线程运行过程中,等待用户输入的数字,等待过程中线程处于暂停状态,一旦接收到用户输入的信息,那么线程会被激活,然后执行相加运算,最后输出结果。

img

处理其他线程发送过来的任务

上面我们改进了线程的执行方式,引入了事件循环机制,可以让其在执行过程中接受新的任务。不过在第二版的线程模型中,所有的任务都是来自于线程内部的,如果另外一个线程想让主线程执行一个任务,利用第二版的线程模型是无法做到的。

那下面我们就来看看其他线程是如何发送消息给渲染主线程的,具体形式你可以参考下图:

img

从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行 DOM 解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的 JS 脚本来处理该点击事件。

那么如何设计好一个线程模型,能让其能够接收其他线程发送的消息呢?

一个通用模式是使用消息队列。在解释如何实现之前,我们先说说什么是消息队列,可以参考下图:

img

从图中可以看出,消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

有了队列之后,我们就可以继续改造线程模型了,改造方案如下图所示:

img

从上图可以看出,我们的改造可以分为下面三个步骤:

  1. 添加一个消息队列;
  2. IO 线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

处理其他进程发送过来的任务

通过使用消息队列,我们实现了线程之间的消息通信。在 Chrome 中,跨进程之间的任务也是频繁发生的,那么如何处理其他进程发送过来的任务?你可以参考下图:

img

从图中可以看出,渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程,后续的步骤就和前面讲解的“处理其他线程发送的任务”一样了,这里就不再重复了。

消息队列中的任务类型

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

以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,我们还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

简述单线程的缺点与解决方案

页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决。

第一个问题是如何处理高优先级的任务。

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

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降

如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性

针对这种情况,微任务就应运而生了。通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。关于宏任务微任务详细介绍可以参考这篇文章

第二个是如何解决单个任务执行时长过久的问题。

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

img

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

定时器与AJAX

前面我们了解了页面中的事件和消息队列,知道了浏览器页面是由消息队列和事件循环系统来驱动的。接下来我们再说说两个特殊的API:setTimeoutXMLHttpRequest。这两个 WebAPI 是两种不同类型的应用,比较典型,并且在 JS 中的使用频率非常高。现在仔细想想,它们的运行机制似乎不符合我们上述的消息队列。

接下来我们来简单了解一下它们的工作原理。

setTimeout

先简单介绍一下setTimeout,它就是一个定时器,用来指定某个函数在多少毫秒之后执行。它会返回一个整数,表示定时器的编号,同时还可以通过该编号来取消这个定时器。

要了解定时器的工作原理,就得先来回顾下之前讲的事件循环系统,我们知道渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。

所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,我们不能将定时器的回调函数直接添加到消息队列中。

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。 所以当通过 JS 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

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

定时器存在的一些问题

  1. 如果当前任务执行时间过久,会影响定时器任务的执行

    通过 setTimeout 设置回调时间为0的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成,如果是个很长的循环代码,那么当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间。

  2. 如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

    在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。

  3. 未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

    除了前面的 4 毫秒延迟,还有一个很容易被忽略的地方,那就是未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

  4. 延时执行时间有最大值

    除了要了解定时器的回调函数时间比实际设定值要延后之外,还有一点需要注意下,那就是 Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。

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

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

XMLHttpRequest

XMLHttpRequest 出现之前,如果服务器数据有更新,依然需要重新刷新整个页面。而 XMLHttpRequest 提供了从 Web 服务器获取数据的能力,如果你想要更新某条数据,只需要通过 XMLHttpRequest 请求服务器提供的接口,就可以获取到服务器的数据,然后再操作 DOM 来更新页面内容,整个过程只需要更新网页的一部分就可以了,而不用像之前那样还得刷新整个页面,这样既有效率又不会打扰到用户。

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

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

回调函数在主函数返回之前执行的,我们把这个回调过程称为同步回调。

回调函数没有在主函数返回之前执行,而是在主函数外部执行的过程称为异步回调。

理解了什么是同步回调和异步回调,接下来我们就来分析 XMLHttpRequest 背后的实现机制,具体工作过程你可以参考下图:

img

这是 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 来请求数据的代码,再结合上面的流程图,我们可以分析下这段代码是怎么执行的。

第一步:创建 XMLHttpRequest 对象。

当执行到let xhr = new XMLHttpRequest()后,JS 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。

第二步:为 xhr 对象注册回调函数。

因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。

XMLHttpRequest 的回调函数主要有下面几种:

  • ontimeout:用来监控超时请求,如果后台请求超时了,该函数会被调用
  • onerror:用来监控出错信息,如果后台请求出错了,该函数会被调用
  • onreadystatechange:用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等

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

注册好回调事件之后,接下来就需要配置基础的请求信息了,首先要通过 open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。

第四步:发起请求。

一切准备就绪之后,就可以调用xhr.send来发起网络请求了。你可以对照上面那张请求流程图,可以看到:

渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

总结

好了,到这里本篇文章也接近了尾声,简单回顾一下本篇文章所讲的东西:

  • 介绍了进程与线程的概念
  • 介绍了单进程浏览器与多进程浏览器
  • 重点分析了页面加载中各个进程之间的配合与渲染进程做了哪些事
  • 从渲染进程的角度分析了重排、重绘、合成的区别
  • 分析了渲染进程中的线程
  • 简述了定时器与AJAX的实现逻辑

最后再次感谢李兵老师的课程《浏览器工作原理与实践》,这里也强烈推荐一下大家,如果想了解更多浏览器底层的知识,建议去学习一下李兵老师的课程,真的非常地棒。那么,我们下一篇文章见。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改