浏览器原理

  1. 1. 目前浏览器架构
  2. 2. HTTP请求流程
    1. 2.1. 浏览器发起 HTTP 请求流程
    2. 2.2. 服务器处理 HTTP 请求流程
    3. 2.3. 浏览器资源缓存(cache)处理
    4. 2.4. 登录状态保持(cookie)
  3. 3. 从输入URL到页面展示
    1. 3.1. 渲染流程
      1. 3.1.1. 构建 DOM 树
      2. 3.1.2. 构建 CSSOM 树
      3. 3.1.3. Layout: 布局阶段
      4. 3.1.4. Layer: 分层
      5. 3.1.5. Paint: 图层绘制,生成绘制列表
      6. 3.1.6. Tiles & Raster: 图块划分 & 栅格化操作
      7. 3.1.7. DrawQuad: 合成和显示
    2. 3.2. 重排 / 重绘 / 合成
  4. 4. JavaScript 代码的执行流程
    1. 4.1. JavaScript 调用栈
    2. 4.2. JavaScript 垃圾回收机制
    3. 4.3. 闭包(Closure)
    4. 4.4. 浏览器的事件循环
    5. 4.5. 宏任务与微任务

目前浏览器架构

浏览器架构

Chrome 浏览器包括 5 类进程:

  • 浏览器进程 x1:负责界面显示、用户交互、子进程管理、存储等功能。

  • 渲染进程 xn:将 HTML、CSS 和 JavaScript 转换为可交互的网页,运行着排版引擎 Blink 和 JavaScript 引擎 V8。Chrome 默认为每个 Tab 标签创建一个渲染进程并运行在沙箱模式下。

  • GPU 进程 x1:浏览器使用 GPU 初衷是为了实现 3D CSS 效果,随后网页、Chrome 的 UI 界面都普遍采用 GPU 来绘制,因此独立出 GPU 进程。

  • 网络进程 x1:独立出来负责页面网络资源加载。

  • 插件进程 xn:插件易崩溃,因此需要通过插件进程来隔离插件的运行。

打开 1 个页面,至少有 4 个进程:浏览器进程、网络进程、GPU 进程、渲染进程,可能有插件进程。

HTTP请求流程

浏览器实际上也是一个应用程序,其核心功能是向服务器请求资源(HTML、CSS、JS、图片等)并渲染成用户可视的页面。浏览器要想与服务器进行交流,就需要HTTP协议来进行规范。HTTP 协议是建立在 TCP 连接基础之上的一种允许浏览器向服务器获取资源的协议。

HTTP请求流程

浏览器发起 HTTP 请求流程

  1. 构建请求

构建请求行信息,如 GET /index.html HTTP1.1

  1. 查找缓存

  2. 准备 IP 地址和端口

使用 DNS 或 DNS缓存 获取对应IP

  1. 等待 TCP 队列

Chrome TCP 队列机制:同一域名最多只能同时建立 6 个 TCP 连接,超出的请求会排队等待

  1. 建立 TCP 连接

  2. 发送 HTTP 请求

HTTP request 的结构

服务器处理 HTTP 请求流程

  1. 返回请求

HTTP response 的结构

  1. 断开连接

  2. 重定向特殊情况

浏览器资源缓存(cache)处理

浏览器会在服务器返回时根据响应头中的 Cache-Control 字段的过期时长来设置资源缓存,下次请求直接读取未过期缓存。若缓存过期,浏览器则会发起网络请求,并在 HTTP 请求头 中带上资源 key,如:If-None-Match:“xxx”,服务器根据资源 key 值判断请求的资源是否有更新,若没有更新则仅返回 304 状态码,有更新则直接返回最新资源。

浏览器资源缓存

登录成功后会生成标识用户身份的字符串并写到响应头的 Set-Cookie 字段里,浏览器解析存到本地,下次请求自动在请求头的 Cookie 字段中添加该值。

cookie

从输入URL到页面展示

从输入URL到页面展示

  1. 用户输入

  2. URL 请求

  3. 准备渲染进程

Chrome 默认会为每个打开的页面分配一个渲染进程,但如果新页面和当前页面属于同一站点(根域名+协议相同)则会复用父页面的渲染进程(process-per-site-instance)。

  1. 提交文档

浏览器进程发出 提交文档消息,渲染进程接收到后会和网络进程建立传输数据的 管道,文档数据传输完成后渲染进程返回 确认提交消息 给浏览器进程,浏览器进程更新浏览器界面状态,并更新 Web 页面。

  1. 解析与渲染

渲染流程

构建 DOM 树

document 即 DOM 结构,区别于 HTML 的是,DOM 是保存在内存中的树状结构,可通过 JavaScript 来查询或修改其内容。

DOM 是表述 HTML 的内部数据结构,它会将 Web 页面和 JavaScript 脚本连接起来,并过滤一些不安全的内容。HTML 解析器 (HTMLParser) 模块负责将 HTML 字节流转换为 DOM 结构。网络进程接收到响应头后会根据响应头中的 content-type 字段来判断文件的类型,若为 text/html,则为该请求创建一个渲染进程。渲染进程准备好后,网络进程和渲染进程之间会建立一个共享数据的管道,HTML 解析器并不是等整个文档加载完成之后再解析,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。

解析过程:首先通过分词器将字节流转换为 Token,分为 Tag Token 和 Text Token,分别表示标签和文本。然后需要将 Token 解析为 DOM 节点并添加到 DOM 树中,HTML 解析器开始工作时,会默认创建一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底,通过不断压栈出栈,最终栈空完成解析。

注:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>1</div>
<script type="text/javascript">
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'test1'
let div2 = document.getElementsByTagName('div')[1]
div2.innerText = 'test2'
</script>
<div>2</div>
</body>
</html>

页面解析的结果为显示 test1 和 2。因为解析 HTML 过程中遇到 <script> 标签时,HTML 解析器会暂停 DOM 的解析(因为可能会操作 DOM),JavaScript 引擎执行 script 标签中的脚本,执行完后 HTML 解析器恢复解析直至生成最终的 DOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>1</div>
<script type="text/javascript" src="./main.js"></script>
<div>2</div>
</body>
</html>
<!-- main.js -->
<!--
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'test1'
let div2 = document.getElementsByTagName('div')[1]
div2.innerText = 'test2'
-->

执行流程不变,执行到 <script> 标签时暂停整个 DOM 的解析,下载并执行 JavaScript 代码,需要注意:JavaScript 文件的下载过程会阻塞 DOM 解析。不过 Chrome 浏览器做了 HTML 预解析优化,当渲染引擎收到字节流后会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,预解析线程会提前下载。如果 JavaScript 文件中没有操作 DOM 相关代码,可以通过 async 或 defer 将该脚本设置为异步加载来优化:

1
2
<script async type="text/javascript" src='main.js'></script>
<script defer type="text/javascript" src='main.js'></script>

使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。

构建 CSSOM 树

  1. 格式转换

当渲染引擎接收到 CSS 文本时(link外部文件、style标签内、内联样式),会执行转换操作将 CSS 文本转换为浏览器可以理解的结构(styleSheets),在浏览器的 console 中可输入命令 document.styleSheets 查看。

  1. 标准化属性值

将css的属性值进行标准化,比如将 2em 转换成 16px,blue 转换成 rgb(0,0,255)等。

  1. 计算 DOM 树中每个节点的具体样式,创建 CSSOM 树

根据 CSS 的 继承 和 层叠 规则计算每个 DOM 节点的样式并被保存在 ComputedStyle 结构内。

  • CSS 加载不会阻塞 DOM 树的解析,但会阻塞 DOM 树的渲染(解析白屏),即阻塞页面的显示,因为需要等待构建 CSSOM 完成后再进行构建渲染树。

  • JavaScript 会阻塞 DOM 生成,而 CSS 又会阻塞 JavaScript 的执行,因此 CSS 有时也会阻塞 DOM 的生成。JavaScript具有修改CSSOM的能力,所以会等待渲染引擎生成 CSSOM,具体例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html>
<head>
<style>
body div{
background-color: red;
width: 100px;
height: 100px;
}
</style>
</head>
<body>
<script type="text/javascript">
document.styleSheets[0].cssRules[0].selectorText = 'body p'
</script>
<div>1</div>
<p>2</p>
</body>
</html>

Layout: 布局阶段

除了 DOM 树和 CSSOM 树中元素的样式,显示页面还需要通过创建渲染树和布局计算来得到 DOM 元素的几何位置信息。

  1. 创建渲染树

在显示之前还要额外地构建一棵只包含可见元素渲染树,遍历 DOM 树中的所有可见节点加到布局中。

  1. 布局计算

计算布局树节点的坐标位置。

Layer: 分层

创建布局后,渲染引擎还要为特定的节点生成专用的图层,并生成对应的图层树(LayerTree)

渲染树的节点默认从属于父节点图层,满足下面两点中一点的元素可被提升为单独的一个图层:

  1. 拥有层叠上下文属性:明确定位属性、定义透明属性、使用 CSS 滤镜等的元素拥有层叠上下文属性。

  2. 需要剪裁(clip)的内容:当内容展示不下被隐藏或出现滚动条时,内容部分会单独创建一个层。

Paint: 图层绘制,生成绘制列表

渲染引擎会对图层树中的每个图层进行绘制,首先会生成绘制列表,可以在开发者工具的 Layers 标签中选择 document 层查看实际绘制列表。

图层绘制并非真正绘出帧图片,而是生成绘制指令列表,绘制过程即完成

Tiles & Raster: 图块划分 & 栅格化操作

绘制列表只是用来记录绘制顺序和指令,主线程会把该绘制列表提交{commit}给渲染引擎中的合成线程进行实际绘制操作。有的图层很大很长,但用户通过视口(viewport)只能看到页面的很小一部分,为了减小开销,合成线程会将图层划分为图块(tile),通常大小为 256x256 或 512x512,合成线程会优先把视口附近的图块栅格化转换成位图。

DrawQuad: 合成和显示

当所有图块都被光栅化,合成线程会生成绘制图块命令DrawQuad并提交给浏览器进程。浏览器进程根据DrawQuad消息绘制到内存中,最后显示在显示器上。

重排 / 重绘 / 合成

重排/重绘/合成

使用 CSS 的 transform 实现动画效果可以避开重排和重绘,直接在非主线程上执行合成动画操作,并不会占用主线程的资源,效率较高,在页面章节会深入讲解。

减少重排重绘的方法:

  1. 使用 class 操作样式,而不是频繁操作 style

  2. 避免使用 table 布局

  3. 批量处理 dom 操作,例如 createDocumentFragment,或使用框架的虚拟DOM

  4. 对 resize 等事件防抖处理

  5. dom 属性读写分离

JavaScript 代码的执行流程

  1. 编译阶段

输入代码经过编译后会生成两部分内容:执行上下文可执行代码

执行上下文(Execution context)是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的 this、变量、对象以及函数等。

编译阶段代码中的变量和函数会被存放到执行上下文中的变量环境对象中,即变量提升(Hoisting)。

  1. 之所以执行变量提升,是因为js代码需要被编译

  2. 变量提升,变量默认值初始化为 undefined

1
2
3
4
5
console.log(a, b);
var b = 1;
function a() { }
var a = 2;
console.log(a, b);

编译后

1
2
3
4
5
6
7
8
9
// 变量环境
var b = undefind;
function a() { }

// 可执行代码
console.log(a, b);
var b = 1;
var a = 2;
console.log(a, b);
  1. 执行阶段

在代码执行阶段 JavaScript 引擎会从变量环境中去查找自定义的变量和函数。

JavaScript 调用栈

  1. 创建执行上下文的场景

  • 执行全局代码,创建页面生存周期内 唯一的全局执行上下文

  • 函数调用,创建函数执行上下文,函数执行结束后销毁

  • 使用 eval 函数时,eval 代码被编译并创建执行上下文

例如:

1
2
3
4
5
6
7
8
// 创建全局执行上下文
function a(){
eval(`
var a = 1
console.log(a)
`); // 创建 eval 执行上下文
}
a(); // 创建 a 函数的执行上下文

调用栈

  1. 栈溢出(Stack Overflow)

调用栈是有大小限制的,当入栈的执行上下文超过一定数目 JavaScript 引擎就会报错,尤其在递归时很容易出现栈溢出,可以通过将递归调用改成其他形式,或使用定时器将任务拆解等方式来避免栈溢出。

JavaScript 垃圾回收机制

JavaScript 是一种自动内存管理的语言。开发者通常不需要手动分配和释放内存。引擎会自动跟踪内存的使用情况,并在不再需要时释放它。这个过程称为垃圾回收。

核心原理:可达性

垃圾回收的核心概念是可达性。从根对象(Roots)出发,通过引用链能够访问到的对象是 可达的(Reachable),它们被认为是“活的”,需要保留在内存中。无法从根对象通过任何引用链访问到的对象是不可达的(Unreachable),它们被认为是“死的”,是垃圾,会被回收。

主要垃圾回收算法

  1. 引用计数(Reference Counting):一种较旧的、简单的策略。

  • 原理:每个对象都有一个引用计数器,记录有多少个引用指向它。当引用计数变为 0 时,对象就被认为是垃圾,可以立即回收。

  • 缺点:无法处理循环引用。如果对象 A 引用了对象 B,对象 B 也引用了对象 A,即使它们已不再被外界引用,它们的引用计数也永远不会变成 0,导致内存泄漏。

  1. 标记-清除(Mark-and-Sweep):这是现代 JavaScript 引擎(如 V8)最主要使用的算法。

  • 标记阶段(Mark):垃圾回收器从根对象开始遍历,标记所有可达的对象。

  • 清除阶段(Sweep):遍历整个堆内存,清除所有未被标记的对象,回收它们占用的空间。

闭包(Closure)

在 JavaScript 中,根据词法作用域规则,内部函数 总是可以访问其 外部函数 中 声明的变量,当通过调用一个外部函数(A)返回一个内部函数(get/set)后,即使该外部函数已经执行结束了,内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量(test1/test2)的集合称为闭包(foo函数的闭包)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function A() {
var test1 = "1"
let test2 = 1
var innerBar = {
get:function(){
console.log(test2)
return test1
},
set:function(value){
test1 = value
}
}
return innerBar
}
var bar = A()
bar.set("2")
console.log(bar.get())

查看闭包

浏览器的事件循环

在介绍事件循环之前,先来看看下面这两段代码。代码一先将元素添加到页面,然后设置元素样式为隐藏;代码二先设置元素样式为隐藏,然后添加到页面。它们实现的功能是一样的都是将一个隐藏元素添加到页面上。但代码一是将元素添加到页面后,再设置样式为隐藏,你认为这样写用户会看到元素隐藏之前闪一下吗?

  • 代码一

1
2
document.body.appendChild(el);
el.style.display = "none";
  • 代码二

1
2
el.style.display = "none";
document.body.appendChild(el);

浏览器在处理任务时,会将任务(不包含强制渲染)处理完成之后才会进行渲染,所以上述的两端代码都会在全部执行之后才会进行渲染,不会出现频闪问题。

浏览器事件循环

强制渲染

  • 使用getBoundingClientRect()offsetTop等属性获取元素位置和尺寸信息。

1
2
const el = document.getElementById("el");
const rect = el.getBoundingClientRect(); // 触发重新渲染
  • 修改样式属性并立即获取,浏览器会强制渲染确保返回正确的值

1
2
3
const el = document.getElementById("el");
el.style.height = "300px";
const h = window.getComputedStyle(el).height; // 获取重新渲染后的高度
  • 获取滚动位置

1
const scrolly = window.scrollY; // 获取垂直滚动位置,触发重新渲染

宏任务与微任务

在浏览器处理任务时,会先执行一个宏任务,当处理完成当前宏任务后,会清空微任务队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 当前代码会输出什么结果?
setTimeout(() => {
console.log('1');
}, 0)

new Promise((resolve) => {
console.log('2');
resolve('3');
}).then((res) => {
console.log(res);
})

new Promise((resolve) => {
resolve('4');
}).then((res) => {
console.log(res);
})

console.log('5');

宏任务(Macrotasks)

  • script整体代码

  • setTimeout/setInterval

  • I/O操作

  • UI渲染

  • 事件回调(如click、scroll等)

  • postMessage

  • MessageChannel

微任务(Microtasks)

  • Promise.then/catch/finally(resolve、reject)

  • MutationObserver

  • process.nextTick(Node.js环境)

  • queueMicrotask