前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浏览器内核

浏览器内核

作者头像
lonelydawn
发布2022-03-30 14:55:18
9300
发布2022-03-30 14:55:18
举报

浏览器内核

浏览器内核 负责解析和执行网页代码,主要包括绘制页面和处理 JS 两个方面。

绘制网页

浏览器在拿到一段页面代码后,

  1. 当遇到 HTML 时,会将其解析为 DOM 树
  2. 当遇到 CSS 时,会将其解析为 CSSOM
  3. 当遇到 JS 时,会优先执行 JS,之后再解析 HTML 和 CSS;如果 JS 操作了 DOM 或样式,则对 DOM 树和 CSSOM 进行修改
  4. 在解析同时,浏览器会持续将生成的 DOM 树和 CSSOM 进行合成,生成渲染树
  5. GUI 会根据渲染树绘制页面,浏览器的帧率为 60 时,每一帧的绘制间隔约为 16ms

构建 DOM 树

网络传输,逻辑上是在传输二进制字节流。浏览器在拿到字节流之后,会先根据资源的编码方式(如UTF-8)进行解码,将字节流转化为字符流。 一串 HTML 的字符流,需要经过语法解析,形成节点后,最终生成 DOM 树。

以语法解析一个简单的 HTML 字符串为例:

代码语言:javascript
复制
<div>
	<img src="x.png" />
</div>
  1. 当匹配到 < 时,进入“标签开始”和“节点开始”状态
  2. 当匹配到 div 时,将其解析为标签
  3. 当匹配到 > 时,退出“标签开始”状态
  4. 当匹配到 < 时,再次进入“标签开始”状态,由于处在 div 的“节点开始”状态,将其父节点标为 div
  5. 当匹配到 img 时,将其解析为标签
  6. 当匹配到 src="x.png" 时,由于处在 img 的“标签开始”状态,将其解析为 img 的属性
  7. 当匹配到 /> 时,将 img 解析为自合闭节点,退出 img 的“标签开始”和“节点开始”状态
  8. 当匹配到 </ 时,进入“标签开始”和“节点关闭”状态
  9. 当匹配到 div 时,将其解析为标签
  10. 当匹配到 > 时,退出“标签开始”状态

这个示例只是简单演示一下语法解析的过程,实际上各种字符的组合规则有很多,匹配和解析起来非常复杂。

通过上面的语法解析之后,最终我们可以获得这段代码中的所有节点。

代码语言:javascript
复制
[
	{
		"id": "666",
		"tag": "div"
	},
	{
		"id": "777",
		"parentId": "666",
		"tag": "img",
		"src": "x.png"
	}
]

由于在所有子节点中都标识了父节点的 id,所以很容易将这些节点组装成一棵树。

构建渲染树

在 DOM 树构建的同时,浏览器还会构建另一个树结构 —— 渲染树,这是由所有可视元素(不包括head、 display: none 的元素)按照显示顺序组成的树,节点的定义如下:

代码语言:javascript
复制
class RenderObject{
  virtual void layout();
  virtual void paint(PaintInfo);
  virtual void rect repaintRect();
  Node* node;  //the DOM node
  RenderStyle* style;  // the computed style
  RenderLayer* containgLayer; //the containing z-index layer
}
RenderStyle

一个元素的样式包括浏览器默认的的样式、开发者定义的样式,这些样式经过继承、层叠等规则计算之后,会生成节点的 computed style,即 RenderStyle。浏览器将根据节点的 computed style 进行布局和绘制。在 CSS2.0 中,computed style 即为节点的最终样式。而在 CSS2.1 中,节点在绘制前的样式为 computed style,在绘制后为 used style。两者的区别在于,width、height、padding等属性的百分比值在绘制后会被替换为像素值。

RenderLayer

RenderLayer 决定了元素在 Z 轴上的展示顺序,元素的层叠等级一般分以下几种情况:

在 CSS3.0 中,还有一些样式会影响元素的层叠等级,常见的有 transform 不为 none、opacity 不为 1、position 不为 static 等,它们与 z-index = 0 基本同级。

布局

渲染树构建完成后,进入布局阶段,浏览器需要为每个节点分配一个应出现在屏幕上的确切坐标。布局方式主要有 4 种:

  • 正常流布局,盒子模型定义了元素在文档流中的排列方式
  • 脱离文档流,浮动和定位属性描述了元素在页面上的位置
  • 弹性盒布局,flex 等属性决定了元素在主轴和交叉轴上的表现
  • 网格布局,grid 等属性决定了元素在网格行和列上的表现

分层与合成

显示器通常都有固定的刷新频率,一般是 60Hz,也就是每秒更新 60 张图像,这可以在人眼反应范围内实现流畅的动画。更新的图片都来自显卡中的缓冲区,显示器要做的事情就是把缓冲区中的图像不断地切换显示到屏幕上,而 GUI 渲染引擎则要保证每秒能绘制出这 60 帧图像,塞入缓冲区。如果不能绘制完成,则会出现掉帧,动画卡顿的现象。为了避免这种情况,浏览器需要尽力优化每一帧的绘制,比如引入分层与合成。

分层:浏览器在绘制图像时,会先将所有 RenderLayer 相同的元素绘制在同一图层上,有多少种 RenderLayer 便会有多少个图层,这些图层会被缓存起来。 合成:在生成图像时,浏览器会先将这些图层按在 Z 轴上的层叠顺序进行合成,之后再推入显卡缓冲区。

如果没有分层与合成,页面即使只有一小块区域发生动画,浏览器也需要重新绘制整张图像。而在引入分层与合成之后,浏览器只需要重新绘制动画发生的图层,之后再合成新图像就可以了,明显优化了渲染性能。

执行 JS

早期的浏览器厂商并不遵循统一的规范,实现的内核各有不同,出现了很多版本,比如 IE 11 以下的 Trident、Mozilla FireFox 的 Gecko、Opera 的 Presto(已废弃)、Safari 的 Webkit、Chrome 的 Blink(Opera 15 以后)。这些内核的 JS 执行引擎也各不相同,其中比较出名的是 Chrome 的 V8 引擎。 Chrome V8 引擎是一个用 C++ 编写的开源高性能的 JS 引擎,由于它是一个可独立运行的模块,方便移植,已被运用于 Chrome、Node.js、小程序、快应用、electron 应用等各种环境。 与其他 JS 引擎一样,V8 引擎会负责代码解析、事件循环、内存管理等工作,我们主要以 V8 引擎为分析对象来看一下这些内容。

代码解析

由于机器并不认识开发者编写的高级语言代码,只认识进制/汇编等机器代码,所以需要执行引擎先把 JS 转化为机器能识别的语言。 在转化过程中,引擎会将 JS 源码转化为 AST,然后转为 ByteCode,优化后获得 Optimized Machine Code,最后再交给机器执行。

在 Chrome V8 引擎出现之前,JS 虚拟机采用的都是解释执行的方式,而 V8 则引入了解释执行和编译执行混合的 即时编译(JIT) 机制。 解释执行和编译执行的区别在于,解释执行是在执行到代码时才把代码转为机器码去执行,启动快,运行慢;而编译执行则会提前把代码转化好,用到时直接执行,启动慢,运行快。 即时编译则是一种权衡策略。当启动时,V8 将使用解释执行的方式;当一段代码的执行次数超过某一阈值时,V8 会把这段代码标为“热点代码”,并将其编译为执行效率更高的机器代码,之后再遇到这段代码时,V8 会直接使用编译好的机器代码。

事件循环

JS 是单线程运行的,同一时间只能运行一个任务,为了避免耗时较长的异步任务阻塞主线程的运行,V8 等引擎引入了 事件循环 机制。 在 JS 中,异步任务分为宏任务和微任务。 宏任务主要包括:

  • script
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate(Node.js) 微任务主要包括:
  • Promise.then
  • Object.observe
  • MutationObserver
  • process.nextTick(Node.js)

一次完整的事件循环如下:

  1. 拿到一段代码,放入主线程执行;
  2. 遇到同步代码,直接执行;
  3. 遇到微任务,将其推入微任务队列;
  4. 遇到宏任务,将其推入宏任务队列;若该任务是异步 IO 等事件,将其交给对应线程处理,处理结束后,将事件回调推入宏任务队列;
  5. 当这段代码中的同步代码运行完毕后,先执行微任务队列中的任务;
  6. 当微任务执行完成后,调度执行宏任务队列,每一个宏任务都将开启一次新的事件循环

正因 JS 的事件循环机制,Node.js 具有高并发高性能的优点。Node.js 在接到异步 IO 请求,会直接交给异步线程去处理,不会阻塞主线程运行,所以可以同时接收大量并发请求。不过服务器的能力是有限的,如果接收的请求太多,服务器可能因为处理不掉以致崩溃,所以目前的技术结构还是会选择更稳定的多线程语言来搭建服务端。

内存管理

不管什么程序语言,内存的生命周期基本是一致的:

  1. 分配内存
  2. 读/写数据
  3. 回收内存 内存的分配和回收,在底层语言中,需要开发者进行管理;而在像 JS 这种高级语言中,则是由 JS 引擎自动完成的。
分配内存

JS 引擎会在开发者定义变量时自动完成内存分配,比如

代码语言:javascript
复制
var a = 123;
var b = {};
var c = [];
var d = new Date();
var e = document.createElement('div');
function f () {}

这些变量会被存放在内存空间中的 栈(Stack)堆(Heap) 中。 栈的特点是先进后出,空间固定,用于存放 String、Number、Boolean、null、undefined、Symbol 这些基本数据类型;堆的特点是按地址取值,空间大小不固定,用于存放 Array、Object、Function 等引用数据类型,这些引用类型变量的地址会被存放在栈中;池用于存放常量。

当使用基本类型数据时,直接在栈中读写即可,效率较高;而当使用引用类型数据时,则要先从栈中读取变量地址,然后到堆中寻址读写。

回收内存

当一个变量不再被引用了,JS 引擎会自动回收掉它所占用的内存,这个过程被称为 垃圾回收(Garbage Collection)。 在 JS 中,引用不止包括 对象对原型的引用(隐式引用)、对象对属性的引用(显式引用),还包括全局/函数作用域对变量的引用。

引用计数法

最初级的垃圾回收算法是引用计数法,即“当一个变量没有被其他对象或作用域引用时,那么回收它”,主要包括两种情况:

  1. 仅被函数作用域引用的变量,当函数执行结束时,该变量需要被回收,如
代码语言:javascript
复制
function main() {
	let a = 1;
	console.log(a);
}
main();
  1. 被其他对象引用的变量,当这些对象被释放时,由于该变量不再被引用,所以需要被回收,如
代码语言:javascript
复制
function main() {
	let a = {b: 1};
	return function temp() {
		let b = {b: a.b};
		console.log(b);
	}
}

let m = main();
m();

引用计数法能处理大多数情况下的垃圾回收,但仍有限制,它无法处理循环引用的情况,比如

代码语言:javascript
复制
function main() {
	let a = {};
	let b = {};
	a.b = b;
	b.a = a;
}
main();

在这个例子中,即使 main 函数执行结束,但由于对象 a 和 b 相互引用,引用计数法也无法回收它们占据的内存。

标记-清除算法

在 JS 中,不仅函数是对象,函数的执行上下文也是对象,这个对象在函数执行时被创建,在函数执行结束时被销毁。函数每次执行都会产生一个新的执行上下文,存放在函数的私有属性 [[scope]] 中,它维护着对函数形参和局部变量的引用。

[[scope]] 可以理解为是一个链表节点,存放着函数自身的执行上下文,它的指针指向父级的 [[scope]]。由于父级的 [[scope]] 的指针又指向父级的父级,这样由子到父从下到上,最终将指向全局对象,形成的链表被称为函数作用域链(Scope Chain)。 标记-清除算法正是基于函数作用域链实现的。

标记-清除算法将“变量是否需要被回收”简化为“变量是否可访问”,若一个变量在所有的函数作用域链上都无法被访问,那么它应该被回收。GC 线程将定时执行遍历,将所有不可访问的对象标记为非活动对象,之后将回收掉这些对象占用的内存。

标记-清除算法可以很好地解决循环引用的问题。在上面的例子中,由于 a 和 b 只被 main 函数的执行上下文引用,当 main 执行结束时,执行上下文被销毁,a 和 b 变成不可访问的变量,所以它们会被“标记-清除”。 这个算法也有弊端,它会错误地把所有从根出发无法访问的变量全部回收掉,不过这种情况很少遇到,开发者不用关心。

为什么使用先标记再清除,而不直接清除? 垃圾回收需要访问内存空间,JS 主线程在运行时也需要访问内存空间。为了避免造成冲突,JS 引擎在执行垃圾回收时会暂停主线程的运行(全停顿,Stop-The-World)。 如果采用直接清除的方式,当需要清除的内存很多时,GC 线程会阻塞主线程很长时间,造成卡顿现象。 因此,GC 线程在回收内存时采用先标记,之后逐步清除的方式。

新生代和老生代

为了提高垃圾回收的效率,V8 引擎将堆内存分为了新生代和老生代两个区域。 新生代对象的特点是占用内存少,生命周期短,很多经过一次垃圾回收就会被销毁,比如开发者自定义的局部变量;老生代对象的特点是占用内存多,生命周期长,比如 window、document 等内置对象。针对这两种对象的特点,新生代和老生代两个区域的垃圾回收算法也有所不同。

新生代的垃圾回收

新生代区域的对象生命周期较短,内存回收要求要快,使用牺牲空间换取时间的 Scavenge 算法。 在 Scavenge 算法中,新生代内存分为 from-space 和 to-space,对象的内存只会分配在 from-space 中。当 from-space 内存快被占满时,GC 线程会启动垃圾回收,过程如下:

  1. 遍历 from-space,将存活的对象复制到 to-space
  2. 将 from-space 清空
  3. 将 from-space 和 to-space 互换

这个算法跳过了挨个回收非活动对象内存和内存整理的过程,但也使得新生代内存的真实可用空间变为一半。由于每次执行清理时都需要将 from-space 中的活动对象复制到 to-space 中,若 from-space 空间太大,复制时间也会随之增长,不符合快速回收的要求,所以新生代区域一般不会太大。

为了保证新生代的空间够用,内存分配时会把占用内存较多的对象直接放入老生代区域。此外,经过多次垃圾回收仍然存活着的新生代对象也会被晋升为老生代。

老生代的垃圾回收

老生代的内存回收会经历标记、清除、整理三个阶段。 在一次垃圾回收中,当非活动对象被清除掉时,内存中会出现很多碎片空间,老生代需要通过内存整理将这些内存碎片拼凑为一段连续的空间,以便后续的分配。

具体的做法就是把所有存放的对象向前移,占据前面空余的空间。

参考文献

浏览器的工作原理:新式网络浏览器幕后揭秘 前端浅谈:浏览器渲染原理 浏览器中的页面是如何渲染生成的? 浏览器是如何工作的:Chrome V8让你更懂JavaScript MDN|getComputedStyle MDN|内存管理 ECMA-262

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022/03/09 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 浏览器内核
    • 绘制网页
      • 构建 DOM 树
      • 构建渲染树
      • 布局
      • 分层与合成
    • 执行 JS
      • 代码解析
      • 事件循环
      • 内存管理
    • 参考文献
    相关产品与服务
    云开发 CloudBase
    云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为200万+企业和开发者提供高可用、自动弹性扩缩的后端云服务,可用于云端一体化开发多种端应用(小程序、公众号、Web 应用等),避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档