浏览器的架构基础知识
线程和进程的概念
-
进程是 CPU 资源分配的最小单位, 或者说一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。
-
线程是 CPU 调度的最小单位。
举个例子:
对于操作系统来说,一个任务就是一个进程,比如打开一个浏览器就是启动了一个浏览器进程,打开一个 Word 就启动了一个 Word 进程。有些进程同时不止做一件事,比如 Word,它同时可以进行打字、拼写检查、打印等事情。在一个进程内部,要同时做多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
线程和进程的关系
-
进程中的任意一线程执行出错,都会导致整个进程的崩溃。
-
线程之间共享进程中的数据。
-
当一个进程关闭之后,操作系统会回收进程所占用的内存。
-
进程之间的内容相互隔离。
浏览器的多进程架构
-
浏览器进程: 负责界面展示,用户交互,子进程管理,文件存取等。
-
渲染进程: 核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程(特殊情况下面也会介绍到)。
-
GPU 进程: GPU 的使用初衷是为了实现 3D CSS 的效果。
-
网络进程: 负责网络资源加载。
-
插件进程: 主要是负责插件的运行,每一个插件一个插件进程。
Chrome分配渲染进程策略
在大多数情况下,如果打开一个标签页,那么浏览器会默认为其创建一个渲染进程。但是我们知道,如果两个标签页属于同一站点 (相同协议、相同根域名),那它们有可能共用同一个渲染进程。
为什么说是有可能呢?我们需要了解浏览上下文组的概念
浏览上下文组
如果浏览器的两个标签页是通过a标签或者js脚本的形式相互打开的,那这2个标签页我们可以说是有连接关系的。
<a href="xxx" target="_black" class=""> 打开新标签 </a>
window.open(xxx)
如果通过上述两种方式打开的新标签页,不论这两个标签页是否属于同一站点,他们之间都能通过 opener
来建立连接,所以他们之间是有联系的。这一类有连接关系的标签页,称为浏览上下文组。
但是这里也有一种例外的情况
<a ref="noopener noopener" href="xxx" target="_black" class=""> 打开新标签 </a>
a 链接的 rel 属性值都使用了 noopener
和 noreferrer
,这个时候打开的新标签页不属于同一个浏览上下文中。
通常,将 noopener
的值引入 rel 属性中,就是告诉浏览器通过这个链接打开的标签页中的 opener
值设置为 null
,引入 noreferrer
是告诉浏览器,新打开的标签页不要有引用关系。
那什么是浏览上下文呢?
其实浏览上下文就是一个标签页包含的内容,如window对象、历史记录、滚动条位置等。
Chrome分配渲染进程策略
了解了浏览上下文组的概念,那我们就可以深入了解下Chrome分配渲染进程的策略。
-
如果多个标签页都位于同一个浏览上下文组,且属于同一站点,那么这几个标签页会被浏览器分配到同一个渲染进程中。
-
如果这两个条件不能同时满足,那么多个标签页会分别使用不同的渲染进程来渲染。
Chrome中iframe分配渲染进程策略
还有一种情况是标签页中存在iframe,Chrome也会按照一定的规则为iframe分配渲染进程
如果标签页中的 iframe 和标签页是同一站点,并且有连接关系,那么标签页依然会和当前标签页运行在同一个渲染进程中,如果 iframe 和标签页不属于同一站点,那么 iframe 会运行在单独的渲染进程中。
V8工作原理
V8 如何执行JS代码
大致流程是:
V8 依据 JavaScript 代码通过词法分析、语法分析生成 AST 和执行上下文,再基于 AST 生成字节码,然后通过解释器执行字节码,通过编译器来优化编译字节码。
词法分析
词法分析是将一行行的源码拆解成一个个 token。所谓token指的是语法上不可能再分的、最小的单个字符或字符串。
如var name = 'chenying'
会被拆分成 var
、name
、=
、chenying
.
语法分析
语法分析是将上一步生成的 token 数据,根据语法规则转为 AST。但如果解析时候出现语法错误,这一步就会终止,并抛出一个“语法错误”。
有了AST, 就可以生成执行上下文了。
字节码
字节码就是介于 AST 和机器码之间的一种代码,但是字节码的占用空间远比机器码小,并且字节码需要通过解释器将其转换为机器码后才能执行。
解释器
解释器会根据AST生成字节码,并解释执行字节码。
编译器
如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(比如一段代码被重复执行多次,这种就称为热点代码),那么后台的编译器 TurboFan
就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
数据在内存中的存放
在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。栈空间就是调用栈,是用来存储执行上下文的,堆空间用来存储对象的。
不过需要注意的是,闭包存在堆空间里面。
那为什么一定要分“堆”和“栈”两个存储空间呢?
因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。
垃圾回收
栈中的数据是如何回收的
JavaScript 引擎会通过向下移动 ESP(记录当前执行状态的指针) 来销毁该函数保存在栈中的执行上下文。
堆中的数据是如何回收的
首先,V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。
对于这两块区域,V8 分别使用两个不同的垃圾回收器:
副垃圾回收器,负责新生代的垃圾回收。
主垃圾回收器,负责老生代的垃圾回收。
副垃圾回收器
副垃圾回收器主要使用Scavenge
算法
-
把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。
-
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
-
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
-
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
这里还需要补充一个对象晋升策略
经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器
主垃圾回收器主要使用 标记 - 清除 和 标记 - 整理 来回收.
标记 - 清除
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
标记 - 整理
过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
不论什么类型的垃圾回收器,它们都有一套共同的执行流程。
- 标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象
- 回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
- 做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。
全停顿
- V8 是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿
- 为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记算法。
输入url到页面展示
这里就不展开每一个细点来讲了,因为每一部分在面试的时候都会被深挖(DNS解析过程、TCP连接、HTTPS、合成、绘制、光栅化等)。
1. 用户输入(浏览器进程处理的)
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。
- 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。
- 如果判断输入内容符合 URL 规则,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL。
- 然后用户按下回车会发现,浏览器进入loading状态,但是页面并没有马上被替换,还是老的页面。
2. URL 请求过程
-
浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。
-
首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。
-
请求前的第一步是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。
-
如果请求协议是 HTTPS,那么还需要建立 TLS 连接。
-
IP 地址进行寻址排队,因为浏览器一次只能发送6个http请求。
-
接下来就是利用 IP 地址和服务器建立 TCP 连接。
-
然后发起http请求,等待服务器响应结果.
-
如果浏览器返回301、302 这2个状态码,浏览器会跳转到新的地址继续导航。如果浏览器返回304,则去查询浏览器缓存进行返回。如果浏览器返回200,那么表示浏览器可以继续处理该请求。
-
如果发现浏览器响应数据的
Content-Type
为text/html
,这个时候浏览器会将下载的资源交给渲染进程处理。
3. 准备渲染进程
可以移步到 Chrome分配渲染进程策略
4. 提交文档
之前说浏览器在用户敲回车的时候,还是老的界面,那是什么时候开始出现新的界面的加载呢?
这涉及到一个词叫 提交文档 。首先文档是指 URL 请求的响应体数据。
-
“提交文档”的消息是由浏览器进程发出的,渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”。
-
等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程。
-
浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
5. 渲染阶段
1、 浏览器无法直接使用HTML,需要将HTML转化为DOM树
在渲染引擎内部,有一个叫HTML 解析器(HTMLParser)的模块,它的职责就是负责将HTML 字节流转换为 DOM 结构。
而且HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
前面讲到,如果content-type
的类型为“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。然后网络进程和渲染进程之间会建立一个共享数据的管道,一边网络进程一直往管道里面放数据,渲染进程一直接收数据,而渲染进程中就是HTML解析器进行接受字节流的。
字节流转换为 DOM 需要三个阶段:
第一步是通过分词器将字节流转换为 Token:
首先解析HTML的时候,通过分词器先将字节流转换为一个个 Token,分为Tag Token
和文本 Token
。
后续的第二个和第三个阶段是同步进行的,需要将 Token 解析为 DOM 节点,并将DOM 节点添加到 DOM 树中: 首先HTML 解析器维护了一个Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:
-
HTML 解析器开始工作时,会默认创建了一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底。
-
如果压入到栈中的是StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。
-
如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。
-
如果分词器解析出来的是EndTag 标签,比如是 EndTag div,HTML 解析器会查看Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成
2、浏览器无法解析CSS样式,需要将CSS样式解析成styleSheets
,计算出DOM树中每个节点的具体样式.
3、 创建渲染树,将DOM树中的可见节点,添加到渲染树中,并计算节点渲染到页面的坐标位置(布局信息)
4、 通过渲染树,进行分层生成图层
什么样的元素会被分层一层呢?
4.1、拥有层叠上下文属性的元素会被提升为单独的一层。
4.2、需要剪裁(clip)的地方也会被创建为图层。
5、 通过图层树,把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表
6、 把待绘制列表交给合成进程,合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
7、 合成线程发送绘制图块命令DrawQuad给浏览器进程。
8、 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
浏览器的缓存
浏览器缓存机制
浏览器中缓存可分为强缓存和协商缓存,其中缓存机制如下:
-
浏览器在加载资源时,先根据这个资源的一些header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器(强缓存都是200状态码)。
-
当强缓存没有命中的时候,浏览器会像服务起发请求,然后服务器端依据一些header判断是否命中了协商缓存,如果命中了协商缓存,则服务器会返回304状态码,否则会返回该资源。然后浏览器判断如果状态码为304,则直接从自己的缓存中读取资源。
-
如果强缓存和协商缓存都没有命中的时候,浏览器会直接从服务起加载资源。
强缓存:Expires、Cache-Control
强缓存主要通过设置Expires
或者Cache-Control
来实现的,它们都用来表示资源在客户端缓存的有效期。并且
Expires
和Cache-Control
可以同时设置,且Cache-Control
优先级更高。强缓存会把资源放到memory cache
和 disk cache
中。
Expires
Expires
是HTTP 1.0提出的一个表示资源过期时间的header,它标识一个绝对时间,由服务器返回,用GMT格式的字符串表示,适用于低版本浏览器。
Expires
的缓存过程是:
-
浏览器第一次像服务起请求一个资源的时候,服务器会在响应头添加
Expires
字段,表示该资源的过期时间 -
浏览器将该资源和响应头等数据缓存起来
-
浏览器再一次请求这个资源的时候,会先在本地缓存查找是否有这个资源,如果找到有,则用该资源缓存的
Expires
字段的时间和当前请求的时间比较,如果当前请求时间比设置的Expires
时间早,则命中缓存。 -
如果没有命中缓存,则像服务器请求该资源,并且缓存新的资源和对应的响应头。
Cache-Control
Cache-Control
可以通过max-age
字段设置一个相对时间,单位是秒,当然还有一些其它的选项如:
-
max-age
: 强缓存的有效时间,单位是秒 -
no-cache
: 使用协商缓存,先与服务起确认返回的响应是否更改 -
no-store
: 直接禁止浏览器缓存。 -
public
: 表明响应可以被任何对象缓存 -
private
: 表明响应只可以被单个用户缓存
Cache-Control
的缓存过程是:
-
浏览器第一次像服务起请求一个资源的时候,服务器会在响应头添加
Cache-Control
字段,表示该资源的过期时间 -
浏览器将该资源和响应头等数据缓存起来
-
浏览器再一次请求这个资源的时候,会先在本地缓存查找是否有这个资源,如果找到有,则根据它第一次的请求时间和
Cache-Control
设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,如果请求时间在过期时间之前,就能命中缓存。 -
如果没有命中缓存,则像服务器请求该资源,并且缓存新的资源和对应的响应头。
协商缓存:Last-Modified、Etag
客户端第一次请求数据时,服务器将缓存的标识与数据一起返回给客户端,客户端拿到缓存的标识。当客户端再次发起那个请求的时候带上这个缓存的标识,服务器根据标识进行判断,如果标识匹配上就返回304,通知客户端可以使用缓存数据,如果匹配不上则返回新的数据和缓存的标识。
而协商缓存的标识就是【Last-Modified
,If-Modified-Since
】和【ETag
、If-None-Match
】。
Last-Modified 和 if-modified-since
Last-Modified
和if-modified-since
的缓存过程是:
-
浏览器第一次像服务起请求一个资源的时候,服务器会在响应头添加
Last-Modified
字段,表示该资源的最后修改时间 -
浏览器将该资源和响应头等数据缓存起来
-
浏览器再一次请求这个资源的时候,会在请求头添加
if-modified-since
字段,为上一次得到的Last-Modified
-
服务器根据浏览器传过来
If-Modified-Since
和资源在服务器上的最后修改时间判断资源是否相同,如果相同表示资源没有修改,则返回304,否则返回最新资源 -
浏览器判断响应码,如果是304则取缓存的资源,否则取服务器返回的新资源,并替换本地缓存。
Last-Modified
和if-modified-since
的缺陷:
- 精确的时间是秒, 也就是说以1秒以内进行变化的话是监听不到的
- 存在一种很极端的情况,如果一直修改文件,但是到最后文件内容没有变但是修改时间变了,也不会走缓存
Etag 和 if-None-Match
-
浏览器第一次像服务起请求一个资源的时候,服务器会在响应头添加
Etag
字段,表示该资源的字符串标识 -
浏览器将该资源和响应头等数据缓存起来
-
浏览器再一次请求这个资源的时候,会在请求头添加
if-None-Match
字段,为上一次得到的Etag
-
服务器根据浏览器传过来
if-None-Match
和资源在服务器上用相同算法算出资源的Etag
进行比较,如果相同表示资源没有修改,则返回304,否则返回最新资源 -
浏览器判断响应码,如果是304则取缓存的资源,否则取服务器返回的新资源,并替换本地缓存。
浏览器缓存位置
从缓存位置上来说分为四种,并且各自有优先级
- Service Worker
- Memory Cache
- Disk Cache
- Push Cache
Service Worker
Service Worker
是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS(Service Worker
中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全)。
Memory Cache
Memory Cache
也就是内存中的缓存,读取速度快,但是缓存的可持续性短,会随进程的释放而释放,如关闭Tab页。
Disk Cache
Disk Cache
也就是存储在硬盘中的缓存,读取速度慢点,但是容量大和缓存的可持续性长。
Push Cache
Push Cache
是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。
浏览器的EventLoop
常见的宏任务和微任务
宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI 交互事件、postMessage、MessageChannel、requestAnimationFrame等。
微任务:Promise.then、 mutationObserver等。
浏览器EventLoop
这里拿一张姜老师的好图
按照流程图大致梳理一下eventloop
的过程如下
- 首先执行一个宏任务(执行栈中的代码)
- 执行过程中如果遇到微任务,立刻就将它添加到微任务队列
- 执行过程中如果遇到宏任务,等待宏任务时间到达或者成功之后,将回调添加到宏任务队列
- 当前宏任务执行完毕之后,依次清空微任务队列中的任务
- GUI线程接管渲染
- 从宏任务队列中取一个任务置于执行栈,依次循环
我们可以通过接下来的代码来加深对这个流程的了解
<script>
document.body.style.background = "red";
console.log(1);
Promise.resolve().then(() => {
console.log(2);
document.body.style.background = "yellow";
})
console.log(3);
</script>
我们打开控制台可以看到输出结果为132, 并且页面一直是黄色。
按照上面总结的流程我们梳理下这块代码:
- 首先执行一个宏任务(执行栈中的代码)-> 输出1、3
- 中途遇到微任务(Promise.resolve),立刻添加到微任务队列
- 宏任务执行完之后,清空微任务队列 -> 输出2
- 当前微任务队列执行完之后,触发GUI渲染 -> 所以页面变成黄色而不是出现红变黄的情况
我们把代码修改下
<script>
document.body.style.background = "red";
console.log(1);
setTimeout(() => {
console.log(2);
document.body.style.background = "yellow";
}, 0)
console.log(3);
</script>
控制台输出还是132,但是我们可以看到有一个红色变黄色的过程。
按照上面总结的流程我们梳理下这块代码:
- 首先执行一个宏任务(执行栈中的代码)-> 输出1、3
- 中途遇到宏任务(setTimeout),等待宏任务时间到达或者成功之后,将回调添加到宏任务队列
- 宏任务执行完之后,因为微任务队列没有任务,所以直接触发GUI渲染 -> 页面变红
- 从宏任务队列中取出一个宏任务(setTimeout的回调),再次按流程执行 -> 页面变黄
再看个代码
<button id="button">点我</button>
<script>
button.addEventListener("click", () => {
console.log("listener1");
Promise.resolve().then(() => {
console.log("micro task1")
})
})
button.addEventListener("click", () => {
console.log("listener2");
Promise.resolve().then(() => {
console.log("micro task2")
})
})
</script>
点击按钮时候控制台输出listener1
,micro task1
,listener2
,micro task2
。
按照上面总结的流程我们梳理下这块代码:
- 首先执行一个宏任务(执行栈中的代码)
- 点击按钮,将监听的回调放到宏任务队列。
- 开启新一轮循环,取出宏任务队列的第一个任务执行 -> 输出
listener1
- 遇到微任务(micro task1), 将它放到微任务队列
- 清空微任务队列 -> 输出
micro task1
我们再修改下代码
button.addEventListener("click", () => {
console.log("listener1");
Promise.resolve().then(() => {
console.log("micro task1")
})
})
button.addEventListener("click", () => {
console.log("listener2");
Promise.resolve().then(() => {
console.log("micro task2")
})
})
button.click();
控制台输出listener1
,listener2
,micro task1
,micro task2
。
按照上面总结的流程我们梳理下这块代码:
- 首先执行一个宏任务(执行栈中的代码, 遇到
button.click()
,立即执行回调代码 - 执行第一个回调 -> 输出listener1
- 第一个回调遇到微任务(micro task1) -> 置于微任务队列
- 执行第二个回调 -> 输出listener2
- 第二个回调遇到微任务(micro task2) -> 置于微任务队列
- 宏任务执行完毕,清空微任务队列 -> 输出
micro task1
,micro task2
浏览器的存储
这里直接引用前端面试之道的图,总结超级到位