前言
本人接触前端也有几年的时间了,但一直没系统性的整理相关知识,特别是对于JavaScript的是深入运用,所以准备在此整理并记录相关知识。
JavaScript语言
对于现在的编程语言来说,大致分为三种,分别如下所示:
- 机器语言:0101这类的二进制机器指令
- 汇编语言:mov、ax等汇编指令
- 高级语言:c、c++、java、javaScript
我们使用的JavaScript属于高级语言,所以它是不能被我们计算机直接执行的,他需要被转换成计算机认识的机器语言才能被执行。
一般来说,高级语言会先转换为汇编语言,再转换成机器语言在计算机上执行,高级语言对于我们人类来说是易阅读,接近我们自然语言的,而机器语言是机器能够识别的语言。
浏览器工作原理
首先,不同浏览器是有着不同的内核的,比如Safari和之前的Chrome浏览器内核是webkit,Edge和现在的Chrome的内核是基于webkit开发的一个分支,叫Blink。而一个浏览器内核里面应该包含负责对html、css的解析、布局和渲染,以及包含一个JavaScript引擎负责JavaScript代码的执行。
如下图所示,在我们输入某个网址查看页面的时候,浏览器会先向服务器请求静态资源,首先请求到的是一个html,这时浏览器会对该html进行解析形成dom,如果遇到link标签就去请求css文件,解析并形成一个cssom的css对象(请求css文件不会阻塞html的解析),遇到script标签就去请求js文件(请求js文件会阻塞html的解析,然后去下载和解析该js文件,因为这个js文件可能会操作DOM)。当dom和cssom构建完毕后,经过Attachment形成一个渲染树(render tree),最后,浏览器引擎通过解析该渲染树,在渲染树上运行布局(用来确定渲染树上所有节点的宽度,高度,尺寸,位置信息),然后通过添加或绘制元素从而呈现一个完整的页面
上面只说了浏览器对html和css的处理,而js处理会单独由该浏览器内核的js引擎进行解析。webkit的js引擎叫做JavaScriptCore,而google自己开发了一款强大的js引擎,V8(由c++编写),所以该引擎还可以被嵌入到c++的程序当中,比如nodejs。在nodejs中运行js代码就是由V8进行引擎进行执行的。
如下图所示,在v8引擎中,JavaScript代码会先经过词法分析、语法分析等生成一个AST抽象语法树,然后再生成字节码,最后这个字节码再转换成对应js运行环境的机器码运行。但执行频率高的函数,会经过turbofan这个库转换成经过优化的机器码,因为每次运行的时候都需要把字节码再转换成对应的机器码也是会消耗性能的,如果同一个函数多次被调用再转换成字节码再转换成机器码就重复的消耗了性能。所有对于频繁调用的函数会直接通过turbofan转换成优化后的机器码被调用执行,而不是转换为字节码。但如果遇到类似重载这种函数被多次调用,比如函数为function foo(num1,num2)的参数应该传入两个数字,但使用中传入两个字符串a(str1,str2),那么当js引擎解析的时候会将优化后的机器码进行deotimization ,重新转换为字节码再运行。
为什么要多一步字节码?js运行在的环境不一定在windows上,环境不一样,cpu指令就不一样,js能跨平台得益于转换成可以跨平台的字节码,再由字节码转换成对应平台的机器码。
这里同时给出官方对上面图中parse模块、Ignition模块、TurboFan模块的解释:
Parse模块会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
- 如果函数没有被调用,那么是不会被转换成AST的;
- Parse的V8官方文档:v8.dev/blog/scanne…
Ignition是一个解释器,会将AST转换成ByteCode(字节码)
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition会执行解释执行ByteCode;
- Ignition的V8官方文档:v8.dev/blog/igniti…
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
- 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
- TurboFan的V8官方文档:v8.dev/blog/turbof…
js代码执行过程
首先有下列代码:
var name = "koke";
foo(123);
function foo(num) {
console.log(m);
var m = 10;
var n = 20;
console.log(name);
}
如上图所示,当执行上面代码前,经过v8引擎解析,会先创建一个全局对象(GlobalObject),简称GO,GO可以在程序的任何地方访问,GO是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符,通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性,比如Date、Array、String、Number、setTimeout等。在浏览器中,GO就是window对象。当代码解析过程中,遇到普通变量这些会直接放进GO中并赋值为undefined,当遇到函数时会先为该函数开辟一个堆内存,并将该内存的地址保存进GO当中。
在v8引擎内部,还会有一个执行上下文栈(调用栈,Execution Context Stack,简称ECS),当上面代码执行时,首先会为在全局的代码创建一个全局执行上下文(Global Execution Context(GEC)),GEC会被放进ECS中执行,而GEC被放进ECS执行过程有两个步骤:
- 在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值。这个过程也称之为变量的作用域提升(hoisting)
- 在代码执行中,对变量赋值,或者执行其他的函数
当代码开始执行时,遇到变量,如果有值就对该变量赋值,如果遇到函数,就会为该函数创建一个函数执行上下文(Functional Execution Context,简称FEC),并将该函数执行上下文压入执行上下文ECS中。FEC中会进行三个步骤:
- 在解析函数成为AST树结构时,会创建一个Activation Object(AO),AO中包含形参、arguments、函数定义和指向函数对象、定义的变量。
- 创建作用域链,由VO(在函数中就是AO对象)和父级VO组成,查找时会一层层查找
- 根据不同情况绑定this
所以这里总结一下
- 当一段代码被执行时,先在执行前,js引擎会创建一个全局对象GO
- 为全局的代码块创建一个全局执行上下文GEC,GEC中包含变量对象VO(这里的VO就是GO)、作用域链(scope chain),作用域链中又包含自己本身的VO和父级的作用域链,但因为这里是GO,所以它没有父级作用域链
- 创建完GEC后会将GEC放进执行上下文栈ECS中进行执行
- 执行过程中遇到赋值便赋值,遇到函数会先创建函数的执行上下文FEC,函数执行上下文中包含活跃对象AO,作用域链,和绑定this。这系列做完后会执行该函数的代码块,遇到变量会在自身的VO进行查找,如果找不到就会沿着自身的作用域链进行查找,当执行完毕后函数中定义的变量未被其它地方引用就会将该函数执行上下文出栈,这时该函数对此时的AO对象失去引用,AO对象销毁,该函数的存储地址也失去了引用,进行销毁
备注:以上是基于早期ECMA的版本规范进行解释的,在最新的ECMA的版本规范中,对于一些词汇进行了修改,上面提到的VO对象在最新的ECMA中有另外一个称呼了变量环境VE,VE不在局限为是一个对象,可以是map或者其它表达方式。
结尾
上面提到的各类名词表:
| 名词 | 全名 | 解释 |
|---|---|---|
| GO | Global Object | 全局对象,解析全局代码时被创建 |
| AO | Activation Object | 活跃对象,解析函数代码时被创建 |
| VO | Variable Object | 变量对象,早期ECMA规范中的变量环境,是一个对象 |
| VE | Variable Environment | 变量环境,现在最新的ECMA规范中的变量环境,对应环境记录,不局限于对象 |
| ECS | Execution Context Stack | 执行上下文栈,以栈的形式调用创建的执行上下文 |
| GEC | Global Execution Context | 全局执行上下文,在执行全局代码前创建 |
| FEC | Functional Execution Context | 函数执行上下文,在执行函数代码前被创建 |
| scope | 作用域, 是在程序运行时代码中的某些特定部分中变量、函数和对象的可访问性 | |
| scope chain | 作用域链,多个作用域对象连续引用形成的链式结构 |