浏览器内核 负责解析和执行网页代码,主要包括绘制页面和处理 JS 两个方面。
浏览器在拿到一段页面代码后,
网络传输,逻辑上是在传输二进制字节流。浏览器在拿到字节流之后,会先根据资源的编码方式(如UTF-8)进行解码,将字节流转化为字符流。 一串 HTML 的字符流,需要经过语法解析,形成节点后,最终生成 DOM 树。
以语法解析一个简单的 HTML 字符串为例:
<div>
<img src="x.png" />
</div>
这个示例只是简单演示一下语法解析的过程,实际上各种字符的组合规则有很多,匹配和解析起来非常复杂。
通过上面的语法解析之后,最终我们可以获得这段代码中的所有节点。
[
{
"id": "666",
"tag": "div"
},
{
"id": "777",
"parentId": "666",
"tag": "img",
"src": "x.png"
}
]
由于在所有子节点中都标识了父节点的 id,所以很容易将这些节点组装成一棵树。
在 DOM 树构建的同时,浏览器还会构建另一个树结构 —— 渲染树,这是由所有可视元素(不包括head、 display: none 的元素)按照显示顺序组成的树,节点的定义如下:
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
}
一个元素的样式包括浏览器默认的的样式、开发者定义的样式,这些样式经过继承、层叠等规则计算之后,会生成节点的 computed style,即 RenderStyle。浏览器将根据节点的 computed style 进行布局和绘制。在 CSS2.0 中,computed style 即为节点的最终样式。而在 CSS2.1 中,节点在绘制前的样式为 computed style,在绘制后为 used style。两者的区别在于,width、height、padding等属性的百分比值在绘制后会被替换为像素值。
RenderLayer 决定了元素在 Z 轴上的展示顺序,元素的层叠等级一般分以下几种情况:
在 CSS3.0 中,还有一些样式会影响元素的层叠等级,常见的有 transform 不为 none、opacity 不为 1、position 不为 static 等,它们与 z-index = 0 基本同级。
渲染树构建完成后,进入布局阶段,浏览器需要为每个节点分配一个应出现在屏幕上的确切坐标。布局方式主要有 4 种:
显示器通常都有固定的刷新频率,一般是 60Hz,也就是每秒更新 60 张图像,这可以在人眼反应范围内实现流畅的动画。更新的图片都来自显卡中的缓冲区,显示器要做的事情就是把缓冲区中的图像不断地切换显示到屏幕上,而 GUI 渲染引擎则要保证每秒能绘制出这 60 帧图像,塞入缓冲区。如果不能绘制完成,则会出现掉帧,动画卡顿的现象。为了避免这种情况,浏览器需要尽力优化每一帧的绘制,比如引入分层与合成。
分层:浏览器在绘制图像时,会先将所有 RenderLayer 相同的元素绘制在同一图层上,有多少种 RenderLayer 便会有多少个图层,这些图层会被缓存起来。 合成:在生成图像时,浏览器会先将这些图层按在 Z 轴上的层叠顺序进行合成,之后再推入显卡缓冲区。
如果没有分层与合成,页面即使只有一小块区域发生动画,浏览器也需要重新绘制整张图像。而在引入分层与合成之后,浏览器只需要重新绘制动画发生的图层,之后再合成新图像就可以了,明显优化了渲染性能。
早期的浏览器厂商并不遵循统一的规范,实现的内核各有不同,出现了很多版本,比如 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 中,异步任务分为宏任务和微任务。 宏任务主要包括:
一次完整的事件循环如下:
正因 JS 的事件循环机制,Node.js 具有高并发高性能的优点。Node.js 在接到异步 IO 请求,会直接交给异步线程去处理,不会阻塞主线程运行,所以可以同时接收大量并发请求。不过服务器的能力是有限的,如果接收的请求太多,服务器可能因为处理不掉以致崩溃,所以目前的技术结构还是会选择更稳定的多线程语言来搭建服务端。
不管什么程序语言,内存的生命周期基本是一致的:
JS 引擎会在开发者定义变量时自动完成内存分配,比如
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 中,引用不止包括 对象对原型的引用(隐式引用)、对象对属性的引用(显式引用),还包括全局/函数作用域对变量的引用。
最初级的垃圾回收算法是引用计数法,即“当一个变量没有被其他对象或作用域引用时,那么回收它”,主要包括两种情况:
function main() {
let a = 1;
console.log(a);
}
main();
function main() {
let a = {b: 1};
return function temp() {
let b = {b: a.b};
console.log(b);
}
}
let m = main();
m();
引用计数法能处理大多数情况下的垃圾回收,但仍有限制,它无法处理循环引用的情况,比如
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 线程会启动垃圾回收,过程如下:
这个算法跳过了挨个回收非活动对象内存和内存整理的过程,但也使得新生代内存的真实可用空间变为一半。由于每次执行清理时都需要将 from-space 中的活动对象复制到 to-space 中,若 from-space 空间太大,复制时间也会随之增长,不符合快速回收的要求,所以新生代区域一般不会太大。
为了保证新生代的空间够用,内存分配时会把占用内存较多的对象直接放入老生代区域。此外,经过多次垃圾回收仍然存活着的新生代对象也会被晋升为老生代。
老生代的内存回收会经历标记、清除、整理三个阶段。 在一次垃圾回收中,当非活动对象被清除掉时,内存中会出现很多碎片空间,老生代需要通过内存整理将这些内存碎片拼凑为一段连续的空间,以便后续的分配。
具体的做法就是把所有存放的对象向前移,占据前面空余的空间。
浏览器的工作原理:新式网络浏览器幕后揭秘 前端浅谈:浏览器渲染原理 浏览器中的页面是如何渲染生成的? 浏览器是如何工作的:Chrome V8让你更懂JavaScript MDN|getComputedStyle MDN|内存管理 ECMA-262