代码改变世界

解读ECMAScript[1]——执行环境、作用域及闭包

2010-12-19 23:57 by T2噬菌体, ... 阅读, ... 评论, 收藏, 编辑

前言

首先说明,对于JavaScript这门脚本语言,我是个菜鸟。虽然也写过不少JavaScript代码,但一直是不求甚解,直到最近才开始系统学习这门语言。学习的原因是我即将毕业,过了年就要正式工作了,而我要入职的职位对JavaScript有一定要求,所以我就在准备毕业答辩之余,挤时间学习JavaScript。我学习语言习惯从标准入手,所以我最近研读了《Standard ECMA-262 3th edition》。虽然ECMAScript不完全等价于JavaScript,但是它相当于JavaScript在语言范畴内的标准,详细定义了其实现的特性及行为(JavaScript是ECMAScript的一个实现),所以我相信研读ECMA-262是学好JavaScript的第一步。

在学习中,我发现ECMAScript的标准定义很特别,很多地方容易让人迷惑和混淆,同时也有一些很有意思的特性,所以我决定分几个主题系统总结一下,这第一篇的主题是“执行环境及作用域(Execution Contexts & Scope)”。

这一篇文章(以及其后续文章中),虽然是讨论JavaScript,但只讨论ECMAScript的内容,而不会涉及JavaScript中超出其范围的部分(如DOM、事件等),也就是文章会纯粹着眼于语言特性、行为和标准的角度,特别是ECMAScript与惯用语言(如C、Java、C#)一些不同之处,这也是为什么本文标题叫做《解读ECMAScript》而不是《解读JavaScript》,当然,具体示例代码使用JavaScript。

文章中示例代码的执行结果将以Google浏览器为准,但同时尽量确保示例代码兼容主流浏览器。ECMA-262标准以3th edition为准。

从几个琐碎的例子说起

提到变量的作用域(Scope)大家一定不陌生,一个变量的作用域简单来说就是能访问到这个变量的区域,对于C#语言,相信大家对各种变量的作用域已经烂熟于胸了,但是ECMAScript在作用域的定义上非常特别,例如下面的代码:

var name = "Microsoft";

function funcA(){
    var name = "Google";
    alert(name);
}

funcA(); //Google
alert(name); //Microsoft

其中第一个name是一个具有全局作用域的变量,而第二个name定义在funcA内部,所以其仅在funcA函数内部可见,同时,函数内部定义的变量会覆盖全局作用域内的同名变量,所以有上述执行结果。乍看之下没什么特别,但是如果稍微改变一下代码:

var name = "Microsoft";

function funcA(){
    name = "Google";
    alert(name);
}

funcA(); //Google
alert(name); //Google

这次结果的不同在于最后一行的执行结果不再是“Microsoft”而是“Google”,这说明这次funcA内部引用的变量name不再是一个新的局部变量,而是改变了全局name变量的值(严格来说,不是改变,因为ECMAScript中字符串变量是只读的,funcA实际是销毁了原有的name然后重建了一个同名变量,但是这里可以不考虑这些细节)。再看看第二段代码,唯一的不同是funcA中没有用“var”关键字声明变量name。于是我们可以得出一个结论(而且很多教科书也是这么写的):

在函数内部使用“var”关键字定义的是函数域变量,不使用则定义的是全局变量。

总体上这句话是没错的,不过一会我们从ECMAScript标准的角度会对其进行更深层次的解读。这里我们要先说明另一个较为重要的问题:

ECMAScript中不存在块作用域。

很多语言是有块作用域的,例如下面一段C#代码:

//C#
int sum = 0;
for(int i = 1; i <=10; i++)
{
    sum += i;  
}
int j = i; //报错,变量i仅在for循环体内可访问

这里在for循环中定义的变量i就是块级作用域变量,当在for循环体外访问时,编译器会报错,告诉我们变量i未定义。但是对应的JavaScript代码:

//JavaScript
var sum = 0;
for(var i = 1; i <= 10; i++){
    sum += i;
}
alert(i); //11

则会正确执行,并且输出i的值为11(因为i最后一次自加没有执行循环体,所以最后i为11而不是10)。这说明ECMAScript中是没有块作用域的,所有在块内定义的变量就相当在函数域内定义,遵循与函数域相同的行为准则。通过以上分析,我们可以得到一个关于ECMAScript作用域的基本结论:

ECMAScript中的作用域分为全局作用域和函数作用域,定义在任何函数外部的变量是全局作用域变量,不使用“var”关键字定义在函数内部的变量也是全局作用域变量,只有使用“var”关键字定义在函数内部的变量才是函数作用域变量,函数作用域变量会覆盖同名的全局作用域变量。全局作用域变量的可见区域是整个脚本(除了被同名函数作用域变量覆盖的区域),函数作用域变量的可见区域是函数内部(除了被内部嵌套函数中同名函数作用域变量覆盖的区域)。

ECMAScript作用域模型

如果仅仅只有上一节内容,完全看不到有什么有趣的东西,那么下面我们深入到ECMAScript标准内部,探究ECMAScript的作用域模型是什么样子的。

执行环境(Execution Context)

《Standard ECMA-262 3th edition》中有这么一句话:

When control is transferred to ECMAScript executable code, control is entering an execution context. Active execution contexts logically form a stack. The top execution context on this logical stack is the running execution context. —— p37

简单来说,这句话的意思就是当执行控制流进入ECMAScript可执行代码(Executable code)时,控制流就进入了一个执行环境(Execution context),活动的执行环境组成一个逻辑上的栈结构,且当前正在执行代码的执行环境总是在栈顶。

单这么看这句话是比较迷惑的,所以我要结合Standard ECMA-262中其它内容对它做一些解释。Standard ECMA-262可执行代码(Executable code)分为三类:全局代码(Global code)、Eval代码(Eval code)和函数代码(Function Code),这里先撇开Eval code不谈,就可以得出如下结论:ECMAScript执行控制流进入全局代码和进入函数代码时,都会创建一个执行环境。这样基本就明了了:

当ECMAScript开始执行时,创建一个全局执行环境;每次进入一个函数时(这个函数被调用),则创建一个当前函数的执行环境并压入栈,离开此函数时从栈中弹出此执行环境并销毁。

至于执行环境具体是什么,我们下面马上讨论。

变量对象(Variable Object)

Standard ECMA-262说明:每一个执行环境有一个与其相关的变量对象,变量对象记录了此执行环境中定义的变量及函数。

我建议将变量对象想象成一个哈希表(Hash table),每一个执行环境指向这么一个hash table,而hash table记录了与这个执行环境有关的一切变量及函数。如果以本文第一段代码:

var name = "Microsoft";

function funcA(){
    var name = "Google";
    alert(name);
}

funcA(); //Google
alert(name); //Microsoft

为例,图1展示了这个模型的逻辑形象。

绘图1

图1

当执行到FuncA内部的语句时,可以认为执行模型如图1所示(当然,图1还不完整,以为有些概念还没有介绍,我们下面补充),其中执行环境栈中压入了两个执行环境,分别指向FuncA的变量对象和全局变量对象。每个变量对象如同一个hash table,保存着这个执行环境下所定义的变量和函数。可以看了,这里变量对象中有几个特殊的成员,如“this”和“arguments”,“this”保存的是指向触发此执行环境的对象,而“arguments”保存的是函数的参数(全局执行环境没有)。

到这里,应该基本能明白执行环境和变量对象的概念了,下面介绍最后一个重要概念——作用域链。

作用域链(Scope Chain)

如果将上面的代码中funcA内部的name变量定义去除,即改为如下代码:

var name = "Microsoft";

function funcA(){
    // var name = "Google";
    alert(name);
}

funcA(); //Microsoft
alert(name); //Microsoft

这里代码依然没有错误,只是funcA会调用全局变量name。那么关键就来了:ECMAScript执行模型是如何知道何时该使用函数域变量,何时该使用全局变量的呢?这就涉及到ECMAScript执行模型中另一个重要的概念:作用域链(Scope Chain)。其中每个执行环境都会关联一个作用域链,如果形象一点,你可以将作用域链想象成一个指针栈,其中每个元素都是指向某个变量对象的指针,从从栈顶到栈底,指向的对象变量依次是当前环境变量对象,上一层环境变量对象,…,最后一个指向全局环境变量对象。图2是对图1增加作用域链后的图示:

绘图2

图2

如图2所示,FuncA内部有一个特殊的[[scope]]指针,指向FuncA的作用域链,这个指针在很多浏览器中被实现为“__scope__”,然后,FuncA的作用域链中有两个元素分别指向当前环境变量对象和全局变量对象。

知道了这个,上面的问题就好解释了。

当ECMAScript解释器寻找一个变量或函数时,遵循如下算法:首先在scope0指向的变量对象中找,找到则返回,找不到则到scope1中找,还招不到则到scope2中找……,如此循环,直到找到第一个符合的变量或函数,如果直到全局变量对象都没有找到,则报一个“标识符不存在”的错误。

知道了这些,关于前面示例代码执行结果,我想就无需我多解释了。

以上就是对ECMAScript执行模型的介绍。总结一下:

当ECMAScript开始执行时,创建一个全局执行环境和全局变量对象;后面每次进入一个函数时(这个函数被调用),则创建一个当前函数的执行环境并压入栈,同时创建此执行环境的变量对象和作用域链,作用域链的创建规则是复制上一层环境的作用域链,并将指向本环境变量对象的指针放到链首;离开函数时,从栈中弹出此执行环境并销毁,同时销毁环境的变量对象和作用域链。

解读闭包和匿名函数

闭包可能是JavaScript中最富有传奇色彩的概念了,许多朋友经常听到这个名词,甚至有人说能不能熟练使用闭包是JavaScript高手和菜鸟的重要区别,这句话虽然有点夸张,不过也从一个侧面说明了闭包的重要性。不过很多朋友对闭包的概念总是一知半解,其实闭包并不神秘,不能真正理解闭包关键是没有理解ECMAScript的执行模型。所以,有了上面的铺垫,我想这次我一定能把闭包给你说明白了。

熟悉JavaScript的朋友都知道,JavaScript的“函数”与很多语言的“函数”差别巨大(后续文章会专门研究),例如JavaScript中可以将函数赋值给一个变量,还可以将函数作为参数和返回值,并且支持函数内部嵌套函数。其实从ECMAScript标准的角度来看,JavaScript中的函数其实是一个对象,这些是后话。现在大家结合上面的执行模型想想,如果在函数内部再嵌套一个函数,并将这个函数作为返回值,执行模型会是什么样子?例如下面代码:

var name = "Microsoft";

function funcA(){
    var name = "Google";
    alert(name);
    return function(){
        name = "Facebook";
        alert(name);
    };
}

var o = funcA(); //Google
alert(name); //Microsoft
o(); //Facebook

在这段代码中,我们为funcA添加了一个返回值,它返回一个匿名函数,这个函数将变量name的值改为“Facebook”然后显示。现在结合上面分析的ECMAScript执行模型,对这段代码进行几点分析:

匿名函数引用的name是哪一个变量?

根据上文提到的执行模型,我们可以知道,匿名函数的作用域链应该有三个元素,第一个是匿名函数的变量对象指针,第二个是funcA的变量对象指针,第三个是全局变量对象指针。根据搜索算法,首先搜索匿名函数的变量对象,显然这个对象中不存在name,然后搜索funcA的变量对象,这里面有个“name”,因此将其作为name变量返回,搜索终止,所以,匿名函数引用的是funcA内定义的“name”变量而非全局变量。

但是有一点很奇怪…

如果仔细分析上述代码,会发现一个很奇怪的矛盾:按照ECMAScript执行模型,当

var o = funcA();

执行完后,执行流就会离开funcA, 那么funcA的变量对象就应该被销毁了,funcA内定义的变量name就应该不存在了,为什么在后面还可以访问到呢?要解释这个问题,请回想上文一句话:

作用域链的创建规则是复制上一层环境的作用域链,并将指向本环境变量对象的指针放到链首;

根据这句话我们知道匿名函数的作用域链有三个元素,引用了三个变量对象(上文有说),这里要注意,返回的匿名函数赋值给了变量o,而变量o是一个全局变量,在funcA执行完成后不会被销毁。当赋值的时候,匿名函数的作用域链已然建立,并且此作用域链引用了funcA的变量环境,因此,当funcA执行完毕后,其执行变量和作用域链确实被销毁了,但是其变量对象没有被销毁,因为匿名函数的作用域链对其有引用,其无法被垃圾回收机制销毁。直观一点,图3展示了当funcA执行完毕后,执行模型的样子:

绘图3

图3

从图3可以看出,当funcA执行完成后funcA的Scope chain早就销毁了,但是其变量对象(红色表示),因为被匿名函数的Scope chain引用了,所以没有销毁。因此,匿名函数可以访问其成员,当匿名函数执行完成后,它会连同匿名函数的Scope chain一起被销毁。

这就是闭包

说到这里,闭包就没什么神秘的了,因为我们上面已经使用闭包了,就是那个匿名函数。正式一点说

闭包就是能访问另一个函数作用域中变量的函数。

因为闭包通常通过匿名函数实现,所以经常有人将闭包和匿名函数两个名词混用。闭包的优点很明显,就是可以访问另一个函数域中的变量,缺点也明显,比较耗内存(因为同时保持了N个变量对象)。许多高手通过闭包创造了很多美妙的JavaScript编程模式,如模拟块作用域,模拟私有变量等等,这些在后续文章中会介绍。

关于this

在本篇文章的最后,要讨论一下“this”这个对象。不过说实话,在这篇文章里讨论this有点不妥,因为其实this和作用域没什么关系,但是在使用闭包时经常会误用this。例如下面代码:

var name = "Global name";

function funcA(){
    var name = "FuncA name";
    return function(){
        alert(this.name);
    };
}

funcA()(); //Global name

上面代码中我们在闭包中引用了this,本来我们以为this会指向funcA,谁知弹出的却是“Global name”,说明this指向的是全局对象(具体其实是window对象,后面文章会详细讨论)。要理解为什么,关键是我上面说的一句话:

其实this和作用域没什么关系。

不要想象this会指向上层作用域的对象,其实this就是简单指向调用这个函数的对象。因为上面代码最后一句也可以写成

window.funcA()(); //Global name

所以这里this其实指向window,那么this.name自然就变成

alert(window.name);

因为所有在全局定义域中定义的变量或函数其实会成为window的成员,所以上面就显示“Global name”了。

总之,要撇清this和作用域的关系。

后续会有什么

这一篇文章从标准的角度研究了ECMAScript的作用域问题,并通过执行模型解释了闭包的本质。后续文章还会从不同专题角度解读ECMAScript,例如从类型系统角度,从函数和对象系统角度等等……不过我想2010年很难更新了。因为已经确定12月26号毕业答辩,从明天开始要全身心修改毕业论文以及准备答辩事宜了,北航的答辩淘汰率还是挺高的(接近1/20),我这心挺悬的。只要答辩顺利,我一定会尽快接着写这个系列。我们2011年见!