浏览器底层梳理

🔑浏览器原理 | 多进程架构 | 渲染引擎 | JS 引擎

浏览器的主要功能

  • 浏览器的主要功能是向服务器请求并在浏览器窗口中显示选择的网络资源。
  • 该资源通常是 HTML 文档,也可能是 PDF、图像或某种其他类型的内容。
  • 资源的位置由用户使用 URI(统一资源标识符)指定。
  • URI = Uniform Resource Identifier 统一资源标志符

  • URL = Uniform Resource Locator 统一资源定位符(用地址定位

  • URN = Uniform Resource Name 统一资源名称(用名称定位

如来自 RFC3986 的示例:urn:oasis:names:specification:docbook:dtd:xml:4.1.2

浏览器的主要组件

  • 用户界面:包括地址栏、前进/后退按钮、书签菜单等
  • 浏览器引擎:用户界面(UI)和渲染引擎之间的协调和传递
  • 渲染引擎:显示请求的资源内容。如请求 HTML 则解析 HTML 和 CSS
  • 网络:如HTTP请求之类的网络调用
  • UI 后端:用于绘制基本的小部件,如组合框和窗口
  • JavaScript 解释器:用于解析并执行 JavaScript 代码
  • 数据存储:这是一个持久层。浏览器可能需要在本地保存各种数据,例如cookie。浏览器还支持 localStorage、IndexedDB 等存储机制

浏览器的多进程架构

在Chrome中,主要的进程有4个:

  • 浏览器进程 (Browser Process) :负责浏览器的TAB的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
  • 渲染进程 (Renderer Process) :负责一个Tab内的显示相关的工作,也称渲染引擎。
  • 插件进程 (Plugin Process) :负责控制网页使用到的插件
  • GPU进程 (GPU Process) :负责处理整个应用程序的GPU任务

多进程架构优化

Renderer Process作用是负责一个 tab 的显示相关工作,不同的 tab 进程之间的内存无法共享,但是时常是需要共享的

四种进程模式

所以为了节省内存,Chrome提供了四种进程模式(Process Models):

  • Process-per-site-instance (default) - 同一个 site-instance 使用一个进程
  • Process-per-site - 同一个 site 使用一个进程
  • Process-per-tab - 每个 tab 使用一个进程
  • Single process - 所有 tab 共用一个进程
  • site 指的是相同的 registered domain name 和 scheme
  • site-instance 指的是一组 connected pages from the same site
    • 用户通过<a target="_blank">这种方式点击打开的新页面
    • JS代码打开的新页面(比如 window.open)、

默认模式选择

Process-per-site-instance兼容了性能与易用性:

  • 相较于 Process-per-tab,能够少开很多进程,就意味着更少的内存占用
  • 相较于 Process-per-site,能够更好的隔离相同域名下毫无关联的 tab,更加安全

浏览器内核和 JS 引擎

  • 以前人们常把浏览器内核分为渲染引擎和 Javascript 引擎。后面有了更明确的区分,浏览器内核单指渲染引擎,Javascript 引擎独立了出来
  • 浏览器内核也就是渲染引擎, 是浏览器最核心的部分, 负责对网页语法的解释并渲染(显示)网页
  • Javascript 引擎的主要工作是将Javascript代码转换为快速优化的机器码,以便浏览器或服务器能够解释和执行。另外它还负责执行代码、分配内存以及垃圾回收

浏览器会经历什么

导航

导航是加载网页的第一步,指的是当用户通过点击一个链接、在浏览器地址栏写下一个网址、提交一个表单等方式请求一个网页时发生的过程

Browser Process 可以划分出不同的工作线程:

  • UI thread:控制浏览器上的按钮及输入框;
  • network thread:处理网络请求,从网上获取数据;
  • storage thread: 控制文件等的访问;

DNS 查询

  • 导航到网页的第一步是查找该页面的资源所在位置(HTML、CSS、Javascript 和其他类型的文件)
  • 实际上所做的是询问其中一台服务器并要求找出哪个IP addresshttps://example.com名称相对应
  • 如果此前从未访问过该站点,必须进行 DNS 查询;初次查找后,IP 地址可能会被缓存一段时间,多次访问同一网站会更快

TCP 握手

通过三次握手建立可靠的 TCP 连接

TLS 协商

在已建立的 TCP 连接上协商加密参数和交换密钥,建立安全的加密通信通道

读取响应

可以打开控制台的 Network 选项卡看看响应头包含的内容,初始请求的响应包含收到的第一个字节的数据。

  • network thread接收到服务器的响应后,开始解析HTTP响应报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型(MIME Type)
  • 同时浏览器会进行安全检查,如果域名或请求内容匹配到恶意站点,,network thread会提示一个警告页

第一个字节的时间(TTFB)

是指从用户提出请求(在地址栏中输入网站名称)到收到第一个 HTML 数据包(通常为14kb)的时间

TCP 慢启动和拥塞算法

  • 一种平衡网络连接速度的算法,作用是确定流量的最佳速率并创建稳定的流量流
  • 第一个数据包将是 14kb(或更小),其工作方式是逐渐增加传输的数据量,直到达到预定的阈值
  • 客户端从服务器接收到每个数据包后,会以ACK消息响应,如果服务器发送数据包太多太快,将会被丢弃,客户端也不会发送任何消息

渲染引擎的工作

渲染进程中,包含以下线程:

  • 一个主线程(main thread)
  • 多个工作线程(work thread)
  • 一个合成器线程(compositor thread)
  • 多个光栅化线程(raster thread)

一些常见的浏览器引擎

  • Blink
    • 开发者: Google
    • 使用者: Google Chrome, Microsoft Edge (基于Chromium的版本), Opera, Vivaldi, Brave
  • WebKit
    • 开发者: Apple
    • 使用者: Apple Safari, 早期版本的Google Chrome, 早期版本的Opera
  • Gecko
    • 开发者: Mozilla Foundation
    • 使用者: Mozilla Firefox, Thunderbird

HTML 解析

解析是指将程序分析并转换为运行时环境实际可以运行的内部格式

HTML 解析是指浏览器内核的 HTML Parse 将 HTML 转化为DOM树(DOM Tree)

词法分析

将一些输入转换为标签(源代码的基本组件)

词法分析过程结束时的结果是一系列 0 个或多个以下标签:DOCTYPE、开始标签 ()、结束标签()、自闭合标签 () 、属性名称、值、注释、字符、文件结尾或元素中的纯文本内容

构建 DOM

创建 token 后, 基于先前解析的标签创建树状结构 DOM (Document Object Model)

  • 解析器从上到下逐行工作
  • 如果遇到非阻塞资源(如图像、音频、字体)时, 浏览器会向服务器请求这些图像并继续解析
  • 如果遇到阻塞资源(CSS 样式表、在 HTML 的 <head> 部分添加的 Javascrpt 文件或从 CDN 添加的字体),解析器将停止执行,直到所有这些阻塞资源都被下载 (所以建议在 HTML 文件末尾添加<script>标签, 如果保留在<head>中, 应该添加deferasync属性)

预加载器

预加载器的工作原理是在浏览器解析 HTML 的同时,预测并提前加载可能需要的资源,如 CSS、JavaScript、图片、字体等

  • 较轻的解析器会扫描 HTML 以查看需要检索的资源(样式表、脚本), 然后预加载器在后台检索这些资源
  • 预加载器可以并行加载资源, 会根据资源的优先级进行下载

CSS 解析

CSS 解析是指 CSS Parse 将 CSS 转化为 CSSOM Tree(Style Rules),可以用 document.styleSheets 查看除了内联和默认样式之外的所有内部和外部样式表

词法分析和构建 CSSOM

  • CSS 解析器获取字节并将它们转换为字符,然后是标签,然后是节,最后它们被链接CSSOM
  • 浏览器会执行一些称为选择器匹配的操作,这意味着每组样式都将与页面上的所有节点(元素)匹配。
  • 浏览器决定多个 CSS 来源的节点采用哪种规则会根据优先级判断

渲染树

DOM树(DOM Tree)和 CSS规则(Style Rules)通过附加(Attachment)生成渲染树(Render Tree)

  • 渲染树的目的是确保页面内容以正确的顺序绘制元素, 它将作为在屏幕上显示像素的绘画过程的输入
  • 浏览器会从 DOM 树的根部遍历每个可见节点, 一些节点(如脚本或者元标记)是不可见的, 还有一些节点会被 CSS 隐藏(例如display:"none"属性)

布局和回流

计算DOM树中可见元素的几何位置(例如节点的宽高、相对包含块的位置),生成布局树

  • 计算节点在设备视口内的确切位置和大小
  • 每次更改页面中影响布局的 DOM 元素时, 都会触发回流
    • 在 DOM 中添加或删除元素
    • 调整浏览器窗口大小
    • 更改元素的大小、位置

分层和合成

  • 生成布局树之后, 渲染主线程会根据布局树的特点将其转换为层树 (LayerTree)。一般滚动条、a标签、transformwill-change等样式都会影响分层效果,另外,像opacityfilter等属性也能影响分层
  • 层树中的每个节点对应着一个图层, 合成操作在合成器线程这个单独线程上执行, 所以在执行合成操作时, 是不会影响主线程执行的

绘制和重绘

渲染引擎将将页面内容绘制到帧缓冲区(Framebuffer)中,帧缓冲区是一个内存区域,用于存储图像数据,这些图像数据最终会被 GPU 渲染到屏幕上

  • 决定哪些元素可见以及确定位置之后, 就可以把布局阶段计算的盒子转换为屏幕上渲染的像素, 这个阶段也叫做光栅化
  • 改变屏幕上元素的外观时, 都会触发重绘
    • 改变元素轮廓
    • 改变元素背景
    • 改变可见性和不透明度

JS 引擎的工作

一些常见的 JS 引擎

  • V8
    • 开发者: Google
    • 主要用途: Chrome浏览器、Node.js
  • JavaScriptCore (Nitro)
    • 开发者: Apple
    • 主要用途: Safari浏览器、WebKit
  • Chakra
    • 开发者: Microsoft
    • 主要用途: 旧版Edge浏览器

JavaScript 代码是如何处理的

语法检查

JavaScript 代码被转换为抽象语法树(AST), 包括两个子阶段:

  • 词法分析: 将源代码分解为一个个的词法单元(tokens)
  • 语法分析: 将词法单元按照语法规则组合成抽象语法树(AST)

运行

  • 构建 AST 后, 会被翻译成字节码或机器代码, 现代 JavaScript 引擎使用即时编译
  • 对编译后的代码进行优化, 然后执行字节码或机器码
  • 执行后会自动回收不再使用的内存,并处理异步任务

How JavaScript is run in the browser

解释器 (Interpreter)

  • 在开始运行代码之前,不必执行整个编译步骤,只需开始翻译第一行并运行
  • 多次运行相同的代码,必须一遍又一遍做同样的翻译
  • 浏览器最初使用 JavaScript 解释器

编译器 (Compiler)

  • 必须在开始时经历编译步骤,启动时间更长
  • 循环的代码不需要重复翻译,运行更快
  • 有更多时间查看并编辑代码使之运行更快,这个编辑称为优化

JIT (Just-In-Time) 即时编译

基本思想

  • 向 JavaScript 引擎添加了一个新部分,称为监视器(又名分析器)。该监视器在代码运行时监视代码,并记录代码运行的次数以及使用的类型
  • 起初监视器通过解释器运行所有内容。如果相同代码运行次数较少,该代码段称为暖代码;代码运行次数很多称为热代码。

基线编译器 (Baseline compiler)

  • 当函数开始变热,监视器会将其发送出去进行编译,并存储编译结果
  • 函数的每一行都被编译为“存根”。存根按行号变量类型进行索引
  • 基线编译器只进行一些优化,执行时间不会耗费太久

优化编译器(Optimizing compiler)

  • 当某个代码块非常热,监视器会将其发送给优化编译器,创建一个更快的版本
  • 假设由特定函数创建的所有对象都具有相同的属性名称,并且属性以相同的顺序添加
  • 使用监视器观察代码执行收集的信息来做出这些判断。但是判断的假设不一定准确,需要在运行之前进行检查是否有效
    • 若有效,编译的代码将运行
    • 若无效,JIT 会废弃优化的代码,执行返回到解释器或基线编译版本,这个过程称为去优化(退出)
  • 如果代码不断优化然后取消优化,那么它最终会比仅执行基线编译版本慢,所以浏览器都添加了限制,以便在发生优化 / 去优化循环的时候中断

一个优化示例

1
2
3
4
5
6
function arraySum(arr) {
let sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
  • JIT 处理不同变量类型编译生成不同机器代码的问题的方式是编译多个基线存根
    • 如果一段代码是单态的,将获得一个存根
    • 如果是多态的,每个类型组合获得一个存根

所以 JIT 在每次执行该行代码的时候需要不断检查类型,循环中的每次迭代都要提出相同的问题

  • 在优化编译器中整个函数被一起编译,共用的类型检查移动到循环之前,加快执行速度

简单来说,JIT 就是通过监控正在运行的代码并发送要优化的热代码路径,使 JavaScript 运行得更快。这使得大多数 JavaScript 应用程序的性能提高了许多倍。

参考文章