浏览器架构、渲染流程、调试、重绘回流、跨域知识梳理
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情
简介
对于前端开发,每天都在和浏览器打交道,但是对于浏览器的架构,浏览器渲染流程有些小伙伴可能还不太清楚,今天笔者对浏览器架构、渲染流程、重绘回流、浏览器DevTools调试知识做了一个简单梳理和总结,希望能对小伙伴们有所帮助。
浏览器的多进程架构
在说浏览器架构前,我们先来说说线程和进程。
进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。
简单的说呢,进程可以理解成正在执行的应用程序,而线程呢,可以理解成我们应用程序中的代码的执行器。而他们的关系可想而知,线程是跑在进程里面的,一个进程里面可能有一个或者多个线程,而一个线程,只能隶属于一个进程。
不同的浏览器使用不同的架构,这里我们以Chrome
为例,介绍浏览器的多进程架构。Chrome
中每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能。每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。
主进程 Browser Process
负责浏览器界面的显示与交互,如前进,后退等。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。
第三方插件进程 Plugin Process
每种类型的插件对应一个进程,仅当使用该插件时才创建。
GPU 进程 GPU Process
最多只有一个,用于 3D 绘制等
渲染进程 Renderer Process
渲染进程,内部是多线程的。主要负责页面渲染,脚本执行,事件处理等。
这里我们稍微扩展一下浏览器内核
浏览器内核主要分成两部分:渲染引擎和 JS引擎。
-
渲染引擎的职责就是渲染,即在浏览器窗口中显示所请求的内容。默认情况下,渲染引擎可以显示 html、xml 文档及图片,它也可以借助插件(一种浏览器扩展)显示其他类型数据,例如使用 PDF 阅读器插件,可以显示 PDF 格式。
-
JS 引擎:解析和执行 javascript 来实现网页的动态效果。
最开始渲染引擎和 JS 引擎并没有区分的很明确,后来 JS 引擎越来越独立,内核就倾向于指渲染引擎。
浏览器的进程模式
我们知道,浏览器可以打开很多个tab,每个tab里面可以展示不同的网页,每个tab又是一个渲染进程,这就意味着,一个tab,就会有一个渲染进程,这些进程之间的内存无法进行共享,而不同进程的内存常常需又包含相同的内容,这就会导致浏览器占用内存过大的问题。
为了节省内存,Chrome提供了四种进程模式(Process Models),不同的进程模式会对 tab 进程做不同的处理。
Single process
所有 tab 共用一个进程,也就是单进程。
Process-per-tab
每个 tab 使用一个进程,这种模式下进程就会非常多。
Process-per-site
同一个site
使用一个进程,比如a.baidu.com
和b.baidu.com
就可以理解为同一个 site
。简单理解就是同一个主域名使用同一个进程,而不管你开了几个tab。
Process-per-site-instance
Process-per-site-instance
也是默认的进程模式。
那site-instance
是什么意思呢?这相较于上面的同一个主域名使用同一个进程而言更细。
满足下面两种情况并且打开的新页面和旧页面属于上面定义的同一个 site,就属于同一个 site-instance
- 用户通过
<a target="_blank">
这种方式点击打开的新页面 - JS代码打开的新页面(比如
window.open
)
也就是说,如果你分别在两个tab打开相同的site,这是两个进程。如果你是在一个tab下通过上面的两种方式打开相同的site,这两个tab用的是同一个进程。
渲染进程 (浏览器内核)
浏览器的渲染进程是多线程的,我们来看看它有哪些主要线程 :
1. GUI 渲染线程
- 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
- 注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。
2. JS 引擎线程
- Javascript 引擎,也称为 JS 内核,负责处理 Javascript 脚本程序。(例如 V8 引擎)
- JS 引擎线程负责解析 Javascript 脚本,运行代码。
- JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序。
- 注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
3. 事件触发线程
- 归属于浏览器而不是 JS 引擎,用来控制事件循环(可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助)
- 当 JS 引擎执行代码块如 setTimeOut 时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX 异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理
- 注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)
4. 定时触发器线程
- 传说中的 setInterval 与 setTimeout 所在线程
- 浏览器定时计数器并不是由 JavaScript 引擎计数的,(因为 JavaScript 引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
- 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行)
- 注意,W3C 在 HTML 标准中规定,规定要求 setTimeout 中低于 4ms 的时间间隔算为 4ms。
5. 异步 http 请求线程
- 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求。
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JavaScript 引擎执行。
浏览器渲染流程
解析文件
解析文件主要是解析html
文件生成DOM树
和解析css
文件生成CSSOM树
。
浏览器的渲染引擎将DOM树
与CSSOM树
合并生成渲染树,只渲染需显示的节点及其样式。DOM树
、CSSOM树
和渲染树
三者的构建并无先后条件
与先后顺序
,非完全独立而是存在交叉并行构建的情况,因此会形成一边加载,一边解析,一边渲染的工作现象。
构建DOM树
浏览器接收到html文档后,对其进行解析,生成DOM树。
构建DOM过程中,如果遇到<script>
标签,渲染引擎会停止对HTML的解析,而去加载并执行JS代码。但是我们可以通过给<script>
标签添加defer、async
来改变js的执行时机。
关于defer、async
可以看笔者前面写的js异步编程,在文章末尾扩展部分有详细说明,这里笔者就不再赘述了。
构建CSSOM树
CSS 文件下载完成,解析 CSS 文件生成CSSOM树。
生成布局树
现在已经生成了DOM树
和CSSOM树
,接下来要做的就是通过浏览器的布局系统确定元素的位置
,也就是要生成一棵布局树(Layout Tree)
。
绘制图层
布局 layout 之后,我们知道了不同元素的结构,样式,几何关系,我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,在绘制阶段,主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。
回流
根据渲染树生成布局渲染树
重绘
根据布局渲染树生成绘制渲染树
合成图层
合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。
显示
把页面内容绘制到内存后,也就是生成了页面,然后把这部分内存发送给显卡,显卡会连接显示器,就会在显示器上进行展示。
这里我们来说说显卡。
无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区
和后缓冲区
对换位置,如此循环更新。
看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。
Chrome DevTools 调试
关于Chrome DevTools
的调试,下面这两篇文章写的很好,大家可以看看。
细数那些不为人知的 Chrome DevTools 骚操作,你会使用几个?
重绘和回流
我们首先来回顾一下渲染流程
:
回流
首先介绍回流
。回流
也叫重排
。
触发条件
简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流
的过程。
具体一点,有以下的操作会触发回流:
- 一个 DOM 元素的几何属性变化,常见的几何属性有
width
、height
、padding
、margin
、left
、top
、border
等等, 这个很好理解。 - 使 DOM 节点发生
增减
或者移动
。 - 读写
offset
族、scroll
族和client
族属性的时候,浏览器为了获取这些值,需要进行回流操作。 - 调用
window.getComputedStyle、getBoundingClientRect
方法。 - 窗口大小的调整。
回流过程
依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。
相当于将解析和合成的过程重新又走了一遍,开销是非常大的。
重绘
触发条件
当 DOM 的修改导致了样式的变化,比如颜色背景色,并且没有影响几何属性的时候,会导致重绘
(repaint
)。
重绘过程
由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程如下:
跳过了生成布局树
和建图层树
的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。
可以看到,重绘不一定导致回流,但回流一定发生了重绘。
优化
看了上面我们知道,在平时编写的代码过程中应该尽量减少浏览器重绘和回流的次数,那么都有哪些手段呢?
- 避免频繁操作元素样式,最好一次性重写
style
属性,或者将样式列表定义为class
并一次性更改class
属性。 - 使用
createDocumentFragment
进行批量的DOM
操作。 - 对于
resize、scroll
等进行防抖/节流处理。 - 可以先为元素设置
display: none
,操作结束后再把它显示出来。因为在display
属性为none
的元素上进行的DOM
操作不会引发回流和重绘。 - 开启
GPU加速
,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。比如利用CSS3
的transform
、opacity
、filter
。(使用translate
进行定位的性能是优于绝对定位的) - 使用
requestAnimationFrame
作为动画帧。动画速度越快,回流次数越多,上述提到浏览器刷新频率为60Hz
,即每16.6ms
更新一次,而requestAnimationFrame()
正是以16.6ms
的速度更新一次,所以可用requestAnimationFrame()
代替setInterval()
。
下面我们来说说GPU
加速的原因
在合成的情况下,会直接跳过布局和绘制流程,直接进入非主线程
处理的部分,即直接交给合成线程
处理。交给它处理有两大好处:
- 能够充分发挥
GPU
的优势。合成线程生成位图的过程中会调用线程池,并在其中使用GPU
进行加速生成,而GPU
是擅长处理位图数据的。 - 没有占用主线程的资源,即使主线程卡住了,效果依然能够流畅地展示。
跨域
跨域也是前端老生常谈的一个话题,也是前端开发所必须要知道的一个知识,下面我们再来总结下。
什么是跨域?
跨域就是在不同源之间交互通信。那什么是同源呢?
所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。
同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。
跨域的限制
- 当前域下的 js 脚本不能够访问其他域下的 cookie、localStorage 和 indexDB。
- 当前域下的 js 脚本不能够操作访问操作其他域下的 DOM。
- 当前域下 ajax 无法发送跨域请求。
注意,对于
img、script、link
这些标签是可以跨域加载资源的。
解决跨域方法
那些不常用的老方案笔者就不再说了,这里只讲几个笔者常用的方案。
cors 跨域资源共享
说到这个方案,我们就得先来说说简单请求和非简单请求啦。
简单请求,需要同时满足以下两大条件,就属于简单请求
-
请求方法是以下三种方法之一:
HEAD、GET、POST
-
Content-Type:只限于
application/x-www-form-urlencoded、multipart/form-data、text/plain
三个值
非简单请求,不满足简单请求的就是非简单请求。
对于这两种不同的请求,cors
有不同的方案。
简单请求跨域请求只会发送一次请求。
非简单请求在通信前会发送一次 http 查询(option)请求,当浏览器得到肯定答复时,才会发送正式的请求,否则不会发送真正的请求,也就是会发两次请求。预检请求会带上 Origin
源地址和 Host
目标地址,同时也会带上 Access-Control-Request-Method
, 列出 CORS 请求用到哪个 HTTP 方法。Access-Control-Request-Headers
,指定 CORS 请求将要加上什么请求头。
对于简单请求跨域,服务器 response
只需要设置 Access-Control-Allow-Origin
字段,该字段是必须的,值为*
或者具体的域名即可。
对于非简单请求跨域
- 服务器
response
需要设置Access-Control-Allow-Origin
字段,该字段是必须的,值为*
或者具体的域名 - 服务器
response
需要设置Access-Control-Allow-Headers
,该字段必须,表明服务器支持的所有头信息字段 - 服务器
response
需要设置Access-Control-Allow-Methods
该字段必须,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法 Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期。
我们知道cookie只会在同源请求下自动携带,如果在跨域的情况下想携带cookie怎么办?
-
服务器
response
可以设置Access-Control-Allow-Credentials
,该字段可选,其值类型是布尔型,表示是否允许发送 Cookie。默认情况下 Cookie 不包括在 CORS 请求中。当设为 true 时表示服务器明确许可,Cookie 可以包含在请求中一起发送给服务器。 -
如果服务器设置了
Access-Control-Allow-Credentials
客户端请求需要设置withCredentials = true
,并且Access-Control-Allow-Origin
的值必须是明确的域名不能是*
。
Node中间件
这其实是我们在开发环境用的最多的方案,比如vue-cli,create-react-app、webpack
里面配置的跨域代理都是这种方式。
它的原理其实很简单,就是利用后端与后端进行数据交互时没有同源策略问题。
- 首先创建一个自己的后端服务,将我们的前端应用放在上面。
- 然后在我们的后端服务开启
cros
。 - 然后前端请求首先全部请求在我们自己的后端服务里,然后我们的后端服务再去调用真实的后端服务,拿到数据后再将数据传输给我们的前端,这样我们就可以获取到其他后端的数据啦。
原理图大概如下:
Nginx反向代理
这其实是我们在生产环境用的最多的方案。
Nginx
跟Node接口代理
是一个道理,只不过Nginx
就不需要我们自己去搭建一个中间服务,内部已经实现好了。
我们只需要在配置文件做如下简单配置即可。
server{
listen 8888;
server_name 127.0.0.1;
location /{
proxy_pass 127.0.0.1:8000;
}
}
扩展
为什么 Javascript 要是单线程的?
如果 Javascript 是多线程的话,在多线程的交互下,处于 UI 中的 DOM 节点就可能成为一个临界资源。假设存在两个线程同时操作一个 DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。所以JS就只能是单线程。
Javascript 会阻塞页面加载吗?
由于 JavaScript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与JavaScript 引擎为互斥的关系。
当 JavaScript 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到引擎线程空闲时立即被执行。
因此如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。这也就是为什么一般我们都会把js放到底部,或者延迟加载的原因。
CSS 加载会造成阻塞吗?
由上面浏览器渲染流程我们可以看出 :
DOM 和 CSSOM 通常是并行构建的,所以 CSS 加载不会阻塞 DOM 的解析。
然而,由于 Render Tree 是依赖于 DOM Tree 和 CSSOM Tree 的,
所以他必须等待到 CSSOM Tree 构建完成,也就是 CSS 资源加载完成(或者 CSS 资源加载失败)后,才能开始渲染。
因此,CSS 加载会阻塞 Dom 的渲染。
CSS 会阻塞后面的 JS 吗?
CSS会阻塞JS执行,并不会阻塞JS文件下载
由于 JavaScript 是可操纵 DOM 和 css 样式的,如果在修改这些元素属性同时渲染界面(即 JavaScript 线程和 UI 线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。
因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与JavaScript 引擎为互斥的关系。
因此,样式表会在后面的 JS 执行前先加载并执行完毕,所以css 会阻塞后面 js 的执行。
为什么操作 DOM 慢
因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。
主流浏览器类型及使用的内核
世界五大浏览器,Chrome
、Safari
、Firefox
、Opera
、IExplorer/Edge
。他们相应的内核如下
- Google Chrome:
Webkit
(前期)、Blink
(后期) - Apple Safari:
Webkit
- Mozilla Firefox:
Gecko
- ASA Opera:
Presto
(前期)、Blink
(后期) - Microsoft IExplorer:
Trident
- Microsoft Edge:
Trident
(前期)、Blink
(后期)
不同内核对网页语法的解析也有不同,因此同一网页语法在不同内核的浏览器中的渲染效果也可能不同,这就是浏览器差异性。
既然浏览器是有差异性的,那怎么处理这种兼容性问题呢?
我们可以使用磨平浏览器默认样式
与加入浏览器私有属性
这两种方式完成浏览器兼容性的处理即可。
-
磨平浏览器默认样式
可以使用 normalize.css -
加入浏览器私有属性
就是对于一些CSS3属性
,加上浏览器私有前缀-webkit-
、-moz-
、-ms-
或-o-
。
对于编写私有属性的顺序需特别注意:兼容性写法放到前面,标准性写法放到最后。在浏览器解析CSS
时,若标准属性无法使用则使用当前浏览器相应私有属性。
/* Chrome、Safari、New Opera、New Edge */
-webkit-transform: translate(10px, 10px);
/* Firefox */
-moz-transform: translate(10px, 10px);
/* IExplorer、Old Edge */
-ms-transform: translate(10px, 10px);
/* Old Opera */
-o-transform: translate(10px, 10px);
/* 标准 */
transform: translate(10px, 10px);
浏览器主流内核介绍
- Blink内核:由谷歌公司与欧朋公司合作自研的内核,同时谷歌公司也将其作为开源内核架构
Chromium
的一部分发布出来,在Chrome 28+
与Opear 15+
中被使用 - Webkit内核:由苹果公司自研的内核,同时也是
Blink内核
的原型,在Chrome 1~28
与Safari 1+
中被使用 - Gecko内核:由网景公司自研的内核,前期在
Navigator
中使用,后期推广到Firefox
中,在Firefox 1+
中被使用 - Presto内核:由欧朋公司自研的内核,其渲染性能达到极致但牺牲了兼容性,目前已废弃,在
Opear 7~14
中被使用 - Trident内核:由微软公司自研的内核,因为其被包括在全世界使用率最高的
Windows系统
中,导致一直称霸渲染引擎界十多年,在IExplorer 4+
中被使用
参考文档
后记
感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!
转载自:https://juejin.cn/post/7141200888521031717