JavaScript变量及其作用域

时间:2011-3-8     作者:smarteng     分类: WEB相关


本文将继续就如下几点做出一些归纳,更多的是对ECMA-262中相应知识点的一些翻译并掺杂笔者的一些拙见,仅供参考。由于对个别专业词汇的解释拿捏不定,在罗列demo的同时,对于结论的后续推断将留给读者自身分析一些余地,见谅。

在X贴过程中,笔者参阅了ECMA-262 (3rd,5th,以下简称文档),Lich_Ray 的中文翻译项目组提供的文档(http://code.google.com/p/ecma-262/ ),一并表示感谢:

  • JavaScript变量基本常识
  • 浅析JavaScript变量作用域

JavaScript变量基本常识

1、js变量定义语法(全局变量、局部变量)

ECMA-262 写道
If the variable statement occurs inside a FunctionDeclaration, the variables are defined with function-local 
scope in that function, as described in s10.1.3. Otherwise, they are defined with global scope (that is, they 
are created as members of the global object, as described in 10.1.3) using property attributes { DontDelete 
}. Variables are created when the execution scope is entered. A Block does not define a new execution 
scope. Only Program and FunctionDeclaration produce a new scope. Variables are initialised to undefined 
when created. A variable with an Initialiser is assigned the value of its AssignmentExpression when the 
VariableStatement is executed, not when the variable is created.

 

文档指出,当在函数声明中声明变量时,变量将作为函数局部变量(准确来说,挂在与该函数相关联的执行环境中变量对象下成为其属性),否则,他们将成为全局变量。变量是在执行流进入函数执行环境时被创建的。文档同时指出,ECMAScript没有块级作用域的概念。只有程序(各种Statement与FunctionDeclaration的集合)或函数声明能创建一个新的作用域。

变量创建后默认被初始化为undefined,如果提供初始化器,则在变量声明被执行的时候赋值(笔者认为赋值的时间点在创建时或在执行时,对编码影响不大,FIXME)。

 

值得一提的是,文档所提到的variable statement,是不能缺少var关键字的,隐藏含义即,缺少var,即使定义(严谨来说不算定义,理解为不声明而直接初始化之)在函数声明中,也是全局变量。给出相关语法:

 

Syntax 写道
VariableStatement : 
var VariableDeclarationList ; 

VariableDeclarationList : 
VariableDeclaration 
VariableDeclarationList , VariableDeclaration 

VariableDeclaration : 
Identifier Initialiseropt 

Initialiser : 
= AssignmentExpression

来看一些demo

Js代码  收藏代码
  1. var  n0; //初始化为undefined   
  2. var  n1 = 1; //全局变量   
  3. n2 = 2;//全局变量   
  4. //window.n2=2;//与上一行等价,但IE部分版本不支持   
  5. //n2;//错误语法,抛出ReferenceError,必须提供初始化器,或提供var关键字   
  6. function  demo() {  
  7.     var  n3 = 3; //局部变量   
  8.     n4 = 4;//全局变量,不推荐这样定义   
  9. }  
  10. alert(n0);//undefined   
  11. alert(n1);//1   
  12. alert(n2);//2   
  13. demo();//别漏掉这行,否则n4不会创建   
  14. //alert(n3);//抛出ReferenceError   
  15. alert(n4);//4   
  16. alert(window.n4);//虽IE不支持,但每个版本都可以通过它来取值   

 

再补充一些demo

全局变量的属性是无法删除的(后来发现局部的也是如此)

 

Js代码  收藏代码

  1. var  n1 = 1;  
  2. delete  n1; //无效   
  3. //delete window.n1;//IE不支持,证明其{DontDelete}特点,chrome支持,但都无效:(   
  4. alert(n1);//1   
  5.   
  6. //顺便熟悉一下delete语法   
  7. var  r1 =  new  Object(); //reference   
  8. r1.name = "jack" ;  
  9. alert(r1.name);//jack   
  10. delete  r1.name;  
  11. alert(r1.name);//undefined   
  12.   
  13. var  p1 =  "hello" ; //primitive   
  14. p1.name = "mike" ; //不可在原始类型上动态添加属性,不会导致错误但无效   
  15. alert(p1.name);//undefined   

为了或得更好的页面性能,可尝试尽早的释放全局变量,尤其是引用类型的全局变量。(置为null,解除引用),否则只能依赖垃圾回收或应用的关闭了。至于局部变量,因为在执行流退出其执行环境的时候,环境本身会释放,所以不用操心。

 

一些猜想:即使能delete也只能导致其值为undefined,而垃圾回收应只针对空指针引用(null)而非undefined,大概这是ECMA-262从第三版引入Undefined类型的原因之一,正式区分空指针和未初始化变量,便于更有效的管理内存。FIXME

 

文档中指出全局变量不可delete,是否局部的可以呢(尽管这样实在没什么必要。。),笔者YY了一些代码,仅供参考:

 

Js代码  收藏代码

  1. function  demo() {  
  2.     var  n = 1;  
  3.     delete  n; //无效。。   
  4.     delete  arguments.callee.n; //也无效。。。但都不会报错   
  5.     alert(n);  
  6. }  
  7. demo();  

当执行流进入函数时,即进入某一个执行环境,该环境关联一个变量对象(variable object),该对象以property形式保存在该环境中声明的所有变量及函数,换句话说,函数中声明的所有变量其base object都是variable object,根据delete操作符的语法,找到base object才有可能删除其下的属性,虽然ECMA-262并未明文指出variable object的明确定义,但在文档中多处提到“using xxx as the variable object”,上面这个demo中的callee就是持有arguments的对象,但删除失败,只能证明这个variable object和其关联的activation object(活动对象)是无法通过代码引用的。FIXME

2、弱类型 

ECMA-262 Language Overview 写道
ECMAScript syntax intentionally resembles Java syntax. ECMAScript syntax is relaxed to enable it to 
serve as an easy-to-use scripting language. For example, a variable is not required to have its type declared 
nor are types associated with properties, and defined functions are not required to have their declarations 
appear textually before calls to them.

ECMAScript语法类似Java,但语法较为松散。变量在声明时无需指定类型,被定义函数也无需在调用前进行声明(像C语言那样)

 

语法的松散特点之一体现在变量不光是值,其类型也可以在生命周期中改变(不推荐反复更改一个变量的值类型)

 

变量包含2种:原始类型(Undefined,Boolean,Null,Number,String ) 和引用类型

原始类型保存于栈内存,占有固定大小空间,具有索引速度快的特点

引用类型为保存于堆内存中的对象,大小不固定,索引相对较慢,变量实际上保存的是指向某对象的一个指针

 

不同类型变量,操作方式也不一样,下面仅浅析部分特点(FIXME):

原始类型在对变量进行复制时,会在栈内存中创建一个新值交由新变量持有,而引用类型,则是在栈内存中复制指针地址值,交由新变量持有,但此时,堆中对象仍然保持一个不变,即2指针指向同一对象。

Js代码  收藏代码

  1. var  n1 = 1;  
  2. var  n2 = n1;  
  3. n1 = 2;  
  4. alert(n2);//2,不受n1影响   
  5.   
  6. var  r1 =  new  Object();  
  7. r1.name = "jack" ;  
  8. var  r2 = r1;  
  9. alert(r2.name);//jack   
  10. r1.name = "mike" ;  
  11. alert(r2.name);//mike,受r1影响   

2、浅析JavaScript变量作用域

在谈及变量作用域的时候,execution context(执行环境)是一个非常关键的概念,并且相关伴随很多内部机制,理解其工作原理,能帮助我们更好的识别变量的行为和状态。

ECMA-262 写道
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. 
...... 
Every execution context has associated with it a variable object. Variables and functions declared in the 
source text are added as properties of the variable object. For function code, parameters are added as 
properties of the variable object. 
...... 
Every execution context has associated with it a scope chain. A scope chain is a list of objects that are 
searched when evaluating an Identifier. When control enters an execution context, a scope chain is 
created and populated with an initial set of objects, depending on the type of code. During execution 
within an execution context, the scope chain of the execution context is affected only by with 
statements (see 12.10) and catch clauses (see 12.14).

 

 

当执行流进入一段可执行代码时,便进入一个执行环境,活动的执行环境组成一个环境栈,栈顶端的执行环境即为当前正在运行的环境 。当环境退出时,相关资源也会得以释放。

 

我们知道JavaScript是一个基于对象的语言(object-based),那么对于变量及函数的管理也是基于这个特点。每个执行环境都会关联一个变量对象(variable object),变量对象是在进入某一个环境的时候进行创建并初始化的,所有在环境中声明的变量和函数都会作为其property挂载在这个对象上,包括函数中的参数。

 

那么我们所关注的变量,其在使用过程中是如何被访问,当遇到同名变量时,如何对其进行追溯并确认的呢?在其内部,是通过一个名为作用域链(scope chain)这样的线性数据结构来保证对环境中所有变量和函数的有序访问的。作用域链是在执行流进入一个执行环境的时候进行初始化的,里面填充的对象,根据进入的代码类型不一样,会产生动态变化,但大多数填充的都是每个执行环境当中的变量对象,并且,在作用域的前端保存的为当前正在运行的执行环境的变量对象 (也有一个说法,称之为活动对象activation object)。文档同时指出,还可以通过2种方式来改变作用域链的格局,with和try-catch。

 

对标识符的解析,是一个沿着作用域链从左至右的搜索过程,直至找到标识符并返回为止。就变量的可见性而言,作用域链成为当前环境可以访问到的变量的严格范围,即如果访问一个当前环境所关联的作用域以外的变量,则会抛出类似引用不到的错误(注意,不是undefined)。FIXME

 

ECMA-262 写道
Every function and constructor call enters a new execution context, even if a function is calling itself 
recursively. Every return exits an execution context. A thrown exception, if not caught, may also exit one 
or more execution contexts. 
When control enters an execution context, the scope chain is created and initialised, variable instantiation 
is performed, and the this value is determined. 
The initialisation of the scope chain, variable instantiation, and the determination of the this value depend 
on the type of code being entered.

文档对何时会进入一个新的执行环境进行了明确的定义:

函数和构造器的调用,包括函数的递归调用,都会导致进入一个新的环境,return则退出,如果抛出异常,无catch的情况下也会导致进入新环境。

 

好了,将这段信息结合上文理解,那么问题来了,读者可能会提出这样的大哉问:不同的执行环境都关联着这样一个作用域,那么,在某一个环境中所能访问到的变量,随着执行流的forward,是如何因执行环境的不同而产生变化的?更进一步的说,环境所占用的资源,又是怎样释放的?要分析这个问题,对作用域在不同时期状态的理解,将成为解决问题的关键。

 

下面我们一起来看2个demo,笔者主要结合环境栈和作用域来尝试进行分析:

Js代码  收藏代码

  1. var  n1 = 1;  
  2. alert(n1);//mark1   
  3. function  first() {  
  4.     var  n1 = 2;  
  5.     var  n3 = 3;  
  6.     alert(n1);//mark2   
  7.     second();  
  8.     //alert(n4);//mark4   
  9. }  
  10. function  second() {  
  11.     var  n4 = 4;  
  12.     //alert(n3);//mark3   
  13. }  
  14. first();  
  15. alert(n1);//mark5   

第一步:当执行流进入全局执行环境(对web浏览器来说,我们一般认为是window对象)的时候,会创建该环境的一个变量对象,在这个变量对象中,保存n1及2个函数声明。这时window执行环境会被压入环境栈,并根据该window执行环境的变量对象初始化作用域链。

环境栈:

window执行环境
......

 

 

 

 

作用域链:

window变量对象

 

 

那么在代码mark1处n1的值为1。刁钻的读者可能会问,为什么不是在first函数中定义的n1=2呢,first函数不是也有一个相应的变量对象吗?要知道,虽然在window执行环境中存在2个函数的定义,并且他们也确实保存在window执行环境的变量对象下,但此时执行流并未进入first函数的执行环境,所以与first函数执行环境对应的变量对象此时还并未创建。

 

第二步:执行流在运行至倒数第二行时进入first函数执行环境,并将此环境执行压栈操作,因为并未退出window对象,所以此时环境栈自顶向下依次为first环境、window环境。

first执行环境
window执行环境
......

 

 

 

 

 

 

对作用域链插入新的变量对象时,是从前端操作的。此时它演变成:

first变量对象

window变量对象

 

 

在mark2处对同名标识符n1的解析,将从first变量对象(作用域链的前端)中开始查找,此时找到n1的值为2并返回,也就不会再在作用域链的下一个(window变量中)查找了。

 

第三步: 此时,在first函数内部对second函数进行调用,执行流离开first执行环境并进入second执行环境,程序对first环境执行出栈操作,然后对second环境执行压栈操作,因为没遇到first函数结束符“}”,first环境虽然出栈,但所占用资源仍然不会释放。

second执行环境
window执行环境
......

 

 

 

 

 

 

而此时,作用域链则产生如下变化,首先first环境的出栈,将会导致从前端移除first变量对象,然后将当前活动的执行环境(second环境)的变量对象添加在作用域前端

second变量对象 window变量对象

 

 

那么在mark3处如试图对在first环境中定义的变量n3求值,则会导致错误(不是undefined),因为此时n3在second变量对象和window变量对象中,都找不到定义。

读者此时应该注意到一个规律,环境栈中的顺序与作用域链中的顺序是一致的。除此之外,我们说,在环境栈中,下方的环境相对于上方相邻的这个环境来说为包含环境,换言之,在一个环境中能访问到的变量,除开自身环境的变量定义外(优先查找,因为在作用域链前端),还有来自包含环境及其级联包含环境的。

 

第四步:执行流退出second环境,返回first环境,相应的second环境出栈,first环境压栈,由于执行流遇到second函数结束符“}”,second环境在出栈的同时,所占用的资源也得以释放。

first执行环境
window执行环境
......

 

 

 

 

 

 

相应的作用域链移除second变量对象,然后在前端插入first变量对象,变为

first变量对象 window变量对象

 

 

在mark4处试图访问second环境中定义的n4变量也是会导致错误的(过程不难分析,略)

 

第五步:执行流离开first环境, 回到window环境,first环境出栈,同时释放其所占资源,作用域链移除前端first变量对象,只留下window变量对象。mark5处n1值为1。(图、分析略)

 

再来看另一个demo

 

Js代码  收藏代码

  1. var  n1 = 1;  
  2. function  outter() {  
  3.     var  n1 = 2;  
  4.     alert(n1);//mark1   
  5.     function  inner() {  
  6.         var  n3 = 3;  
  7.         alert(n1);//mark2   
  8.     }  
  9.     inner();//mark0   
  10.     //alert(n3);//mark3   
  11. }  
  12. outter();  
  13. alert(n1);//mark4   


 

 

第一步:执行流运行至mark1时,n1值为2。分析同上。环境栈及作用域链状况如下

outter执行环境
window执行环境
......

 

 

 

 

 

 

outter变量对象 window变量对象

 

 

第二步:在mark0处对inner函数的调用,使执行流进入inner执行环境,但此时,并不会导致outter执行环境的出栈,仅仅是在环境栈原有2个执行环境的基础上继续对inner环境执行压栈,因为当前活动的执行环境(inner),其函数是声明在outter环境中的,即这个inner函数本身就是作为outter环境的变量对象中properties当中的一员。这一点与上例有着本质上的区别(上例,2个函数之间的关系为并列关系,都是window变量对象的一员)。环境栈及作用域链状况如下

inner执行环境
outter执行环境
window执行环境
......

 

 

 

 

 

 

 

inner变量对象 outter变量对象 window变量对象

 

 

在mark2处,n1值为2,在outter变量对象中找到(分析略)

 

第三步:在mark3处,尝试访问n3会报错(图、分析略)

 

第四步:在mark4处,n1值为1(图、分析略)

 

最后补充一个带with的demo:

 

Js代码  收藏代码

  1. var  n1 = 1;  
  2. function  outter() {  
  3.     var  n1 = 2;  
  4.     function  inner() {  
  5.             var  n1 = 3;  
  6.     }  
  7.     inner();  
  8.     with  (n1) {  
  9.         //var n1;//mark3   
  10.         //var n1 = 4;//mark2   
  11.         var  n2 = n1 + 4;  
  12.     }  
  13.     alert(n2);//mark1   
  14. }  
  15. outter();  

看读者能否自行分析出,mark1处n2值为6的结果,提示:

1、with/try-catch语句,都会在作用域链的前端添加一个变量对象(with后所跟参数的所有属性、方法所作的变量声明)

2、执行流进入with后所跟的“{}”块,并不会导致进入一个新的执行环境,因为没有块级作用域一说,所以在这个块中声明的变量会被添加在所在执行环境的变量对象中。

 

读者可尝试把mark2和mark3处代码交替取消注释运行查看结果。

 

关于变量及其作用域的分析,笔者认为在大多数时候都是比较直观简单的,没必要如本文中如此这般细掰,但是对于程序错误的分析定位,闭包分析,内存管理,则尤为重要。对于复杂抽象的技术(设计模式)我们应该善于转变成简单的案例进行理解把握,但对于基本功,则相对应该深入探究。

 

关于本文所涉及的参考,读者大多部分都可以从ECMA-262第十章节Execution contexts中找到,good luck