Skip to content

浏览器原理

HTTP 请求示意图

八个阶段:构建请求、查找缓存、准备 IP 和端口、等待 TCP 队列、建立 TCP 连接、发起 HTTP 请求、服务器处理请求、服务器返回请求和断开连接

⭐️ 在浏览器里,从输入 URL 到页面展示,这中间发生了什么?

  1. 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程
  2. 然后,在网络进程中发起真正的 URL 请求
  3. 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程
  4. 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (CommitNavigation)”消息到渲染进程
  5. 渲染进程接收到“提交导航”的消息之后,便开始准备接收 HTML 数据,接收数据的方式是直接和网络进程建立数据管道
  6. 最后渲染进程会向浏览器进程“确认提交”,这是告诉浏览器进程:“已经准备好接受和解析页面数据了”。
  7. 浏览器进程接收到渲染进程“提交文档”的消息之后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

浏览器的渲染流程

  1. 渲染进程将HTML内容转换为能够读懂的DOM树结构。
  2. 渲染引擎将CSS样式表转化为浏览器可以理解的styleSheets,计算出DOM节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令DrawQuad给浏览器进程。
  8. 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。

重排、重绘、合成

  • 重排:更新元素的几何属性

重排需要更新完整的渲染流水线,所以开销也是最大的

  • 重绘:更新元素的绘制属性

重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些

  • 合成:更改一个既不要布局也不要绘制的属性

使用CSS的transform来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率

同名函数、变量处理原则

  • 如果是同名的函数,JavaScript编译阶段会选择最后声明的那个
  • 如果变量和函数同名,那么在编译阶段,变量的声明会被忽略
js
showName() // 1

function showName() {
  console.log(1)
}

var showName = function () {
  console.log(2)
}

showName() // 2

变量提升

function

var

let/const

创建提升

初始化提升

✅ undefined

赋值提升

js
console.log(fn) // [Function: fn]
function fn() {}

console.log(a) // undefined
var a = 1

// 暂时性死区(Temporal Dead Zone)
console.log(b) // ReferenceError: Cannot access 'b' before initialization
let b = 2

垃圾回收

TODO: 经过内存整理之后活动对象在堆中的内存地址就变化了,主线程还处于垃圾回收阶段,此时内存变化是如何更新到相应执行上下文中的呢?

实际上V8对堆中对象的引用是通过叫句柄的对象进行管理的,地址变化会主动在句柄上体现

栈垃圾回收 当函数执行结束,JS引擎通过向下移动ESP指针(记录调用栈当前执行状态的指针),来销毁该函数保存在栈中的执行上下文(变量环境、词法环境、this、outer)。

堆垃圾回收 一、代际假说 1、大部分对象存活时间很短 2、不被销毁的对象,会活的更久 二、分类 V8 中会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。 三、新生代 算法:Scavenge 算法 原理: 1、把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。 2、新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。 3、先对对象区域中的垃圾做标记,标记完成之后,把这些存活的对象复制到空闲区域中 4、完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。 对象晋升策略: 经过两次垃圾回收依然还存活的对象,会被移动到老生区中。 四、老生代 算法:标记 - 清除(Mark-Sweep)算法 原理: 1、标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。 2、清除:将垃圾数据进行清除。 碎片: 对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。 算法:标记 - 整理(Mark-Compact)算法 原理: 1、标记:和标记 - 清除的标记过程一样,从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素标记为活动对象。 2、整理:让所有存活的对象都向内存的一端移动 3、清除:清理掉端边界以外的内存 优化算法:增量标记(Incremental Marking)算法 原理: 1、为了降低老生代的垃圾回收而造成的卡顿 2、V8把一个完整的垃圾回收任务拆分为很多小的任务 1、让垃圾回收标记和 JavaScript 应用逻辑交替进行

栈空间的垃圾回收

通过ESP的向下移动销毁保存在栈中数据

js
function foo(){
    var a = 1
    var b = {name:"极客邦"}
    function showName(){
      var c = 2
      var d = {name:"极客时间"}
    }
    showName()
}
foo()

当执行到第 6 行代码时,其调用栈和堆空间状态图如下所示:

当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP (记录当前执行状态的指针,extended stack pointer)这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程。上面 showName 的执行上下文虽然保存在栈内存中,但是已经是无效内存了。比如当 foo 函数再次调用另外一个函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文。

堆空间的垃圾回收

代际假说:

  • 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 不死的对象,会活得更久

在 V8 中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。

新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。

对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

垃圾回收器的工作流程:(主、副垃圾回收器都是相同的流程)

  1. 标记空间中活动对象非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
  2. 回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  3. 内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。

副垃圾回收器:新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,如下图所示:

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中

主垃圾回收器:

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

由于老生区的对象比较大,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。

标记 - 清除(Mark-Sweep)算法的流程:

首先是标记过程阶段。标记阶段就是递归遍历调用栈,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

当 showName 函数执行退出之后,这段代码的调用栈和堆空间如下图所示:

垃圾的清除过程

上面的标记过程和清除过程就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。你可以参考下图:

从ECMAScript规范来看箭头函数

  1. this是绑定在作用域对象中的,箭头函数的作用域对象中未设置this绑定,所以会按照作用域链向上查找this,也就是 词法this
  2. 箭头函数没有在 进入作用域阶段 初始化arguments变量
  3. 箭头函数没有[[construct]]内部属性,所以不能被new调用