从Chrome出发认识浏览器原理
前言
对于浏览器原理,平时总是接触到零零碎碎的知识点,总是读到类似于这样的文章:
- 输入
URL到浏览器显示页面过程,发生了什么? - 浏览器的渲染过程?
- 回流是什么?重绘是什么?
读过很多浏览器相关的文章,但是对于浏览器原理依旧是模模糊糊,并没有构建起完整的知识体系。最近抽出时间完整的看了Mariko Kosaka关于浏览器原理的文章。
「下面是个人的总结与理解,各位大佬不喜勿喷,你们的点赞会是对我最大的鼓励和支持😊」
开始之前, 我们先简单区分一些概念问题。
CPU & GPU
- CPU(中央处理器): 解释计算机指令以及处理数据, 它可以串⾏地处理交给它的任务
- GPU(图形处理器): 单个
GPU核⼼只能处理⼀些简单的任务,不过它胜在数量多,单⽚GPU上会有很多很多的核⼼可以同时⼯作,并⾏计算能⼒⾮常强
--- GPU加速:利用的就是GPU数量多,并行计算能力强的特性
进程 & 线程
- 进程:可以看成是正在被执⾏的应⽤程序。
- 线程:跑在进程中的,一个进程里面可能有一个或者多个线程。这些线程可以执⾏任何⼀部分应⽤程序的代码
- 当启动一个应用程序时,操作系统会为这个程序创建一个进程,同时还为这个进程分配一个私有的内存空间,关闭这个进程时,这个程序对应的进程也会随之消失,进程对应的内存空间也会随之消失。
为什么很多应用程序都是采用多进程的方式来工作的?
这就是因为进程与进程之间是相互独立的,这样的话就算其中一个工作进程挂了,其他进程也不会受到影响,而且挂掉的进程可以重启。
一、浏览器中的多进程架构
一些关键进程的介绍:
1. 多进程架构的优点
1. 容错性
每个tab单独分配一个渲染进程,其中某一个tab崩溃时,你可以随时关闭这个tab并且其他tab不受影响。这就是为什么平时我们关闭掉一个卡住的tab标签页时,其他网页并不会受到影响。
可是如果所有的tab都跑 在同⼀个进程的话,它们就会有连带关系,⼀个挂全部挂。
2. 安全性和沙盒性
操作系统提供⽅法让你限制每个进程拥有的能力,所以浏览器可以让某些进程不具备某些特定的功能。
例如,由于tab渲染进程可能会处理来自用户的随机输⼊,所以Chrome限制了它们对系统文件随机读写的能力。
3. 每个进程拥有独立的、更大的内存空间
因为每个进程都会分配⼀块独⽴的内存空间, 所以理所当然的, 每个进程都会有更多的内存。
2. 多进程架构的缺点
其实上面已经提到了, 每个进程都会拥有自己独立的内存空间, 他们并不能像同⼀个进程中的线程⼀样共享内存空间。而⼀些基础的东西比如V8 Javascript引擎, 会在不同进程的内存空间中同时存在, 所以就消耗了不必要的内存。
这样看来,是不是其实上面所说的第三点也就并不能作为优点。
那在Chrome中是怎么针对这个内存问题做优化的呢?
3. 多进程架构内存优化
1.限制启动的进程数
当进程数目达到⼀定界限后, Chrome会将访问同⼀个网站的tab都放在⼀个进程里面跑
2.Chrome的服务化,节省更多空间
服务化:将和浏览器本身(Chrome)相关的部分拆分为⼀个个不同的服务,服务化之后,这些功能既可以放在不同的进程里面运行也可以合并为⼀个单独的进程运行
——> 这样做的主要原因是为了让Chrome在不同性能的硬件上有不同的表现:
-
当
Chrome运行在⼀些性能比较好的硬件时,浏览器进程相关的服务会被放在不同的进程运行以提高系统的稳定性。 -
相反如果硬件性能不好,这些服务就会被放在同⼀个进程里面执行来减少内存的占用。
二、Chrome中的安全优化
- 网站隔离 :为网站内不同站点的iframe分配⼀个独立的渲染进程
之前说过Chrome会为每个tab分配⼀个单独的渲染进程,可是如果⼀个tab只有⼀个进程的话,不同站点的iframe都会跑在这个进程里面,这也意味着它们会共享内存,这就有可能会破坏同源策略。而进程隔离是隔离网站最好最有效的办法了。
三、导航时发生了什么?
从浏览器进程出发。
前面说过,浏览器进程是负责主体部分的,包括导航栏、书签,提供存储功能等。
浏览器进程中有很多负责不同工作的线程:
- UI 线程:绘制浏览器顶部按钮和导航栏输入框等组件
(当你在导航栏⾥⾯输⼊⼀个
URL的时候,其实就是UI线程在处理你的输⼊) - 存储线程:控制文件读写
- 网络线程:处理网络堆栈以从
Internet接收数据的
下面我们来说说在导航栏输入时,经过了哪些具体的步骤
1. 解析输入类型
用户输入时,UI线程做的第一件事就是询问你:
“你输入的是一些搜索关键词,还是一个URL地址呢?”
因为导航的输入既可能是一个可以直接请求的域名,也可能是用户想搜索的关键词信息。所以在用户输入时,UI线程进行一系列的解析来判定是执行下列的哪种情况:
- 直接向用户输入的站点发起请求?
- 将用户输入发送给搜索引擎?
2. 开始导航
用户按下回车键时,UI线程会通知网络线程初始化一个网络请求来获取站点内容
这时Tab上的icon会展示一个提示资源正在加载中的旋转圈圈,此时网络线程就在处理一些诸如DNS解析、为请求建立TLS连接的操作
- 注意:如果这时网络线程收到服务器的
HTTP 301重定向响应,它就会告知UI线程进行重定向,然后它会再次发起⼀个新的网络请求。
3. 读取响应
1)对响应类型的判断
网络线程在收到HTTP响应主体时,在必要的情况下会先检查一下流的前几个字节以确定响应主体的具体媒体类型(MIME Type)
而响应主体的媒体类型一般可以通过HTTP头部的Content-Type来确定,不过Content-Type有时候会缺失或者是错误的,这种情况下浏览器要进行MIME类型嗅探来确定响应类型
2)对不同响应类型的处理
- 响应主体是一个
HTML文件:浏览器会将获取的响应数据交给渲染进程 - 响应主体是一个压缩文件或者其他类型的文件:浏览器会将响应数据交给下载管理器来处理
3)安全检查
网络线程在把内容交给渲染进程之前还会对内容做安全检查
- SafeBrowsing检查:如果请求的域名或者响应的内容和某个已知的病毒网站相匹配,网络线程会展示一个警告页面
- CORB检查:以确保敏感的跨站点数据不会进入渲染器进程
4. 查找一个渲染进程来绘制页面
- 网络线程在做完所有检查后并且能够确定浏览器应该导航到该请求的站点,它就会向
UI线程发送确认信息(告诉UI线程所有的数据都已经被准备好了) - UI线程在收到网络进程的确认后会为这个网站寻找一个渲染进程来渲染页面。
- Chrome里的一个优化点
第⼆步中,当UI线程发送URL链接给网络进程时,它其实已经知晓它们要被导航到哪个站点了。
所以在网络进程干活的时候,UI线程会主动地启动⼀个与网络请求并行的渲染器进程。如果⼀切顺利的话(没有重定向之类的东西出现),网络进程准备好数据后页面的渲染进程已经就准备好了,这就节省了新建渲染进程的时间。
不过如果发⽣诸如网站被重定向到不同站点的情况,刚刚那个渲染进程就不能被使⽤了,它会被摒弃,⼀个新的渲染进程会被启动。
5. 提交导航
- 到这⼀步的时候,数据和渲染进程都已经准备好了,浏览器进程会通过IPC告诉渲染进程去提交本次导航(
commit navigation)。 - 除此之外浏览器进程还会将刚刚接收到的响应数据流传递给对应的渲染进程,因此浏览器进程可以继续接收HTML数据。
- ⼀旦浏览器进程收到渲染线程的回复说导航已经被提交了,导航这个过程就结束了,文档的加载阶段(
document loading phase)会正式开始。
到了这个时候:
- 导航栏会被更新,安全指示符和站点设置会展示新页面相关的站点信息。
- 当前
tab的会话历史也会被更新,这样当你点击浏览器的前进和后退按钮也可以导航到刚刚导航完的页面。 - 为了方便你在关闭了
tab或窗口的时候还可以恢复当前tab和会话内容,当前的会话历史会被保存在磁盘上面。
6. 加载完成
当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。
一旦渲染进程完成渲染,它会通过IPC告知浏览器进程,然后UI线程就会停止导航栏上旋转的圈圈。
# 导航到不同的站点
上面讲述的是一个导航的过程,那么如果我们想去浏览另一个页面,浏览器会怎么做?
能够想到的是, 浏览器必然会重复⼀遍导航的步骤, 但是在这之前, 浏览器还有⼀些收尾⼯作要做
浏览器进程会向渲染进程确认是否需要处理beforeunload事件
beforeunload:可以在用户重新导航或者关闭当前tab时给用户展示⼀个“你确定要离开当前页面吗?”的⼆次确认弹框
浏览器进程之所以要在重新导航的时候和当前渲染进程确认的原因是,当前页面发⽣的⼀切(包括页面的JavaScript执⾏)是不受它控制而是受渲染进程控制,它不知道里面的具体情况。
- 注意以不要随便给⻚⾯添加
beforeunload事件监听,你定义的监听函数会在页面被重新导航的时候执行,因此这会增加重导航的时延。 beforeunload事件监听函数只有在⼗分必要的时候才能被添加,例如用户在页面上输入了数据, 并且这些数据会随着页面的消失而消失。
如果第二次导航是在页面内发起的, 比如页面内
Js执行了location.href=xxxx, 这时浏览器怎么做的?
- 渲染进程会自己先检查⼀个它有没有注册
beforeunload事件的监听函数,如果有的话就执行,执行完后发生的事情就和之前的情况没什么区别了,唯⼀的不同就是这次的导航请求是由渲染进程给浏览器进程发起的。
如果导航是到不同的站点呢?
- 会有另外⼀个渲染进程被启动来完成这次重导航,而当前的渲染进程会继续处理现在页面的⼀些收尾⼯作,例如
unload事件的监听函数执行。
# Service Worker
- 作用:更好地控制本地缓存的内容以及何时从网络获取新数据。如果开发者在
service worker里面设置了当前的页面内容从缓存中获取,当前页面的渲染就不需要重新发送网络请求了,这就大大加快了整个导航的过程。 - 本质:
Service Worker其实只是一些跑在渲染进程里面的JavaScript代码。
那么问题来了:
导航开始时,浏览器进程如何判断要导航的站点存不存在对应的service worker里的呢?
service worker在注册的时候,它的作用范围(scope)会被记录下来!
所以在导航开始的时候,网络进程会根据请求的域名在已经注册的service worker作用范围里面寻找有没有对应的service worker。如果有命中该URL的service worker,UI线程就会为这个service worker启动⼀个渲染进程来执行它的代码。也就是说Service worker既可能使用之前缓存的数据也可能发起新的网络请求。
# 导航预加载
在上面的例子中,你应该可以感受到如果启动的service worker最后还是决定发送网络请求的话(也就是在已经注册的service worker里没有找到对应的缓存),浏览器进程和渲染进程这⼀来⼀回的通信包括service worker启动的时间其实增加了页面导航的时延。
- 导航预加载:⼀种通过在
service worker启动的时候并行加载对应资源的方式来加快整个导航过程效率的技术。
预加载资源的请求头会有⼀些特殊的标志来让服务器决定是发送全新的内容给客⼾端还是只发送更新了的数据给客⼾端
四、浏览器的渲染流程?
渲染进程负责Tab里面的所有的事:主要任务是将HTML、CSS、JavaScript转化成页面内容
渲染进程具有多个线程
- 主线程
- 工作线程:在使用
web worker或者service worker时,相关代码由工作线程处理 - 合成器线程、内部光栅线程:用于高效流畅地渲染出页面内容
1. 构建
1. 构建DOM
上面提到过,在导航结束的时候,渲染进程会收到来自浏览器进程提交导航的消息,在这之后渲染进程就会开始接收HTML数据,同时主线程也会开始解析接收到的文本数据,并把它转化为⼀个DOM对象。
DOM对象既是浏览器对当前页面的内部表示,也是Web开发人员通过JavaScript与页面进⾏交互的数据结构以及API
- 至于是如何将
HTML文档解析成DOM对象是由HTML标准中定义的
不过在在web开发过程中,你可能从来没有遇到过浏览器在解析HTML的时候发生错误的情景。这是因为浏览器对HTML的错误容忍度很⼤。就比如缺失了一个闭合的</p>标签,浏览器也会自动将它补齐
2. 子资源加载
除了HTML文件,网站通常还有使用一些诸如图片、CSS样式以及JavaScript脚本等子资源,这些文件会从缓存或者网络中获取。
主线程会按照在构建DOM树时遇到各个资源的循序⼀个接着⼀个地发起网络请求,但是为了提升效率,浏览器会同时运行“预加载扫描”程序。
- 预加载扫描:如果 HTML 文档中有类似的标签
<img>,<link>,预加载扫描程序会在HTML解析器里面找到对应要获取的资源,并把这些要获取的资源告诉浏览器进程里面的网络线程。
3. JS资源加载
当HTML解析到<script>标签的时候,会阻塞HTML文档的的解析,等到JS代码加载完毕之后才会继续加载HTML
- 那为什么解析到
JS时候要阻塞HTML的加载呢?
因为script标签中的JavaScript可能会使⽤诸如document.write()这样的代码改变文档流(document)的形状,从而使整个DOM树的结构发⽣根本性的改变。所以HTML 解析器不得不等JavaScript执⾏完成之后才能继续对HTML⽂档流的解析工作。
- 如何不阻塞?
可以通过设置<script>标签的async和defer属性来控制异步加载
-
不设置:会阻碍 HTML 解析,只有下载好并执行完脚本才会继续解析HTML。
-
async:解析HTML过程中进行脚本的异步下载,下载完成后马上执行,执行时阻塞
-
defer:完全不会阻塞HTML的解析,HTML解析完成之后再执行脚本
2. 样式计算
主线程解析页面的CSS从而确认每一个DOM节点的计算样式。其中主线程是根据CSS选择器计算出每一个DOM元素的应该具备的具体样式。
3. 布局
完成样式计算之后,渲染进程知道了页面的具体文档结构以及每个节点对应的样式信息,但是这些信息还不能最终确定页面的样子
这是因为还不知道每个节点的几何信息
布局的具体流程:主线程会先遍历DOM树,根据DOM节点计算出节点的位置,加上之前的已经完成的计算样式,形成一颗布局树
-
布局树
布局树的每个节点会有它在页面上的 x , y 坐标以及盒子大小的具体信息。布局树和之前的
DOM树差不多,不同的是这棵树只有那些可见的节点信息。
如果⼀个节点被设置为了
display:none,这个节点就是不可见的就不会出现在布局树上(visibility:hidden的节点会出现在布局树)。同样的,如果⼀个伪元素节点有诸如::before{content:"Hi!"}样的内容,它会出现在布局上,而不存在于DOM树。
4. 绘画
完成了DOM节点的样式计算和布局之后,其实还是不足以渲染出页面的。
为什么呢?这是因为对于渲染来说也是有渲染顺序的,例如:如果页面上出现z-index属性,绘制元素的顺序就会影响页面的正确性
所以绘制过程中,主线程需要遍历布局树以确定绘制顺序。
- 流水线更新的性能问题
对于这种一步一步进行的渲染流水线式更新,流水线的每一步都要使用到前一步的的结果来生成新的数据,这就意味着如果某一步出错了,这一步后面的所有步骤就要被重新执行。如果布局树发生变化,则需要为文档的受影响部分重新生成绘制顺序。
5. 合成
完成上面的步骤之后,浏览器已经知道了文档结构、每个元素的样式、元素的几何信息、绘画顺序。那浏览器是怎么利用这些绘制出页面的呢?将以上的信息转化成显示器的像素的过程叫做光栅化。
—— 现代浏览器采用合成的方式,来展示整个页面。
- 合成
合成是一种将页面分成若干层,然后分别对他们进行光栅化,最后在一个单独的合成线程里面合并成一个页面的技术。
当用户滚动页面时,由于页面的每一个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果而已。页面的动画效果也是类似的,将页面上的层进行移动并构建出一个新的帧即可
1)分层
-
页面想要将某些部分提升成单独的层,可以使用
css中的will-cahnge属性(GPU加速的一种方式 —— 用于优化回流重绘) -
GPU加速也叫css3硬件加速 -
如果你为太多元素使用
css3硬件加速,会导致内存占用较大,会有性能问题
2)光栅化和合成
- 创建了层并确定了绘制顺序之后,主线程就会把信息交给合成线程。合成线程光栅化每一层。这个过程中,合成线程实际上会将页面分成一块一块发送到光栅线程
- 将合成好的框架提交给浏览器进程;UI线程更新页面。
💡合成应用:优化回流重绘 -- GPU加速。
合成的好处是它是在不涉及主线程的情况下完成的。合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么只合成动画(GPU加速)常用于性能优化方案。