前话
Hello!各位大佬,不知道大家在学习JavaScript这门语言时,是如何巩固自己所学的知识呢? 我个人比较喜欢从头回顾一遍知识,再把零散的知识点梳理出来,也就是大家现在看到的这篇文章了。想要掌握JS这门语言,那么当然得理解JS代码执行所经历的过程。接下来,让我们一起从基础开始学习一下。
基础知识
我们都知道,一辆车要想跑得快,那么一定离不开一个好的引擎(engine)。JavaScript代码的执行也是这样,执行引擎是代码运行的一个基础。
-
那么,为什么需要JavaScript执行引擎呢?
这个问题就涉及到一些计算机底层的知识了。首先,对于计算机来说,我们编写的JavaScript代码是高级语言,计算机是不能直接执行的,需要我们将源代码通过 编译器(Compiler) 编译为汇编语言,再经过 汇编器(Assembler) 和 链接器(Linker) 形成可执行的机器代码。在这个过程中,JavaScript引擎不仅仅负责编译代码,还负责执行代码、资源分配和垃圾回收等操作。 -
有哪些JavaScript引擎呢?
- V8(Google),以C++编写,凭借优秀的执行速度和高效的内存管理而著称。
- SpiderMonkey,由Mozilla开发,用于Firefox浏览器,支持ECMAScript标准。
- JavaScriptCore :Apple开发的引擎,主要用于Safari浏览器。
今天我主要以V8引擎来梳理JavaScript代码的执行机制,搞懂了这些,就开始正式分析了。
1.解析阶段(Parsing phase)
前面我们提到过,程序员编写的代码是不能够执行丢给计算机执行的,需要先处理一下下。
-
词法分析(Lexical Analysis) :首先,源码会被V8引擎解析成一系列元素,如:字符串、标识符、运算符等,这些部分通常被称为token(词法单元)。
-
语法分析(Syntax Analysis) :当V8获取到了一系列token序列,就会开始进行语法分析,构造一个抽象的语法树(AST),语法树是一种树形数据结构,每一个结点中存放了一种语法结构,例如:
if语句,for循环......
这里大多是V8内部的一些运作机制,了解即可,接下来的预编译才是重头戏。
2.预编译阶段(Pre-compilation phase)
预编译阶段会经历创建执行上下文对象和声明提升两个过程,他们几乎是同时发生的。
在这之前,我们继续来了解一个概念:作用域。在目前的JavaScript运行环境中,作用域有三种:全局作用域、函数作用域和块级作用域。那他们分别长什么样子呢?
var globalVar = '全局变量'
function foo(){
var LocalVar = '局部变量'
}
for(var i = ; i < 10 ; i++){
let blockVar = '块级作用域内的变量'
}
foo()
简单分析,从三个变量所在位置不同我们不难看出,globalVar(全局变量)所在的区域为全局作用域,LocalVar(局部变量)所在的区域为函数作用域,由let或const类型关键字和变量所在的那行叫做块级作用域,注意{ }内的区域不是块级作用域。当然,变量没有块级变量的说法,这里所说的块级变量只是为了解释哪些区域是块级作用域。块级作用域是ES6之后引入的。
作用域内的规则也很简单,内部作用域可以访问外部作用域,反之则不行。例如我们来看以下代码:
var a = 1;
function foo(){
var b = 2
console.log(a)
}
foo()
console.log(b)
这个例子答案是:运行后会报错,具体为:a:1 ; ReferenceError: b is not defined。
这就是因为
console.log(b)在全局作用域内部执行,而恰好全局作用域内没有变量b,且当V8引擎执行到全局作用域的console.log(b)命令时,却不能够去访问函数作用域(foo内部)的,故报错:b没有定义。当把第6行代码去掉运行结果为a:1,这也就说明:实质上各作用域之间也是有一定的关系的,他们都在一个作用域链上,当在本作用域内找不到一个变量时,会顺着
作用域链去找变量。
再来看一下变例:
var a = 1;
function foo(){
console.log(a)
}
foo()
console.log(b)
var b = 2
这个例子的变化是:将变量b的声明移到了console.log(b)之后,但是我们可以神奇地发现,这样一句代码执行后却没有报错,而是输出:
1.
1 ; undefined2.显然,b的值为undefined,这就说明变量b已经被定义,只是没有被赋值。(undefined是JavaScript中的一种原始数据类型)
3.这个情况的原因是:在对变量进行赋值之前,V8会针对var类型的变量执行一个声明提升的过程,默认将var b = 2;的声明过程提到其所在作用域(全局作用域)的顶端,并为a赋值为undefined
特例:let和const声明的变量不会被声明提升,如果在声明let或const变量之前访问它们,就会形成一个暂时性死区(Temporal Dead Zone, TDZ),这个区域的内容在声明执行之前将被禁止访问。
变量的声明提升过程讲完了,来看看函数的声明提升,其实上述代码这样写也是正确的:
var a = 1;
var b = 2
foo()
function foo(){
console.log(a)
}
console.log(b)
套路很相似,就是将foo()函数的调用放在函数声明的前面时,V8也会默认将函数foo()的声明提到作用域的顶端区域。且函数的声明提升优于变量声明提升。既然知道了V8引擎如何处理这些变量的声明,就可以开始预编译的核心步骤了。
我们先来看一个例子:
function fn(a){
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function (){}
console.log(b);
function d(){}
var d=a;
console.log(d);
}
fn(1)
以上程序执行结果是什么呢?为什么是这个答案呢。思考一下,再对照以下答案:
function () {}
123
123
function () {}
123
看到这,不知道大家是否很疑惑,不是说V8会对变量进行声明提升吗?为什么第2行结果输出不是 123???
各位,这里情况就更复杂了,因为不止有变量a的声明,还有函数a的声明,这里就有一些赋值顺序需要我们深入底层去推敲了,不过大家不用担心,这个过程很好理解。
函数中的预编译
首先,我们应当明白,大部分代码都被括在了函数fn形成的函数作用域里,我们先来看看函数的预编译机制。
1.V8为创建该函数的执行上下文对象AO(Activation Object)。
执行上下文对象是一个抽象的概念。从逻辑上来说,创建执行上下文对象是在进行变量的声明提升之前的一步操作,这个过程会为代码中的变量、形参和函数体创建一个环境,包括变量环境,词法环境和作用域链等。
定位到上述例子,可以写出如下伪代码:
function fn(a){
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function (){}
console.log(b);
function d(){}
var d=a;
console.log(d);
}
fn(1)
AO{//函数的执行上下文对象
}
2.找形参和变量声明,将形参和变量名作为AO的属性,值为undefined
从代码来看,形参为a,变量声明有var a;var b;var d,则AO中可以这样写:
AO{
a:undefined,
b:undefined,
d:undefined
}
3.将实参和形参统一
全局作用域中调用了函数fn,传入了实参1,故将形参a的值和1统一后,AO变成了这样。
AO{
a:1,
b:undefined,
d:undefined
}
4.在函数体内找到函数声明,将函数名作为AO的属性名,值赋予函数体
找出函数声明function a() {},找出来后修改AO的属性值。
AO{
a:function a() {},
b:undefined,
d:undefined
}
正式执行
第一个 console.log(a)打印:function a() {}
第二个 console.log(a)前,a的值被修改为123,故打印:123,此时AO对象长这样。
AO{
a:123,
b:undefined,
d:undefined
}
第三个console.log(a)时,从AO对象中可以看出,a的值为:123。
第四个console.log(b)前,变量b被修改为:function (){ },故打印:function (){},AO对象长这样。
AO{
a:123,
b:function a() {},
d:undefined
}
第五个console.log(d)前,变量d被修改为a的值:123,故打印123。
看到这,你是不是豁然开朗?只要顺着V8在函数内的预编译步骤,正确分析出各consloe.log()打印的值自然不在话下。但是这还没完,这里只是函数内的预编译,还有全局预编译没分析呢。
全局预编译
我们来看看以下代码会输出什么?
global = 100;
function fn() {
console.log(global); //undefined
global = 200;
console.log(global); //200
global = 300;
}
fn();
console.log(global);
var global;
结果我已经在注释中给出,你答对了吗?回答错误也没关系,我们来捋一捋全局的预编译如何执行,分析清楚后也就拿捏了。
1.创建全局的执行上下文对象 GO(global object)
依旧是伪代码:
GO{
}
global = 100;
function fn() {
console.log(global); //undefined
global = 200;
console.log(global); //200
global = 300;
}
fn();
console.log(global);
var global;
2.在全局找变量声明,变量名作为GO的属性名,值为undefined
经过这个步骤,GO对象变为如下状态:
GO{
global:undefined
}
这里将全局变量声明找到填进去即可。
3.在全局找函数声明,函数名作为GO的属性名,值为函数体
我们找到了函数fn的声明。
GO{
global:undefined,
fn:function () {}
}
正式执行
1.首先,global被赋值为100,GO被改变。
GO{
global:100
}
2.接着,到了第14行:fn(),函数入口,但是再我们看来,函数体的执行上下文对象还没定义呢,故在进入fn()后我们创建函数的执行上下文对象AO,继续进行上面说的函数预编译。
这里简单提一下,AO最终为:
AO{}
对,你没看错,就是空。
3.执行函数fn内部代码时,先打印global,此时根据:内部作用域可以访问外部作用域,反之则不行,可知,global最后在全局作用域找到了,值为100。
4.后续也涉及到了变量global的赋值和打印
global = 200;
console.log(global); //200
这里第一行修改global时,会先在函数体fn内部找是否声明了变量,但是可以发现AO内部空空如也,自然需要继续到全局作用域找寻global变量,找到后,会将值赋给全局执行上下文对象里的属性global,故这里打印:200。
5.到了最后一次打印,结果就为:300,这是因为在函数fn内部的最后一行修改了global的值,且修改的是全局的global变量。
到此,这些代码我们就通过探究内部运行机制得出来结果,这样去梳理一份代码的执行过程,可以说是清晰高效,可以帮助我们确认结果而不是猜测结果。
调用栈
调用栈是一种管理函数调用关系的数据结构。为了帮助更好理解V8引擎预编译的过程,我们有必要从实现该过程的底层数据结构剖析。栈这种输入结构相信大家都不陌生,遵循先进后出FILO(First in last out)的原则。在V8创建执行上下文对象时,会依次将对象压入栈中。
这就是执行上下文对象存放在调用栈内的情况。V8引擎编译代码时,会先进行全局预编译,创建了一个GO(global object)压入栈底,等到执行到函数体时创建AO(Activation Object)压入栈。执行上下文环境中包含了变量环境和词法环境,var型变量和function函数体放在变量环境中,而词法环境中主要是let和const类型的参数。当一个函数或者全局代码被执行完毕以后,会将执行上下文对象销毁,以保证栈空间的充裕。
特别地,如果我们调用如下代码,会发生爆栈的情况:
function foo(){
foo()
}
不难看出,栈空间是有限的,而全局执行上下文对象位于栈底,这提醒我们尽量少定义全局变量,避免长期占用过多内存。为了不污染命名空间,我们也可以利用闭包等技术。
总结
JS代码的执行无处不依赖于V8引擎,其在创建上下执行文对象、声明提升、管理栈空间,垃圾回收等方面具有重要意义。深刻体会预编译步骤是很有必要的,本期有些点并未深入拓展,例如作用域链的概念,在后续都会一一介绍。梳理知识点的过程是对自己的升华,越写的多就越觉得这门语言还有许多问题需要我去一一搞懂,接下来我会持续总结自己所学的知识和大家分享,如果你觉得本期的内容对你有帮助,帮忙点个赞,这将是我继续创作的动力!