大吃一惊!!原来浏览器原理那么简单~

220 阅读14分钟

从浏览器进程作为切入点,着重讲解了渲染进程和网络进程的部分重要知识点,从而串联起整个前端体系。

1、浏览器中的进程

1.浏览器是多进程
2.每新开一个tab页,系统就会创建一个独立的进程

1.1、浏览器进程分类

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

1.2、浏览器多进程架构的优缺点:

1.2.1、优点

多进程模型提升了浏览器的稳定性、流畅性和安全性

1.2.2、缺点

  • 更高的资源占用
  • 更复杂的体系架构

2、渲染进程

渲染进程的线程主要有5种:

  1. GUI渲染线程
  2. JS引擎线程
  3. 事件触发线程
  4. 定时触发器线程
  5. 异步HTTP请求线程(IO线程)

2.1、GUI渲染线程

2.1.1、渲染流程

渲染分为以下8个阶段:

  1. 构建DOM树
  2. 样式计算
  3. 布局阶段
  4. 分层
  5. 绘制
  6. 分块
  7. 光栅化
  8. 合成

理解内容: 直接上图

前三阶段:构建DOM树--> 样式计算 --> 布局阶段,如下图👇 image.png

第四阶段:我们来看看图层与布局树之间关系,如下图👇

第五阶段:绘制,如下图👇

image.png

第六阶段:分块,如下图👇

绘制出所有图层内容的话,就会产生太大的开销。
基于上面的原因,合成线程会讲图层划分为图块(tile),采用分块的方式进行绘制。

第七阶段:光栅化,如下图👇

将图块交给栅格化线程池生成位图

image.png

第八阶段:合成和显示

栅格化操作完成后,合成线程会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。

浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。

显卡原理:

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区后缓冲区对换位置,如此循环更新。

这个时候,心中就有点概念了,比如某个动画大量占用内存时,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

总结:

image.png

把上面整个的渲染流水线,用一张图片更直观的表示

image.png

2.1.2、回流-重绘-合成

2.1.2.1、回流

另外一个叫法是重排,回流触发的条件就是:对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流过程。

具体一点,有以下的操作会触发回流:

  1. 一个 DOM 元素的几何属性变化,常见的几何属性有widthheightpaddingmarginlefttopborder 等等, 这个很好理解。
  2. 使 DOM 节点发生增减或者移动
  3. 读写 offset族、scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作。
  4. 调用 window.getComputedStyle 方法。

依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。

2.1.2.2、重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

根据概念,我们知道由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程,流程如下:

跳过了布局树建图层树,直接去绘制列表,然后在去分块,生成位图等一系列操作。

可以看到,重绘不一定导致回流,但回流一定发生了重绘。

2.1.2.3、合成

还有一种情况:就是更改了一个既不要布局也不要绘制的属性,那么渲染引擎会跳过布局和绘制,直接执行后续的合成操作,这个过程就叫合成

举个例子:比如使用CSS的transform来实现动画效果,避免了回流跟重绘,直接在非主线程中执行合成动画操作。显然这样子的效率更高,毕竟这个是在非主线程上合成的,没有占用主线程资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

利用这一点好处:

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

提升合成层的最好方式是使用 CSS 的 will-change 属性

GPU加速原因

比如利用 CSS3 的transformopacityfilter这些属性就可以实现合成的效果,也就是大家常说的GPU加速

  • 在合成的情况下,直接跳过布局和绘制流程,进入非主线程处理部分,即直接交给合成线程处理。
  • 充分发挥GPU优势,合成线程生成位图的过程中会调用线程池,并在其中使用GPU进行加速生成,而GPU 是擅长处理位图数据的。
  • 没有占用主线程的资源,即使主线程卡住了,效果依然流畅展示。

2.2、JS引擎线程

GUI渲染线程与JS引擎线程是互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

2.2.1、 V8垃圾回收

V8将内存分为 新生代老生代 两代:

新生代就活得短一点的对象。

老生代就存活时间较长或常驻内存的对象。

针对不同生代采用不同的回收算法,我们称之为分代式回收

V8堆的整体大小 = 新生代占用的内存空间 + 老生代占用的内存空间

可以通过 --max-new-space-size--max-old-space-size 去设置新老生代空间大小。

在新生代里有一个算法,将新生代分成了两个空间(semispace),一个使用中的状态(From空间),一个闲置状态(To空间)。
当我们分配对象时,是在From空间中进行分配。 每次经过Scavenge会将From空间中有引用的对象复制到To空间中,然后释放From空间,再将To空间和From空间角色互换,反复如此。

晋升

从新生代到老生代的过程叫晋升。

需要满足以下任意一个条件:

  1. 一个对象经历过Scavenge回收
  2. 一个是To空间的内存占用超过 25%

此时这个对象会被分配到老生代空间中。

设置25%这个阈值的原因:

如果占比过高,会影响后续的内存分配

而老生代对象,采用标记清除标记整理,但标记清除会造成内存不连续,所以会有标记整理取解决掉内存碎片,就是清理掉边界碎片

为了避免出现JS应用逻辑和垃圾回收器看到的不一致情况,垃圾回收的3种基本算法需要将应用逻辑暂停下来,等到垃圾回收后再恢复执行,这种行为称之为“全停顿(stop-the-world)”。

为了降低全堆垃圾回收带来的停顿时间,V8后续引入了增量标记、延迟清理和增量式整理等方法。还计划引入并行标记、并行清理,利用多核来降低每次回收的停顿时间。

2.3、事件触发线程

  1. 事件触发线程归属于浏览器而不是JS引擎,用来控制事件循环(存在一个事件队列)
  2. 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击,Ajax异步请求等),会将对应的任务添加到事件线程中
  3. 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
  4. 注意,由于JS的单线程关系,所以这些待处理的事件需要等待JS引擎空闲时进行处理。

2.4、定时触发器线程

  1. setInterval、setTimeOut所在线程
  2. 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎时单线程的,如果处于阻塞线程状态就会影响计时的准确)
  3. 因此通过单独线程来计时并触发(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
  4. 注意,W3C在HTML标准中规定要求setTimeOut中低于4ms的时间间隔为4ms

2.5、异步HTTP请求线程(IO线程)

  1. 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
  2. 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中(放入事件触发线程中)。再由JavaScript引擎执行。

渲染进程又称作浏览器内核:

image.png

从上面的概念我们可以得到几点总结:

  1. 浏览器是多进程的。
  2. js执行的主线程为JS引擎,并且无论何时都只有一个JS线程在运行,所以是单线程执行。
  3. GUI渲染线程和JS引擎线程是互斥的,并且JS会阻塞页面的加载和渲染。
  4. 定时器(setInterval,setTimeout)会在定时器触发器线程中进行计时。
  5. 定时触发器线程计时结束后需要执行的事件和异步HTTP请求线程的回调事件都会进入到事件触发线程的任务队列中等待JS引擎的执行

2.6、浏览器事件循环机制

2.6.1、 宏任务和微任务

本文的MacrotaskWHATWG 中叫taskMacrotask为了便于理解,并没有实际的出处。

同步任务和异步任务的划分其实并不准确,准确的分类方式是宏任务(Macrotask)和微任务(Microtask)。

宏任务包括:script(整体代码), setTimeout, setInterval, requestAnimationFrame, I/O,setImmediate

其中setImmediate只存在于Node中,requestAnimationFrame只存在于浏览器中。

微任务包括: Promise, Object.observe(已废弃), MutationObserver(html5新特性),process.nextTick

其中process.nextTick只存在于Node中,MutationObserver只存在于浏览器中。

注意:

UI Rendering不属于宏任务,也不属于微任务,它是一个与微任务平行的一个操作步骤。 HTML规范文档

这种分类的执行方式就是,执行一个宏任务,过程中遇到微任务时,将其放到微任务的事件队列里,当前宏任务执行完成后,会查看微任务的事件队列,依次执行里面的微任务。如果还有宏任务的话,再重新开启宏任务……

image.png

console.log('a');

setTimeout(function() {
    console.log('b');
    process.nextTick(function() {
        console.log('c');
    })
    new Promise(function(resolve) {
        console.log('d');
        resolve();
    }).then(function() {
        console.log('e')
    })
})
process.nextTick(function() {
    console.log('f');
})
new Promise(function(resolve) {
    console.log('g');
    resolve();
}).then(function() {
    console.log('h')
})

setTimeout(function() {
    console.log('i');
    process.nextTick(function() {
        console.log('j');
    })
    new Promise(function(resolve) {
        console.log('k');
        resolve();
    }).then(function() {
        console.log('l')
    })
})
复制代码

第一轮事件循环:

1.第一个宏任务(整体script)进入主线程,console.log('a'),打印a。

2.遇到setTimeout,其回调函数进入宏任务队列,暂定义为setTimeout1

3.遇到process.nextTick(),其回调函数被分发到微任务队列,暂定义为process1

4.遇到Promisenew Promise直接执行,打印g。then进入微任务队列,暂定义为then1

5.遇到setTimeout,其回调函数进入宏任务队列,暂定义为setTimeout2

此时我们看一下两个任务队列中的情况

宏任务队列微任务队列
setTimeout1、setTimeout2process1、then1

第一轮宏任务执行完毕,打印出a和g。

查找微任务队列中有process1then1。全部执行,打印f和h。

第一轮事件循环完毕,打印出a、g、f和h。

第二轮事件循环:

1.从setTimeout1宏任务开始,首先是console.lob('b'),打印b。

2.遇到process.nextTick(),进入微任务队列,暂定义为process2

3.new Promise直接执行,输出d,then进入微任务队列,暂定义为then2

此时两个任务队列中

宏任务队列微任务队列
setTimeout2process2、 then2

第二轮宏任务执行完毕,打印出b和d。

查找微任务队列中有process2then2。全部执行,打印c和e。

第二轮事件循环完毕,打印出b、d、c和e。

第三轮事件循环

1.执行setTimeout2,遇到console.log('i'),打印i。

2.遇到process.nextTick(),进入微任务队列,暂定义为process3

3.new Promise直接执行,打印k。

4.then进入微任务队列,暂定义为then3

此时两个任务队列中

宏任务队列:空

微任务队列:process3then3

第三轮宏任务执行完毕,打印出i和k。

查找微任务队列中有process3then3。全部执行,打印j和l。

第三轮事件循环完毕,打印出i、k、j和l。

到此为止,三轮事件循环完毕,最终输出结果为:

a、g、f、h、b、d、c、e、i、k、j、l

3、网络进程

3.1、浏览器缓存

浏览器缓存分为强制缓存协商缓存

3.1.1、强制缓存

Expires 是 HTTP/1.0控制网页缓存的字段
Cache-control 是 HTTP/1.1的字段

Cache-control取值:

  • public:所有内容都将被缓存(客户端和代理服务器都可缓存)

  • private:所有内容只有客户端可以缓存,Cache-Control的默认取值

  • no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定

  • no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存

  • max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效

到了HTTP/1.1,Expire已经被Cache-Control替代,原因在于Expires控制缓存的原理是使用客户端的时间与服务端返回的时间做对比,那么如果客户端与服务端的时间因为某些原因(例如时区不同;客户端和服务端有一方的时间不准确)发生误差,那么强制缓存则会直接失效,这样的话强制缓存的存在则毫无意义

3.1.2、协商缓存

Last-Modified / If-Modified-Since 是 HTTP/1.0的字段
Etag / If-None-Match 是 HTTP/1.1的字段

强制缓存优先于协商缓存进行,若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,重新获取请求结果,再存入浏览器缓存中;生效则返回304,继续使用缓存,主要过程如下:

image.png

参考