浏览器架构、渲染流程、重绘回流、调试、跨域知识梳理

1,843 阅读23分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

简介

对于前端开发,每天都在和浏览器打交道,但是对于浏览器的架构,浏览器渲染流程有些小伙伴可能还不太清楚,今天笔者对浏览器架构、渲染流程、重绘回流、浏览器DevTools调试知识做了一个简单梳理和总结,希望能对小伙伴们有所帮助。

线程和进程

在说浏览器架构前,我们先来说说线程和进程。

进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。

线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。

简单的说呢,进程可以理解成正在执行的应用程序,而线程呢,可以理解成我们应用程序中的代码的执行器。而他们的关系可想而知,线程是跑在进程里面的,一个进程里面可能有一个或者多个线程,而一个线程,只能隶属于一个进程。

浏览器的多进程架构

不同的浏览器使用不同的架构,这里我们以Chrome为例,介绍浏览器的多进程架构。Chrome中每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能。每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。

image.png

主进程 Browser Process

负责浏览器界面的显示与交互,如前进,后退等。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。

第三方插件进程 Plugin Process

每种类型的插件对应一个进程,仅当使用该插件时才创建。

GPU 进程 GPU Process

最多只有一个,用于 3D 绘制等。

渲染进程 Renderer Process

渲染进程,内部是多线程的。主要负责页面渲染,脚本执行,事件处理等。

这个是浏览器的内核,我们需要重点掌握,后面笔者会详细介绍。

浏览器的进程模式

我们知道,浏览器可以打开很多个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

  1. 用户通过<a target="_blank">这种方式点击打开的新页面
  2. JS代码打开的新页面(比如 window.open)

也就是说,如果你分别在两个tab打开相同的site,这是两个进程。如果你是在一个tab下通过上面的两种方式打开相同的site,这两个tab用的是同一个进程。

渲染进程 (浏览器内核)

浏览器的渲染进程是多线程的,我们来看看它有哪些主要线程 :

image.png

1. GUI 渲染线程

  • 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。
  • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
  • 注意,GUI 渲染线程与 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起(相当于被冻结了),GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

2. JS 引擎线程

  • Javascript 引擎,负责处理 Javascript 脚本程序。(例如 V8 引擎)
  • JS 引擎线程负责解析 Javascript 脚本,运行代码。
  • JS 引擎一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程在运行 JS 程序。
  • 注意,GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

3. 事件触发线程

  • 事件触发线程用来控制事件循环(可以理解,JS 引擎自己都忙不过来,需要浏览器另开线程协助)
  • 当 JS 引擎执行代码块如鼠标点击,会将对应任务添加到事件线程中
  • 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理
  • 注意,由于 JS 的单线程关系,所以这些待处理队列中的事件都得排队等待 JS 引擎处理(当 JS 引擎空闲时才会去执行)

4. 定时触发器线程

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

5. 异步 http 请求线程

  • 也就是我们的网络请求,比如ajax、fetch、axios,在连接后是通过浏览器新开一个线程进行请求。
  • 当检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由 JS 引擎执行。

浏览器渲染流程

下面我们来详细说说浏览器的渲染流程,大概过程如下图。

image.png

解析文件(Parse)

解析文件主要是解析html文件生成DOM树和解析css文件生成CSSOM树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。

构建DOM树

浏览器接收到html文档后,对其进行解析,生成DOM树。

image.png

如果主线程解析到script位置,会停止解析 HTML,转而等待JS文件下载好,并将js代码执行完成 后,才能继续解析HTML。这是因为JS代码的执行过程可能会修改当前的DOM树,所以DOM树的生成必 须暂停。这就是JS会阻塞 HTML解析的根本原因。

image.png

但是我们可以通过给<script>标签添加defer、async来改变js的执行时机,而不阻塞html的解析。关于defer、async可以看笔者前面写的js异步编程,在文章末尾扩展部分有详细说明,这里笔者就不再赘述了。

构建CSSOM树

为了提高效率,浏览器会启动一个预解析线程率先下载和解析css文件。所以生成CSSOM树和DOM树是并行的。

如果主线程解析到Link位置,此时外部的 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML。这是因为下载和解析CSS的工作是在预解析线程中进行的。这就是CSS不会阻塞HTML解析的根本原因。

image.png

当CSS 文件下载完成,就会解析 CSS 文件生成CSSOM树。

image.png

样式计算(Reaclculate Style)

有了DOM树和CSSOM树后就需要样式计算了,主线程会遍历得到的 DOM树,依次为树中的每个节点计算出它最终的样式,称之为 Computed Style。在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255,0,0),相对单位会变成绝对单位,比如em 会变成px。

这一步完后,会得到一棵带有样式的DOM树。

image.png

生成布局树(Layout)

到现在我们已经有了一颗带有样式的DOM树,接下来要做的就是通过浏览器的布局系统确定元素的尺寸和位置,也就是要生成一棵布局树(Layout Tree)。

image.png

注意,DOM树和布局树并不是一一对应的,如果某个节点被display:none后,它是不会出现在布局树里面。

分层(Layers)

主线程会使用一套复杂的策略对整个布局树中进行分层。分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,从而提升效率。

滚动条、堆叠上下文、transform、opacity 等样式都会或多或少的影响分层结果,也可以通过 will-change属性更大程度的影响分层结果。

其实在浏览器控制台Layers里可以查看我们页面的分层结果,比如笔者的掘进首页就被分成了五层。

image.png

绘制(Paint)

这里的绘制是给前面的每一个分层生成如何绘制的指令,用于描述这一层的内容该如何画出来。

image.png

绘制列表我们也是可以通过开发者工具查看的,在Layers下面,点击具体的某层就可以看到绘制列表。

image.png

到此渲染主线程(GUI线程)的工作就完成了,剩余步骤会交给其它线程来处理。

分块(Tiling)

分块就是将我们的每一个图层分成很多的小区域。

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成,合成线程首先对每个图层进行分块,将其划分为更多的小区域,它会从线程池中拿取多个线程来完成分块工作。

image.png

光栅化(Raster)

分块完成后就进入了光栅化,光栅化就是将我们前面分好的块变成位图。

image.png

光栅化这个阶段会用到GPU加速,也就是用到GPU进程,以极高的速度完成光栅化。生成的位图最后发送给合成线程。

image.png

GPU进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。光栅化的结果,就是一块一块的位图。

画(Draw)

接下来就是画了,合成线程拿到每个层、每个块的位图后,生成一个个「指引(quad)」信息。

指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。变形发生在合成线程,与染主线程无关,这就是transform效率高的本质原因。

合成线程会把quad提交给 GPU 进程,由 GPU 进程产生系统调用,提交给显卡,完成最终的屏幕成像。

这里我们来说说显卡。

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

看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

重绘和回流

我们首先来回顾一下渲染流程:

image.png

回流(reflow)

首先介绍回流。回流也叫重排。

触发条件

简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化或者获取某些宽高样式的时候,会发生回流的过程。

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

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

回流过程

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

image.png

相当于将解析和合成的过程重新又走了一遍,开销是非常大的。

重绘(repaint)

触发条件

当 DOM 的修改导致了样式的变化,比如颜色背景色,并且没有影响几何属性的时候,会导致重绘(repaint)。

重绘过程

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

跳过了生成布局树和建图层树的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列操作。

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

优化

看了上面我们知道,在平时编写的代码过程中应该尽量减少浏览器重绘和回流的次数,那么都有哪些手段呢?

  1. 避免频繁操作元素样式,最好一次性重写 style 属性,或者将样式列表定义为 class 并一次性更改 class 属性。
  2. 使用createDocumentFragment进行批量的 DOM 操作。
  3. 对于 resize、scroll 等进行防抖/节流处理。
  4. 可以先为元素设置 display: none,操作结束后再把它显示出来。因为在 display 属性为 none 的元素上进行的 DOM 操作不会引发回流和重绘。
  5. 开启GPU加速,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。比如利用 CSS3 的transform、opacity、filter。(使用translate进行定位的性能是优于绝对定位的)
  6. 使用requestAnimationFrame作为动画帧。动画速度越快,回流次数越多,上述提到浏览器刷新频率为60Hz,即每16.6ms更新一次,而requestAnimationFrame()正是以16.6ms的速度更新一次,所以可用requestAnimationFrame()代替setInterval()。

下面我们来说说GPU加速的原因:

前面我们在分析渲染流程最后一步画的时候就说到了,transform 既不会影响布局也不会影响绘制指令,它影响的只是染流程的最后一个draw阶段。

由于draw阶段在合成线程中,所以transform的变化几乎不会影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响transform的变化。

同样的还有opacity、filter属性,也会启用GPU加速。

GPU加速的本质就是这类样式的变化不会影响样式计算、布局、绘制、光栅化等,恰恰只会影响到渲染流程的画阶段,这个阶段是在合成线程中并且会用到GPU来处理进行加速。所以这就是大名鼎鼎的GPU加速。

Chrome DevTools 调试

关于Chrome DevTools的调试,下面这两篇文章写的很好,大家可以看看。

细数那些不为人知的 Chrome DevTools 骚操作,你会使用几个?

掘金小册 你不知道的 Chrome 调试技巧

跨域

跨域也是前端老生常谈的一个话题,也是前端开发所必须要知道的一个知识,下面我们再来总结下。

什么是跨域?

跨域就是在不同源之间交互通信。那什么是同源呢?

所谓同源是指"协议+域名+端口"三者相同,即便两个不同的域名指向同一个ip地址,也非同源。

image.png

同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到XSS、CSRF等攻击。

跨域的限制

  1. 当前域下的 js 脚本不能够访问其他域下的 cookie、localStorage 和 indexDB。
  2. 当前域下的 js 脚本不能够操作访问操作其他域下的 DOM。
  3. 当前域下 ajax 无法发送跨域请求。

注意,对于img、script、link这些标签是可以跨域加载资源的。

解决跨域方法

那些不常用的老方案笔者就不再说了,这里只讲几个笔者常用的方案。

cors 跨域资源共享

说到这个方案,我们就得先来说说简单请求和非简单请求啦。

简单请求,需要同时满足以下两大条件,就属于简单请求

  1. 请求方法是以下三种方法之一:HEAD、GET、POST

  2. 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 字段,该字段是必须的,值为*或者具体的域名即可。

对于非简单请求跨域

  1. 服务器 response 需要设置 Access-Control-Allow-Origin 字段,该字段是必须的,值为*或者具体的域名
  2. 服务器 response 需要设置 Access-Control-Allow-Headers,该字段必须,表明服务器支持的所有头信息字段
  3. 服务器 response 需要设置 Access-Control-Allow-Methods 该字段必须,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法
  4. Access-Control-Max-Age 该字段可选,用来指定本次预检请求的有效期。

我们知道cookie只会在同源请求下自动携带,如果在跨域的情况下想携带cookie怎么办?

  1. 服务器 response 可以设置 Access-Control-Allow-Credentials,该字段可选,其值类型是布尔型,表示是否允许发送 Cookie。默认情况下 Cookie 不包括在 CORS 请求中。当设为 true 时表示服务器明确许可,Cookie 可以包含在请求中一起发送给服务器。

  2. 如果服务器设置了 Access-Control-Allow-Credentials 客户端请求需要设置 withCredentials = true,并且 Access-Control-Allow-Origin 的值必须是明确的域名不能是*。

  3. 客户端cookie本身 需要设置 SameSite=None;

Node中间件

这其实是我们在开发环境用的最多的方案,比如vue-cli,create-react-app、webpack里面配置的跨域代理都是这种方式。

它的原理其实很简单,就是利用后端与后端进行数据交互时没有同源策略问题。

  1. 首先创建一个自己的后端服务,将我们的前端应用放在上面。
  2. 然后在我们的后端服务开启cros。
  3. 然后前端请求首先全部请求在我们自己的后端服务里,然后我们的后端服务再去调用真实的后端服务,拿到数据后再将数据传输给我们的前端,这样我们就可以获取到其他后端的数据啦。

原理图大概如下:

image.png

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 会阻塞页面的加载。

由于 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 加载会阻塞 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(后期)

不同内核对网页语法的解析也有不同,因此同一网页语法在不同内核的浏览器中的渲染效果也可能不同,这就是浏览器差异性。

既然浏览器是有差异性的,那怎么处理这种兼容性问题呢?

我们可以使用磨平浏览器默认样式与加入浏览器私有属性这两种方式完成浏览器兼容性的处理即可。

  1. 磨平浏览器默认样式可以使用 normalize.css

  2. 加入浏览器私有属性就是对于一些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+中被使用

参考文档

浏览器渲染原理

(1.6w字)浏览器灵魂之问,请问你能接得住几个?

从 8 道面试题看浏览器渲染过程与性能优化

后记

感谢小伙伴们的耐心观看,本文为笔者个人学习笔记,如有谬误,还请告知,万分感谢!如果本文对你有所帮助,还请点个关注点个赞~,您的支持是笔者不断更新的动力!