浏览器222

15 阅读36分钟

一、浏览器组成

  1. 对浏览器的理解

浏览器的核心功能,是将用户请求的 Web 资源进行解析与呈现。它会通过网络向服务器请求对应资源,并将结果展示在浏览器窗口中。这些资源通常以 HTML 格式为主,同时也支持图片、PDF 等其他媒体与文档格式。用户通过 URI(统一资源标识符) 来指定所需资源的位置,浏览器据此完成资源定位与获取。

  1. 主流浏览器内核

内核名称浏览器特点
BlinkChrome、Edge、Opera基于 WebKit 分支,Google 主导
WebKitSafariApple 主导,开源
GeckoFirefoxMozilla 主导
TridentIE已停止维护

主流的浏览器内核有如下四种:

1、Trident内核

代表产品为Internet Explorer,又称其为IE内核,是微软开发的一种排版引擎。

2、Gecko内核

代表作品为Mozilla Firefox。Gecko是一套开放源代码的、以C++编写的网页排版引擎,是最流行的排版引擎之一,仅次于Trident。

3、WebKit内核

代表作品有Safari、Chrome。WebKit是一个开源项目,包含了来自KDE项目和苹果公司的一些组件,主要用于Mac OS系统,它的特点在于源码结构清晰、渲染速度极快。缺点是对网页代码的兼容性不高,导致一些编写不标准的网页无法正常显示。

4、Presto内核

代表作品Opera。Presto是由Opera Software开发的浏览器排版引擎,供Opera 7.0及以上使用。

  1. 浏览器的主要组成部分

浏览器的界面常由如下几部分构成:

其中地址栏用于输入网页地址并进行访问。

菜单栏是浏览器本身的一些工具及插件,不同浏览器风格各异。

页面窗口是每一个打开的网站。

选项卡则是支持同时在任务栏里面打开多个浏览器的功能(即访问网页的一种形式)。

状态栏则是反映某些进度,比如下载或者加载进度等。

  • 用户界面 : 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗⼝显示的您请求的⻚⾯外,其他显示的各个部分都属于⽤户界⾯。

  • 浏览器引擎 : 在⽤户界⾯和呈现引擎之间传送指令。查询渲染引擎,把结果返回给界面,控制整个页面加载流程。

  • 渲染引擎: 负责解析 HTML/CSS、构建 DOM/CSSOM、合成渲染树、布局、绘制页面。

  • JavaScript 引擎: ⽤于解析和执⾏ JavaScript 代码。内存管理、垃圾回收

  • 网络 :⽤于⽹络调⽤,⽐如 HTTP 请求。其接⼝与平台⽆关,并为所有平台提供底层实现。

  • UI 后端 - ⽤于绘制基本的窗⼝⼩部件,⽐如组合框和窗⼝。其公开了与平台⽆关的通⽤接⼝,⽽在底层使⽤操作系统的⽤户界⾯⽅法。

  • 数据存储 : 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie、localStorage、sessionStorage等。

    •   一张极简总结图(文字版)
    •   用户界面
           ↓
        浏览器引擎(调度)
           ↓
        渲染引擎(HTML/CSS 渲染页面)
           ↓
        JS 引擎(执行 JS)
           ↓
        网络模块(请求资源)
           ↓
        UI 后端 / 数据存储
      

值得注意的是,和⼤多数浏览器不同,Chrome 浏览器的每个标签⻚都分别对应⼀个呈现引擎实例。每个标签⻚都是⼀个独⽴的进程。

为什么要 “一个标签页一个进程”?

  1. 稳定性更强: 一个页面崩溃,不会拖垮整个浏览器

  2. 安全性更高: 进程之间有沙箱隔离,网页无法随意访问系统 / 其他页面。

  3. 性能更可控: 可以单独控制 / 销毁某个标签页的资源。

  4. 总结

Chrome = 多进程架构

每个标签页 ≈ 独立的渲染进程

每个渲染进程里有独立的渲染引擎、V8 引擎、运行环境

这就是为什么:不同标签页的 JS、window、localStorage 是互相隔离的。

二、进程与线程

  1. 进程与线程的概念

进程 : 进程是操作系统资源分配的基本单位,进程中包含线程。简而言之,就是正在进行中的应用程序。

线程:线程是由进程所管理的。是进程内的一个独立执行的单位,是CPU调度的最小单位。

进程是资源分配的最小单位,线程是CPU调度的最小单位。

多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的

一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程(进程是运行在虚拟内存上的 ,虚拟内存是用来解决用户对硬件资源的无限需求和有限的硬件资源之间的矛盾的。从操作系统角度来看,虚拟内存即交换文件;从处理器角度看,虚拟内存即虚拟地址空间。)

  • 线程是进程的基本单位,一个进程由一个或者多个线程组成,搞清楚这个关系之后,我们可以明确线程就是程序执行的最小单元。

  • 线程和进程一样,也是动态概念,有创建有销毁,存在只是暂时的,不是永久性的。

  • 进程与线程的区别在于进程在运行时拥有独立的内存空间,也就是说每个进程所占用的内存都是独立的

    • 例如:微信运行时,系统会给它一个运行内存。
  • 多个线程是共享内存空间的,但是每个线程的执行是相互独立的,线程必须依赖于进程才能执行,单独的线程是无法执行的,由进程来控制多个线程的执行,没有进程就不存在线程。

    • 例如:我先开启一个发送消息的线程,那么同时还能由一个接收消息的线程。两个线程之间完全独立。

如果程序很多时,内存可能会不够,操作系统为每个进程提供一套独立的虚拟地址空间,从而使得同一块物理内存在不同的进程中可以对应到不同或相同的虚拟地址,变相的增加了程序可以使用的内存。

进程和线程之间的关系有以下四个特点:

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。

  2. 线程之间共享进程中的数据。

  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存, 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。

  4. 进程之间的内容相互隔离。 进程隔离就是为了使操作系统中的进程互不干扰,每一个进程只能访问自己占有的数据,也就避免出现进程 A 写入数据到进程 B 的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信的机制了。

为了提升浏览器的稳定性和安全性,浏览器采取了多进程模型。

  1. 浏览器四大进程

Chrome浏览器进程架构图

从图中可以看出,最新的 Chrome 浏览器包括:

  • 1 个浏览器主进程

  • 1 个 GPU 进程

  • 1 个网络进程

  • 多个渲染进程

  • 多个插件进程

这些进程的功能:

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。

  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。不能读写硬盘上的数据,不能获取操作系统权限。

  • GPU 进程:其实, GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。这个进程可以调用硬件进行渲染,从而实现渲染加速。比如translate3dcss3属性会骗取调用GPU进程从而开启硬件加速。

  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。

  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

所以,打开一个网页,最少需要四个进程:1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程。如果打开的页面有运行插件的话,还需要再加上 1 个插件进程。

浏览器是多进程的,有一个主进程,每打开一个tab页面都会新开一个进程(某些情况下多个tab会合并进程)。

虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:

  • 更高的资源占用:因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构:浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。
  1. 进程和线程的区别

  • 进程可以看做独立应用,线程不能
  • 资源:进程是cpu资源分配的最小单位(是能拥有资源和独立运行的最小单位);线程是cpu调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。
  • 通信方面:线程间可以通过直接共享同一进程中的资源,而进程通信需要借助 进程间通信。
  • 调度:进程切换比线程切换的开销要大。线程是CPU调度的基本单位,线程的切换不会引起进程切换,但某个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
  • 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存、I/O 等,其开销远大于创建或撤销线程时的开销。同理,在进行进程切换时,涉及当前执行进程 CPU 环境还有各种各样状态的保存及新调度进程状态的设置,而线程切换时只需保存和设置少量寄存器内容,开销较小。
  1. 浏览器渲染进程的线程

  1. GUI渲染线程

  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树、构建CSSOM树、构建渲染树和绘制页面
  • 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
  • 注意,GUI渲染线程与JS引擎线程是互斥的,JS 执行时,渲染暂停;渲染时,JS 不执行。
  1. JS引擎线程(单线程)

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

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

  • 定时器setInterval与setTimeout所在线程
  • 浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的, 如果任务队列处于阻塞线程状态就会影响记计时的准确)
  • 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)

注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。

  1. 异步http请求线程

  • 用于处理请求XMLHttpRequest,在连接后是通过浏览器新开一个线程请求。如ajax,是浏览器新开一个http线程
  • 将检测到状态变更(如ajax返回结果)时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入js引擎线程的事件队列中。再由JavaScript引擎执行。
  1. 合成线程

  • 图层划分、图块切分、栅格化
  • transform /opacity 动画在这里执行,不卡主线程
  1. 栅格化线程

  • 把图层变成位图
  • 多线程并行,交给 GPU 加速

三、输入url地址到浏览器显示页面发生了什么

看一张HTTP 请求示意图,用来展现浏览器中的 HTTP 请求所经历的各个阶段。

下面我们来分析下上面的流程图:

当用户在地址栏输入一个地址或者关键字,并按下回车键的时候,意味着当前页面很快要被替换,在这个时候会触发当前页面的beforeunload事件。

然后浏览器的当前tab栏就变成加载状态,变成一个转动的圆圈,此时页面还没有开始改变,需要等到后面“提交文档”后,才会别新内容替换。

浏览器主进程合成完整Url:如果是输入的是地址,比如 “baidu.com”,则自动合成为:www.baidu.com/。

如果输入的是关键字,则使用默认搜索引擎,合成带搜索关键字Url,比如输入:‘hello’,默认搜索引擎为百度,则合成为:www.baidu.com/s?ie=UTF-8&…

然后把完整url发送给网络进程。

网络进程接收到url请求后,先判断是否本地缓存了资源。如果有,则直接返回资源给浏览器主进程,不发起网络请求。如果没有缓存,则进入网络请求。

网络请求之前,先要进行DNS解析,把域名转换成ip,这一步也是先查DNS缓存,如果有当前域名的缓存,则从缓存中直接取对应ip。

如果没有缓存,则从DMS服务器请求ip。然后构建请求体,请求头(包括cookie)等信息,向服务端发送网络请求(建立Tcp链接)。

服务端接收到请求消息后,进行对应操作,然后生成响应数据,发送给网络进程。

网络进程接收到服务器返回的响应数据后,先解析响应头信息,判断状态码是否为重定向(3xx),如果是,则取响应头中Location字段,重新发起请求。

如状态码为200,表示请求成功,可以继续处理请求。

如果状态码为200,浏览器主进程会根据响应头中的Content-Type字段做出响应对策,如果此字段的值为application/octet-stream,则启动下载流程。

如果Content-Type为text/html,则启动渲染流程。

默认情况下,浏览器会为每一个tab页签创建一个渲染进程,但是如果是同一个站点(根域名+协议相同,端口+子域名不同),则共用一个渲染进程。

进入渲染流程开始前,浏览器主进程会发送一个“接收文档”消息给渲染进程,这里的文档是指存在网络进程里面的响应体信息。

渲染进程接收到“提交文档”的消息后,会和网络进程建立一个通道,接收数据。

渲染进程接收到数据后,开始向浏览器主进程发送“确认提交”,消息

浏览器主进程接收到“确认提交”的消息后,开始更新浏览器页面,包括:地址栏的url,前进后退按钮。

渲染进程开始生成页面,这个过程是一边接收一边生成。当页面渲染完毕后(当前页面及内部iframe都出发了onload事件),发送“渲染完毕”消息。

浏览器主进程接收到消息后,显示页面,并停止标签栏的加载动画。

接下来我们从进程角度讨论一下:从浏览器里,输入URL地址,到页面显示,这中间发生了什么?

从上图可以看到,整个过程需要各个进程之间的配合,我们结合上图我们从进程的角度,描述一下

  1. 浏览器进程接收到用户输入的URL请求,浏览器进程便将URL转发给网络进程。
  2. 网络进程中发起真正的URL请求。
  3. 网络进程接收到响应头数据,便解析响应头数据,并将数据转发给浏览器进程
  4. 浏览器进程接收到网络进程的响应头数据之后,发送 "提交文档" 消息到渲染进程
  5. 渲染进程接收到"提交文档"的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道
  6. 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程
  7. 浏览器进程接收到渲染进程"确认提交"的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

所谓提交文档,就是浏览器主进程,将网络进程接收到的HTML数据提交给渲染进程

四、渲染主线程是如何工作的?

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ......

思考题:为什么渲染进程不使用多个线程来处理这些事情?

要处理这么多的任务,主线程遇到了一个前所未有的难题:如何调度任务?

比如:

  • 我正在执行一个 JS 函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个 JS 函数,执行到一半的时候某个计时器到达了时间,我该立即去执行它的回调吗?
  • 浏览器进程通知我“用户点击了按钮”,与此同时,某个计时器也到达了时间,我应该处理哪一个呢?
  • ......

渲染主线程想出了一个绝妙的主意来处理这个问题:队列

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务

这样一来,就可以让每个任务有条不紊的、持续的进行下去了。

整个过程,被称之为事件循环(消息循环)

五、浏览器渲染流程

下面来分析以下流程图:

渲染进程开始接受到数据的时候,为了提高效率,会先预扫描接收到的数据,如果如果发现有需要加载资源的标签(img,link,外部script等),就先告诉浏览器主进程,先去下载,这个过程叫预解析,这个任务交出去后,就继续做自己本职工作,解析html文件。

-当主线程解析html文件时,会碰到三种类型数据:html标签,css代码,js代码。

html标签:对于普通的html标签,会生成Dom树(标签节点的结构树,是浏览器的内置对象,会有一些内置方法和属性)。

css样式:对于css代码,会根据css的样式选择器构建cssDom树,并对样式进行计算(rem,em转换为px,没有定义样式的提供默认样式),生成computedStyle。

如果遇到的是css外部链接,如果从预解析开始还没下载完,则继续下载,不会阻塞解析。

js代码:对于js代码,会先判断js代码前的css有没有解析完(包括外部css的下载),如果没有则等待css代码下载完并解析完毕,然后再执行js代码。js执行期间阻塞解析。所以步骤是这样:

遇到js -> 阻塞dom树构建 -> css下载 -> css解析->js执行->继续构建dom树

js链接:对于js的链接,如果标签上没有设置异步标志(async/defer),则和普通的js代码一样,下载也会阻塞dom解析,也需要等css下载解析完,但是css下载不会阻塞js下载,步骤如下:

遇到js链接(无异步标签) -> 阻塞dom树构建 -> css下载(同时js下载) -> css解析->js执行->继续构建dom树

如果有异步标签,则下载不阻塞dom树构建,async文件下载完,立即执行。defer文件下载完,等html解析完,按加载顺序执行。步骤如下:

遇到js链接(async) ->下载js(不影响dom构建) -> js下载完毕 -> 立即执行js(走普通js代码流程)

遇到js链接(defer) ->下载js(不影响dom构建) -> js下载完毕 -> 等html解析完毕 -> 按顺序执行js

等dom树和computedStyle都构建完毕后(要都构建完毕), 更具dom树和computedStyle,构建布局树layoutTree,布局树包含每个节点的位置坐标和盒模型的大小,并且剔除了隐藏的节点(样式设置了display:none的节点)。

等布局树layoutTree构建完毕后,我们已经知道了页面上要显示的每个节点的大小,位置和样式。继续来主线程会对节点进行分层,通过遍历layoutTree构建图层树layerTree。哪些节点会被分为一层呢?分为两种情况:

拥有层叠上下文属性的元素会被单独提升为一层(什么是层叠上下文),包含设置了z-index,transform,will-change,filter,opacity<1,flex子元素等等。

需要裁剪的地方会被分为一层,即元素的大小被限制,而内容超出元素大小,内容被裁剪。

图层树layerTree被创建后,会为每一个图层创建绘制指令列表,可以再浏览器调试窗口的layers标签下查看分层和指令列表信息。渲染进程的主线程把绘制指令生成后,并不执行,而是转交给合成线程。

合成线程先把图层分为图块(大小通常为256256/512512),然后把浏览器用户视口附近的图块优先交给栅格化线程来生成位图。

栅格化的最小执行单位是图块,即最少要把一个图块栅格化。栅格化的过程通常会用GPU执行,就是说栅格化线程会把绘制图块的指令发送给GPU,然后GPU生成图块的位图(像素点的颜色值),存在GPU内存。

当视口附近所有图块栅格化完毕后,合成线程发送DrawQuad指令给浏览器主进程,浏览器主进程把页面的内容显示在屏幕上。

原文链接:blog.csdn.net/u010013405/…

渲染时间点

了解什么是渲染之后,我们不由得好奇发问:渲染是在什么时候发生的呢?

当我们在浏览器键入一个URL时,网络线程会通过网络通信拿到HTML,但网络线程自身并不会处理HTML(人家是专注于搞网络的),它会将其生成一个渲染任务交给消息队列,在合适的时机渲染主线程会从消息队列中取出渲染任务执行,启动渲染的流程


  1. HTML文档解析

渲染的第一步是解析 HTML文档

解析过程中中遇到HTML元素会解析HTML元素最终生成DOM树,遇到 CSS 会下载并解析 CSS,遇到 JS会暂停解析HTML,而是去下载并执行 JS。为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载 HTML 中的外部 CSS 文件和 外部的 JS 文件。

因为浏览器无法直接理解和使用html,所以需要将html转换为浏览器能够理解的结构——DOM树。 在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。

生成DOM树

解析的过程中遇到HTML元素会解析HTML元素最终生成DOM树

生成CSSOM树

解析的过程中遇到style标签link元素行内样式CSS样式,会解析CSS生成CSSOM树

CSS不会阻塞HTML解析

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

CSSOM构建

  • 不会阻塞DOM树的构建(现代浏览器)
  • 但会阻塞渲染树构建(必须等待CSSOM完成)
  • 会阻塞后续JavaScript执行(JS可能依赖样式)

JS会阻塞HTML解析

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

  • JavaScript

    • 同步脚本(<script>)会立即阻塞DOM构建
    • 遇到脚本时,必须等待当前所有CSS下载完成才执行(避免JS操作未解析的样式)

script标签中defer和async的区别

如果没有defer或async属性,浏览器会立即加载并执行相应的脚本。它不会等待后续加载的文档元素,读取到就会开始加载和执行,这样就阻塞了后续文档的加载。

下图可以直观的看出三者之间的区别:

其中蓝色代表js脚本网络加载时间,红色代表js脚本执行时间,绿色代表html解析。

其区别如下:

  • 执行顺序: 多个带async属性的标签,不能保证加载的顺序;多个带defer属性的标签,按照加载顺序执行;

  • async:遇到scirpt标签时,浏览器开始异步下载,下载完成后如果此时 HTML 还没有解析完,浏览器会暂停解析,先让 JS 引擎执行代码,执行完毕后再进行解析(可能会阻塞)

  • defer:遇到scirpt标签时,浏览器开始异步下载,html页面解析完才执行js文件。(立即下载,但延迟执行(整个页面都解析完毕之后再执行,不阻塞)

第一步完成后,会得到 DOM 树和 CSSOM 树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在 CSSOM 树中。

  1. 样式计算

渲染的下一步是样式计算

经过HTML解析过后,我们拿到了DOM树和CSSOM树,但是光得到这两颗树还不够,还需要知道每个DOM对应哪些样式。

主线程会遍历得到的 DOM 树,依次为树中的每个节点计算出它最终的样式,称之为 Computed Style

在这一过程中,很多预设值会变成绝对值,比如**red会变成rgb(255,0,0)** ;相对单位会变成绝对单位,比如**em会变成px**。

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

  1. 布局

接下来是布局,布局完成后会得到布局树。

布局阶段会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息。例如节点的宽高、具体位置的x,y坐标,

有了样式信息,但是还不能绘制出来,因为还不知道具体的位置信息和尺寸信息

当修改了节点的几何属性,如大小、位置,就需要重新计算布局,这个过程也叫做回流或者重排(reflow)

获取节点的几何属性时,如 offsetWidth / getBoundingClientRect/clientWidth强制重排

大部分时候,DOM 树和布局树并非一一对应。

比如display:none的节点没有几何信息,因此不会生成到布局树;又比如使用了伪元素选择器,虽然 DOM 树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。还有匿名行盒、匿名块盒等等都会导致 DOM 树和布局树无法一一对应。

  1. 分层

  • 主线程会使用一套复杂的策略对整个布局树中进行分层。
  • 将页面进行分层,之后某个层变化时,就可以单独更新这一个图层,从而避免了全页面的更新,提高效率。
  • 滚动条一般会单独分层
  • 滚动条、堆叠上下文、transformopacity 等样式都会或多或少的影响分层结果,也可以通过will-change属性更大程度的影响分层结果。

  1. 绘制

  • 为每一个分层单独绘制对应的指令集,用来描述当前图层该如何绘制

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成。

  1. 分块

完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成。

合成线程首先对每个图层进行分块,将其划分为更多的小区域。

它会从线程池中拿取多个线程来完成分块工作。

  • 大尺寸图层(如全屏滚动区域)会占用大量的GPU内存,消耗资源效率低
  • 合成线程会调用多个线程把每个分层都分成更小的(Tile),通常为256x246或者512x512像素

  1. 光栅化

上面我们已经获得了文档结构、元素的样式、元素的几何关系、绘画顺序,接下来把这些信息转化为显示器中的像素才能显示,这个转化的过程,就叫做光栅化。此过程是合成器的光栅工作线程把每个块变成位图,位图可以理解成内存里的一个二维数组,这个二维数组记录了每个像素点信息

合成线程会将块信息交给 GPU 进程,以极高的速度完成光栅化。GPU 进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块。光栅化的结果,就是一块一块的位图。

最后一个阶段就是

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

指引会标识出每个位图应该画到屏幕的哪个位置,以及会考虑到旋转、缩放等变形。

变形发生在合成线程,与渲染主线程无关,这就是transform效率高的本质原因。

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

GPU 硬件加速的原理有两个原因:

  • 使用某些css属性后,渲染引擎会把该把元素单独分层交给 GPU 渲染,GPU处理图形计算更快;

  • 分层渲染不会造成页面的回流重绘;

合成线程计算出每个位图在屏幕上的位置,交给GPU进行最终呈现。

其中的quad称为“指引信息”,指明位图信息位于屏幕上的哪一个像素点。

为什么合成线程不直接将结果交给硬件,而要先转交给GPU?

合成线程和渲染主线程都是隶属于渲染进程的,渲染进程处于沙盒中,无法进行系统调度,即无法直接与硬件GPU通信。

沙盒是一种浏览器安全策略,使得渲染进程无法直接与操作系统、硬件通信,可以避免一些网络病毒的攻击。

综上,合成线程将计算结果先转交给浏览器的GPU进程,再由其发送给硬件GPU,最终将内容显示到屏幕上。

👉CSS中的transform是在这一步确定的,只需要对位图进行矩阵变换。

这也是transform效率高的主要原因,因为它与渲染主线程无关,这个过程发生在合成线程中。

  1. 总结

再让我们来回顾一遍完整过程:


六、垃圾回收机制

浏览器的垃圾回收机制是一种自动管理内存的机制,用于检测和释放不再使用的内存,以减少内存泄漏和提高系统性能。

  1. 内存生命周期

内存分配和释放的过程(生命周期)分为以下几个阶段:

  1. 分配内存:当我们申请变量、函数、对象的时候,系统会自动为它们分配内存;
  2. 内存使用:即读写内存,也就是使用变量、函数等;
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存;
  1. 栈内存、堆内存

堆内存(Heap)和栈内存(Stack)是计算机内存的两个主要区域,它们分别用于存储不同类型的数据,

  • 堆(Heap): 堆是用于动态分配内存的区域,用于存储引用类型的数据,如对象和数组。在堆中分配的内存不会自动释放,需要通过垃圾回收机制来回收不再使用的内存。堆的大小通常比栈大,并且可以动态增长和收缩。
  • 栈(Stack): 栈是用于管理函数执行上下文和存储基本类型值的一种数据结构,其分配和释放由执行上下文(Execution Context)的进入和退出决定。

动态数据存储在堆内存中,同时会把其内存地址存到栈内存中。所以如果一个对象的引用存储在栈内存中,即使执行上下文被弹出,这个对象仍然存在于堆内存中,只要还有其他引用指向它,它就不会被垃圾回收机制清除。

  1. 两种基础的垃圾回收机制

  1. 引用计数法

引用计数法:每个对象维护了一个引用计数器,记录着当前有多少个指针指向该对象。当引用计数器减为零时,说明该对象不再被引用,可以被释放。

优势:

  1. 可即刻回收垃圾,当被引用数值为0时,对象在变成垃圾的时候就立刻被回收。
  2. 因为是即时回收,所以程序不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。

缺点:

  1. 时间开销大,因为引用计数算法需要维护引用数,一旦发现引用数发生改变需要立即对引用数进行修改;
  2. 最大的缺点还是无法解决循环引用的问题;

看一个例子,就能很鲜明的看出引用计数存在的缺点了:

function foo() {
  const A = {};
  const B = {};

  A.foo = B;
  B.foo = A;

  return "hello abin";
}

foo();

很明显,上面函数 foo() 内创建了两个对象 AB,并相互引用了对方,形成了一个循环引用。这样即使foo函数执行完,A、B的引用数也不会变为0,就会造成内存泄漏。

解决办法:手动把变量设置为null

  1. 标记清除法(标记整理法、可达性分析法)

标记清除法:从根对象(通常是全局对象,可以理解为windows)开始,遍历内存中所有对象的引用关系,如果是能访问到的对象,则标记为可达对象(无法访问的为不可达对象),标记所有可达对象,最后清除未被标记的对象,实现内存的自动回收。

优点:

  1. 实现简单,打标记也就是打或者不打两种可能,所以就一位二进制位就可以表示;
  2. 解决了循环引用的问题;

缺点:

  1. 内存碎片化(内存零零散散的存放,造成资源浪费);
  2. 再分配时遍历次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端;
  3. 不会立即回收资源;

优化:

为了优化标记清除法内存碎片化的问题,通常会在标记后引入整理阶段,将存活的对象整理到一起,以释放出连续的内存空间,提高内存的利用率。

整个过程为:

标记

整理

清除

七、内存泄漏

内存泄漏指的是程序中未释放不再需要的内存的情况。

  1. 常见可能造成内存泄漏的问题:

  • 不合理的闭包
  • 被遗忘的定时器(setInterval)、requestAnimationFrame;
  • DOM 引用丢失(DOM 被移除了,但是绑定在其身上的各类事件还在);
  • 比如一些音视频的播放器,在不使用时,要及时的 destory 而非简单的移除其 DOM 元素;
  • 未清理的console打印;
  • 被忽视的全局变量引用;
  • 第三方库的引用与销毁;
  • ES6 的一些语法的使用:Map、Set 等。要及时清除引用或者使用弱引用的weakmap、weakset,例如下:
const mySet = new Set();
const obj = { key: 'value' };
mySet.add(obj);
// 在不再需要 obj 时,手动删除它
mySet.delete(obj);

const weakSet = new WeakSet();
const obj = { key: 'value' };
weakSet.add(obj);
// 不再需要 obj 时,WeakSet 会自动处理

2. ### 内存泄漏问题定位

利用 Chrome 的 DevTools 可以很容易排查内存泄漏问题。主要是用 Performance性能 面板和 Memory内存 面板。

  1. 利用 Performance 工具排查是否存在内存泄漏问题

打开 Performance 面板,勾选内存选项(默认是不勾选的),即可开始收集内存随时间的变化曲线,如下图中框选的蓝色趋势部分,如果该趋势走向趋于平稳,则内存回收正常,否则即可能存在内存泄漏问题。

内存选项旁边的扫帚图标可以手动进行GC(垃圾回收)

  1. 利用 Memory 面板定位问题

Memory 面板有三个选项:堆快照(Heap Snapshot)、内存时间轴(Memory Timeline)、内存分配采样(Allocation)

一般常用堆快照和内存时间轴。

堆快照可以捕获网页的内存快照,并提供详细的内存信息和统计数据。如果你已经大概猜到了哪里导致了内存泄漏,可以在操作前后捕获内存快照,并进行比较,在增量 > 0点记录中定位问题。

而内存时间轴可以显示网页在时间轴上的内存使用情况,在时间轴上,可以看到有起伏的蓝色和灰色柱状图,其中蓝色代表当前时间线下所占用的内存;灰色表示表示原占用空间得到释放。

录制一段时间之后,结束录制,同样会生成快照。比堆快照更方便的是,你可以查看各个时间段的内存数据以排查问题。

当然你也可以查看最终的内存分配情况定位问题:

  1. 需要关注的Constructor构造函数

可以看到,在内存面板中,堆内存列表列出了很多构造函数,为了快速定位问题,你需要了解下这些常见的构造函数大致代表什么:

  • system、system/Context 表示引擎自己创建的以及上下文创建的一些引用,这些不用太关注,不重要
  • closure 表示一些函数闭包中的对象引用
  • array、string、number、regexp 这一系列也能看出,就是引用了数组、字符串、数字或正则表达式的对象类型
  • HTMLDivElement、HTMLAnchorElement、DocumentFragment等等这些其实就是你的代码中对元素的引用或者指定的 DOM 对象引用