浏览器内核
不同的浏览器有不同的内核组成:
Gecko: 早期被Netscape和Mozilla Firefox浏览器浏览器使用
Trident: 微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink
Webkit:苹果基于KHTML开发、开源的,用于Safari,Google Chrome之前也在使用
Blink:是Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera等
事实上,我们经常说的浏览器内核指的是浏览器的排版引擎:
排版引擎(layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine) 或样版引擎
浏览器内核

-
当我们输入网址去请求资源的时候,浏览器会将域名转换为ip地址,随后通过ip地址去对应的主机上下载对应的资源,
一般情况下,我们会去请求的首页资源为
index.html -
浏览器会通过
HTML Parser将html解析为DOM Tree, 并开始解析,当解析到script标签和link标签,且需要对应的资源的时候,浏览器就会发送请求去请求对应的css文件,或js文件
-
浏览器会通过
CSS Parser将CSS 解析为Style Rules -
浏览器在解析JS代码的时候,会停止HTML和CSS的解析,因为JS可以通过代码的方式去操作HTML和CSS,等到JS解析完毕以后
浏览器才会继续解析CSS和JS,从而减少不必要的操作
-
浏览器继续生成DOM Tree 和 Style Rules后,会将他们进行合并(Attachment)生成最后的Render Tree(渲染树)
-
通过Layout对Render Tree具体如何渲染进行布局,如根据浏览器实际的宽度和高度确定样式值中80%所对应的具体距离值
-
通过Painting模块,对Render Tree进行实际的绘制,最后将绘制后的内容展示在界面上
JS引擎
JS是一门高级的编程语言,这就意味着我们在使用JS的时候,需要一个工具将JS转换为CPU可以直接识别的机器语言,这个工具就是JS引擎
常见的JS引擎
SpiderMonkey: 第一款JavaScript引擎,由Brendan Eich开发(也就是JavaScript作者)
Chakra: 微软开发,用于IE浏览器
JavaScriptCore: WebKit中的JavaScript引擎,Apple公司开发
V8: Google开发,用于Chrome的引擎
浏览器内核和JS引擎
虽然我们经常说的浏览器内核实际指代的是排版引擎
但是实际上,浏览器内核由两部分组成:
排版引擎: 负责HTML解析、布局、渲染等等相关的工作
JS引擎: 解析、执行JavaScript代码
以Webkit为例,其中的排版引擎为webCore,JS引擎为JSCore
V8
-
V8是用C ++编写的Google开源高性能引擎
-
V8可以解析JavaScript和WebAssembly
-
V8可以运行在Chrome和Node.js上
-
V8是跨平台的,可以运行在Linux,Windows,Mac os等多个平台上
-
V8可以独立运行,也可以嵌入到任何C ++应用程序中

浏览器引擎在解析到JS引擎后,会以数据流的形式,将js代码交给js引擎去进行对应的解析
-
parse模块: 对JS引擎进行相应的编码解析,语法分析和词法分析,生成名为tokens的对象数组,最后将tokens数组生成对应的AST抽象语法树
-
Ignitation模块: 解析AST树生成对应的字节码,在解析过程中,会收集对应的信息,如类型信息,将频繁会调用的函数标记为hot函数
-
V8引擎根据用户所在的平台的CPU架构 (不同平台的CPU架构是不同的,例如:window的CPU架构和mac的CPU架构是不同的)
转换为对应的汇编语言,再转换为对于的机器指令去操作CPU来完成我们对应的功能
-
之前所收集到的hot函数,会通过
TurboFun模块,将对应的代码在合适的时机直接转换为优化后的机器码,并缓存起来 -
因为JS是弱数据类型语言,所以在这个过程中,可能因为参数类型的改变,导致之前缓存起来的函数无法再次使用
所以V8也提供了对应的
Deoptimization操作,将优化后的机器码转换为字节码,在根据情况将再次重新转换为合适的机器码
总结:
Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码:
- 如果函数没有被调用,那么是不会被转换成AST的
Ignition是一个解释器,会将AST转换成ByteCode(字节码):
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算)
- 如果函数只调用一次,Ignition会执行解释执行ByteCode
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码:
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能
- 机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是 number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码
- 这就是为什么需要TypeScript来对JS进行类型校验的原因之一

Blink将源码交给V8引擎,Stream以数据流的形式获取到源码并且进行编码转换;
Scanner会进行词法分析(lexical analysis),词法分析会将代码转换成tokens;
接下来tokens会被转换成AST树,经过Parser和PreParser:
- Parser就是直接将tokens转成AST树架构
- PreParser就是我们常说的预解析
生成AST树后,会被Ignition转成字节码(bytecode),之后的过程就是代码的执行过程
预解析
为什么需要预解析
- 预解析又被称之为变量提升
- 并不是所有的JavaScript代码,在一开始时就会被执行,对于不需要立即解析内容,js引擎会对他们仅仅只是进行简单的预编译操作,仅仅只是解析其名称和参数。
- V8引擎实现了Lazy Parsing(延迟解析)的方案,它的作用是将不必要的函数进行预解析,也就是只解析暂时需要的内容(如函数名,参数信息等),而对函数的全量解析是在函数被调用时才会进行
- 预解析过程是在js代码转换为AST的时候进行的,也就是在parse模块工作的时候(编译阶段)被执行
function foo(num1, num2) {
// 这里的add函数是不需要一上来就被解析为AST的
// 只有当foo实际被调用的时候,才需要解析其中的代码和函数
function add(num1, num2) {
console.log(num1 + num2)
}
add()
}
foo(20, 30)
预解析过程
变量的预解析
初始化全局对象
- js引擎会在执行代码之前,会在堆内存中创建一个全局对象
- 这个全局对象名为Global Object,简称为GO
- 该对象在所有的作用域(scope)都可以访问
- 该对象中包含了Date、Array、String、Number、setTimeout、setInterval等全局对象和全局方法
- 还有一个window属性指向自己
- GO会在GEC(全局执行上下文)被创建之前就已经被创建,是最早被创建的内容
// 伪代码如下
var globalObject = {
setTimeout: ()=>{},
setInterval: ()=>{},
Math: {},
Date: {},
window: this
....
}
// 这就是为什么下面三行代码输出的都是window对象
console.log(window)
console.log(window.window)
console.log(window.window.window)
创建执行上下文栈
-
执行上下文栈 (Execution Context Stack,简称为ECS或ECStack)
-
ECS本质上就是平时说的代码的执行上下文栈
创建全局执行上下文
-
当我们执行全局代码的时候,就会创建Global Execution Context,简称为GEC
-
GEC在解析的时候,会被加入到ECS中运行, 也就是进行入栈操作
2.1 在GEC对象中加入一个值为对象的属性VO(variable object), 对于GEC的VO,其值就是GO

2.2 在代码执行之前,将全局变量和全局函数加载到GlobalObject中,但是并不赋值
var num1 = 20
var num2 = 30
var result = num1 + num2
// 上述代码 转换后的GO对象的伪代码如下
var globalObject = {
/*
此处省略 存在的 全局的属性和方法
*/
num1: undefined,
num2: undefined,
result: undefined
}
// 这也就是为什么在变量未定义前可以访问后面才定义的变量
// 且获取到的值是undefined的原因
// 而这一步的过程,我们称之为预解析
// 注意: 只有使用var定义的变量才会进行预解析
console.log(num1)
var num1 = 20
var num2 = 30
var result = num1 + num2

2.3 逐行执行代码
// 最后形成的GO的伪代码如下:
var globalObject = {
/*
此处省略 存在的 全局的属性和方法
*/
num1: 20,
num2: 30,
result: 50
}
var num1 = 20
var num2 = 30
var result = num1 + num2
// 这也就是为什么可以在定义且赋值后
// 可以使用或访问对应的变量的值
console.log(result) // => 50

函数的预解析
// 示例代码
sum(10, 20)
function sum(num1, num2) {
console.log(num1 + num2)
}
sum(20, 30)
编译阶段
和变量的预解析一样,在编译阶段会创建GO, GEStack等,只不过,函数会被视为特殊的数据类型,会在内存中单独开辟一个新的内存空间

-
函数在GO中值存储的是函数名和函数体所对应的地址,而实际存储函数的是另一个地址空间,此时我们就说sum指向(引用)了0x100这块内存空间
-
在函数体中会存储两部分内容
- 当前需要存储函数的函数体
- [[ scope ]] 这是一个内部私有属性,存储的是当前函数的父级作用域,在这里sum函数的父级作用域就是GO对象
执行阶段
// 因为函数体被提前声明好了,所以sum函数可以被正常调用
sum(10, 20)
function sum(num1, num2) {
console.log(num1 + num2)
}
sum(20, 30)
函数被调用了,浏览器才会去解析器内部的代码,所以在此时会和GEC一样,在ECStack中压入一个FEC(函数执行上下文),并开始进行和GEC中一样的操作,只不过此时的VO不在指向GO,而是指向一个新的对象,称之为AO(当前活跃对象),之后的操作和之前一致
AO对象在函数被解析,并创建函数执行上下文前就已经被解析创建完毕

函数执行完毕
函数在执行完毕以后,会将函数执行上下文和其对应的AO对象,弹出栈,所以此时的内存如下:

如果此时,再次执行sum函数的时候,会创建新的FEC和其对应的AO对象,在重新进行对应的流程
所以在本例中,执行了两次sum函数,所以会创建两个完全独立的FEC和对应的AO
PS:
在ES5即以前的js版本中,在执行上下文中会存在一个VO,类型为对象,所有的属性和方法会挂载到VO对象上
但是在新的ES版本中,VO对象改名为了
VE(variable Envirment),类型也不再局限为对象,可以是map,set等结构来进行实现而其中的每一个存储的属性值,每一个键值对被称之为
ER(Envirment Record)