JavaScript在运行过程中不同于其他语言。如果不了解JavaScript的词法环境、执行上下文等,在开发过程中很容易产生bug,比如this指向与预期不一致,某个变量不知何故被更改等等。那么今天我们就来说说JavaScript代码的运行过程。大家都知道JavaScript代码需要在JavaScript引擎中运行。当我们说到JavaScript运行时,我们经常会提到执行环境、词法环境、作用域、执行上下文、闭包等,这些概念看起来很相似,但似乎不太容易区分清楚。他们描述了什么?这些词与JavaScript引擎执行代码的过程有关。为了弄清楚这些概念的区别,我们可以回顾一下下面JavaScript代码运行过程中的各个阶段。JavaScript代码运行的各个阶段JavaScript是一种弱类型语言,变量类型只能在运行时确定。JavaScript引擎在执行JavaScript代码时,还会自上而下进行词法分析、句法分析、语义分析等,代码分析完成后生成AST(抽象语法树),最终生成机器码,可以由CPU根据AST执行并执行。这个过程,我们称之为句法分析阶段。除了语法分析阶段,JavaScript引擎在执行代码时还会进行其他处理。以V8引擎为例,JavaScript代码在V8引擎中的运行过程主要分为三个阶段。句法分析阶段。在这个阶段,代码将被解析以检查语法错误(SyntaxError)。如果发现语法错误,将在控制台抛出异常并终止执行。编译阶段。这个阶段会创建执行上下文(ExecutionContext),包括创建变量对象、建立作用域链、确定this的指向等。每次进入不同的运行时环境,V8引擎都会创建一个新的执行上下文。执行阶段。在编译阶段创建的执行上下文被压入调用栈,成为运行的执行上下文。代码执行结束后,将其从调用栈中弹出。其中语法分析阶段属于编译器的一般内容,不再赘述。前面提到的执行环境、词法环境、作用域、执行上下文等都是在编译和执行阶段产生的概念。执行上下文的创建执行上下文的创建离不开JavaScript运行环境。JavaScript运行时环境包括全局环境、函数环境和eval。全局环境和函数环境的创建过程如下:第一次加载JavaScript代码时,首先创建一个全局环境。全局环境是最外层,直到应用程序退出(例如关闭浏览器和网页)才会被销毁。每个函数都有自己的运行环境,调用函数时会进入函数的运行环境。当环境中的所有代码都执行完后,环境就会被销毁。不同的功能在不同的环境中运行。即使同一个函数在多次调用时也会创建多个不同的函数环境。在不同的运行环境中,函数可访问的变量和其他数据范围不同,环境的行为(如创建和销毁)也不同。每次JavaScript进入不同的运行时环境,JavaScript都会创建一个新的执行上下文。该过程包括:建立范围链(ScopeChain);创建可变对象(VariableObject,简称VO);确定这一点。由于在建立作用域链的过程中涉及到变量对象的概念,所以我们先看变量对象的创建,再看建立作用域链以及确定这个方向。CreateVariableObjectVariableObject(VO)每一个执行上下文都会有一个关联的变量对象,它会保存在这个上下文中定义的所有变量和函数。在浏览器中,全局环境的变量对象是window对象,所以所有的全局变量和函数都是作为window对象的属性和方法创建的。相应的,Node中全局环境的变量对象就是全局对象。创建VO创建变量对象的过程会创建一个arguments对象(仅限函数环境),会检查当前上下文的函数声明和变量声明。对于变量声明:此时会为变量分配内存,并初始化为undefined(这个过程只做定义声明,执行阶段执行赋值语句)。对于函数声明:这时候会在内存中创建一个函数对象,直接初始化为函数对象。变量声明和函数声明的过程就是我们常说的变量提升和函数提升,其中函数声明提升会优先于变量声明提升。因为变量提升很可能会导致变量被意外覆盖的问题,也可能会导致该销毁的变量没有被销毁。因此,ES6中引入了let和const关键字,让JavaScript也有了块级作用域。作用域在各种编程语言中,作用域分为静态作用域和动态作用域。JavaScript使用词法作用域(LexicalScoping),即静态作用域。词法范围内的变量在编译期间将具有确定的范围。词法作用域中的变量在编译过程中会产生一定的作用域。此范围是当前执行上下文。在ES5之后,我们使用LexicalEnvironment而不是scope来描述执行上下文。所以,词法环境可以理解为我们常说的作用域,也就是指当前执行上下文(注意是当前执行上下文)。在JavaScript中,词法环境分为词法环境和变量环境两种,其中:变量环境用于记录var/function等变量声明;词法环境用于记录let/const/class等变量声明。也就是说,在变量创建的时候会进行函数提升和变量提升,JavaScript会通过词法环境来记录函数和变量的声明。JavaScript通过使用两个词法环境(而不是一个)来记录不同的变量声明内容,在不影响原始变量声明和函数声明的情况下支持块级作用域。这是创建变量的过程,它是创建执行上下文的一部分。创建变量的过程会创建一个范围,也称为词法环境。建立作用域链,作用域链就是将各个作用域以某种方式连接在一起。范围是词法环境,词法环境由两个成员组成。环境记录(EnvironmentRecord):用来记录变量对象在其自身的词法环境中。外部词法环境:记录对外部词法环境的引用。通过对外部词法环境的引用,可以逐层扩展作用域,建立由内向外延伸的作用域链。当一个变量在自己的词法环境记录中找不到时,可以根据外部词法环境引用查找外层,直到最外层词法环境中的外部词法环境引用为空。这就是作用域链的变量查询。JavaScript代码的运行过程分为定义期和执行期。上述编译阶段属于定义阶段。代码示例如下:functionfoo(){//定义全局函数fooconsole.dir(bar);vara=1;functionbar(){//在foo函数内部定义函数bara=2;}}console.dir(foo);foo();前面我们说过,JavaScript使用的是静态作用域,所以函数的作用域在定义期间就已经确定了。在上面的例子中,全局函数foo创建了foo的一个[[scope]]属性,它包含了全局[[scope]]:foo[[scope]]=[globalContext];而当我们执行foo()时,也会分别进入foo函数的定义期和执行期。foo函数定义期间,函数bar的[[scope]]会包含全局[[scope]]和foo的[[scope]]:bar[[scope]]=[fooContext,globalContext];运行上面的代码,我们可以在控制台中看到预期的输出:我们可以看到:foo的[[scope]]属性包含全局[[scope]]bar的[[scope]]将包含全局[[scope]]和foo的[[scope]]意味着JavaScript会通过外部词法环境引用创建一个变量对象的作用域链,从而保证执行环境可以访问的变量和函数的有序访问。这个过程除了创建作用域链之外,还会对创建的变量对象做一些处理。在编译阶段,将创建变量对象(VO)。这个过程会进行函数声明和变量声明。此时变量的值被初始化为undefined。代码进入执行阶段后,JavaScript会给变量赋值。这时,变量对象会被转换为活动对象(ActiveObject,简称AO),转换后的活动对象就可以访问了。这就是VO->AO的过程。例子如下:functionfoo(a){varb=2;functionc(){}vard=function(){};}foo(1);foo(1)执行时首先进入定义期,此时:参数变量a1变量b和d的值被初始化为undefinedfunctionc创建函数并初始化AO={arguments:{0:1,length:1},a:1,b:undefined,c:referencetofunction()c(){}d:undefined}前面我们也提到,进入执行期后,会执行赋值语句进行赋值。此时变量b和d会被赋值2,函数表达式:AO={arguments:{0:1,length:1},a:1,b:2,c:referencetofunctionc(){},d:referencetoFunctionExpression"d"}这是VO->AO过程。定义期(编译阶段):对象值仍未定义,处于不可访问状态。进入执行阶段(executionphase):VO被激活,此时会赋值变量属性。实际上,在执行过程中,除了VO被激活外,活动对象还会加上函数执行时传入的参数和自变量,所以AO和VO的关系可以用下面的关系来表示:AO=VO+functionparameters+arguments现在,我们知道作用域链是在代码进入执行阶段时引用外部词法环境创建的。总结如下:在编译阶段,JavaScript在创建执行上下文时会先创建一个变量对象(VO);在执行阶段,变量对象(VO)被激活为活动对象(AO),函数内部的变量对象通过外部词法环境的引用来创建作用域链。通过作用域链,我们可以直接读取函数内部的外部变量和全局变量,但外部环境无法访问函数内部的变量。例子如下:functionfoo(){vara=1;}foo();console.log(a);//undefined我们不能在全局环境下访问函数foo中的变量a,这是因为作用域链全局函数的,不包含函数foo内的作用域。如果我们要访问函数内部的变量,我们可以在函数foo中通过函数bar返回变量a,返回函数bar,这样我们也可以通过调用函数返回的函数bar来访问变量a全局环境下的functionfoo:functionfoo(){vara=1;functionbar(){returna;}returnbar;}varb=foo();console.log(b());//1当函数执行结束时,执行上下文会被销毁,其中包括作用域链和激活对象。在上面的例子中;b()执行时,包含作用域的foo函数上下文已经被销毁,但是foo作用域下的a仍然可以访问;这是因为bar函数引用了foo函数变量对象,此时即使创建bar函数的foo函数的执行上下文被销毁,其变量对象仍会保留在JavaScript内存中,bar函数可以仍然通过bar函数的作用域链找到并访问它。这是闭包;闭包允许我们从外部读取局部变量,常见的用途包括:函数用于从外部读取其他函数内部的变量;闭包可以用来模拟私有方法;这些变量的值一直保存在内存中。注意,在使用闭包时,不再使用的变量需要及时清理,否则可能会出现内存泄漏。判断this的指向在JavaScript中,this指向执行当前代码的对象的所有者,可以简单理解为this指向最后一次调用当前代码的对象。根据JavaScript中函数调用方式的不同,this的指向分为以下几种情况。在全局环境中,this指向全局对象(浏览器中的窗口)。在函数内部,this的值取决于函数的调用方式。该函数作为对象的方法被调用,this指向调用该方法的对象。该函数用作构造函数。function(使用new关键字),它的this被绑定到正在构造的新对象中,在类的构造函数中,this是一个普通对象,类中所有的非静态方法都会被添加到this的原型中箭头函数,this指向创建它的环境。使用apply、call、bind等方式调用:根据不同的API,可以切换函数执行的上下文,即可以看到this绑定的对象,而this在不同的情况下会有不同的指向。在ES6箭头函数出现之前,为了在一定的运行环境下正确获取this对象,我们经常使用如下代码:varthat=this;varself=this;此代码将变量分配给this,以便于使用。但是会降低代码的可读性,不推荐。通过正确使用箭头函数,我们可以更好地管理作用域。小结今天我们了解了JavaScript代码的运行过程,分为三个阶段:语法分析阶段、编译阶段、执行阶段。在编译阶段,JavaScript会创建一个执行上下文。在执行阶段,变量对象(VO)会被激活为活动对象(AO),并为变量赋值。只有这样才能访问活动对象。执行结束后,作用域链和活动对象都被销毁,使用闭包可以将活动对象保留在内存中。这就是JavaScript代码的工作方式。
