JavaScript变量作用域探究
JavaScript变量作用域探究
前段时间,在coding的时候,碰到了当时感觉不可思议的问题。简化下问题,大体是这样的:
if(false){
var a = 12;
}
console.log(a); // undefined
当时对这个问题很困惑,回去恶补了下JS变量的相关知识,发现还是自己的基础知识掌握的不够扎实,于是决定探究下JS变量的相关机制。如果你对上面的结果也有疑问,我相信看完下面的讲解之后,再看这问题,天空飘来五个字:“那都不叫事”。
一. 变量
在各种编程语言中我们都接触过变量的概念,什么是变量,它是在内存中分配的一段空间。在JavaScript中,在使用变量之前需要用var关键字进行声明。下面是各种变量声明的写法:
var a = 12;
var b;
var c,d,e=’hello’;
f = 520
console.log(a); //12
console.log(b); //undefined
console.log(c); // undefined
console.log(d); // undefined
console.log(e); // hello
console.log(f); //520
你会发现,f没有使用var进行声明,就直接赋值使用了。在JS的非严格模式下,这样做是允许的。这样做和用var进行声明有什么区别呢?看下面的代码
function test(){
var a = 100;
b = 200;
}
console.log(a); //error
console.log(a); //200
上述代码表明,在函数内部不用var声明,直接进行赋值,实际上相当于声明了一个全局变量,所以在函数外可以使用该变量。什么是全局变量和局部变量,这就牵扯到下面的变量作用域的知识。
二. 变量作用域
1. 函数作用域
变量都有它的作用范围,即作用域。全局变量拥有全局作用域,在JavaScript代码中的任何地方都可以使用。但在函数内部声明的变量只能在函数体内有定义,它们是局部变量,作用域是局部性的。函数的参数也是局部变量。
在一些类似C,JAVA的编程语言中,花括号内的每一段代码都具有各自的作用域,而且变量在声明它们的代码段之外是不可见的,我们称之为块级作用域(block scope)。而JavaScript中,变量在声明它们的函数体以及这个函数体嵌套的任意函数体内都是有定义的。我们称之为函数作用域。
function test(o){
var i = 0; //i在整个函数体内均是有定义的
if( typeof o == “object”){
var j = 0;//j在函数体内有定义的,不仅限于这个代码段
for(var k=0;k<10;k++){ //k在函数体内有定义,不限于循环
console.log(k);
}
console.log(k);
}
console.log(j);
}
2. 声明提前(Hoisting)
上面的例子中,如果if的条件一直是fasle,那if代码段中的变量声明了吗?回到文章开始的那个问题
if(false){
var a = 12;
}
console.log(a); // undefined
事实证明,变量a 已经声明成功了,但是还没有赋值。这是为什么呢?JavaScript的函数作用域是指在函数内声明的所有变量在函数体内是可见的,有意思的是这些变量在声明之前就已经可用。JavaScript的这个特性称为声明提前(hoisting)。即JavaScript函数里声明的所有变量(但不涉及赋值)都被“提前”
至函数体的顶部。看下面的代码:
var scope = “global”;
function f(){
console.log(scope); // undefined
var scope = “local”;
console.log(scope); // local
}
由于JavaScript的声明提前机制,上面代码的实际执行情况如下:
var scope = “global”;
function f(){
var scope;// 函数体内的var声明被提前到函数体的顶部
console.log(scope); // undefined
scope = “local”;
console.log(scope); // local
}
三. 执行上下文(Execution Context)
1. 执行上下文栈(Execution Context Stack)
当控制器转入 ECMA 脚 本的可执行代码时,控制器会进入一个执行环境。当前活动的多个执行环境在逻辑上形成一个栈结构。该逻辑栈的最顶层的执行环境称为当前运行的执行环境。任何 时候,当控制器从当前运行的执行环境相关的可执行代码转入与该执行环境无关的可执行代码时,会创建一个新的执行环境。新建的这个执行环境会推入栈中,成为 当前运行的执行环境。执行环境包含所有用于追踪与其相关的代码的执行进度的状态。
在ECMASscript中的代码有三种类型:global, function和eval。
每一种代码的执行都需要依赖自身的上下文。当然global的上下文可能涵盖了很多的function和eval的实例。函数的每一次调用,都会进入函数执行中的上下文,并且来计算函数中变量等的值。eval函数的每一次执行,也会进入eval执行中的上下文,判断应该从何处获取变量的值。
注意,一个function可能产生无限的上下文环境,因为一个函数的调用(甚至递归)都产生了一个新的上下文环境。
function foo(bar) {}
foo(10); // 调用相同的function,每次都会产生3个不同的上下文
foo(20); //(包含不同的状态,例如参数bar的值)
foo(30);
一个执行上下文可以激活另一个上下文,就好比一个函数调用了另一个函数(或者全局的上下文调用了一个全局函数),然后一层一层调用下去。逻辑上来说,这种实现方式是栈,我们可以称之为上下文堆栈。
激活其它上下文的某个上下文被称为 调用者(caller) 。被激活的上下文被称为被调用者(callee) 。被调用者同时也可能是调用者(比如一个在全局上下文中被调用的函数调用某些自身的内部方法)。
当一个caller激活了一个callee,那么这个caller就会暂停它自身的执行,然后将控制权交给这个callee. 于是这个callee被放入堆栈,称为进行中的上下文[running/active execution context]. 当这个callee的上下文结束之后,会把控制权再次交给它的caller,然后caller会在刚才暂停的地方继续执行。在这个caller结束之后,会继续触发其他的上下文。一个callee可以用返回(return)或者抛出异常(exception)来结束自身的上下文。
如下图,所有的ECMAScript的程序执行都可以看做是一个执行上下文堆栈[execution context (EC) stack]。堆栈的顶部就是处于激活状态的上下文。
执行上下文栈
当一段程序开始时,会先进入全局执行上下文环境[global execution context], 这个也是堆栈中最底部的元素。此全局程序会开始初始化,初始化生成必要的对象[objects]和函数[functions]. 在此全局上下文执行的过程中,它可能会激活一些方法(当然是已经初始化过的),然后进入他们的上下文环境,然后将新的元素压入堆栈。在这些初始化都结束之后,这个系统会等待一些事件(例如用户的鼠标点击等),会触发一些方法,然后进入一个新的上下文环境。
有一个函数上下文“EC1″和一个全局上下文“Global EC”,下图展现了从“Global EC”进入和退出“EC1″时栈的变化:
执行上下文栈的变化
ECMAScript运行时系统就是这样管理代码的执行。如上所述,栈中每一个执行上下文可以表示为一个对象。让我们看看上下文对象的结构以及执行其代码所需的状态(state)。
2. 执行上下文(Execution Context)
一个执行的上下文可以抽象的理解为object。每一个执行的上下文都有一系列的属性(我们称为上下文状态),他们用来追踪关联代码的执行进度。这个图示就是一个context的结构。
上下文结构
除了这3个所需要的属性(变量对象(variable object),this指针(this value),作用域链(scope chain) ),执行上下文根据具体实现还可以具有任意额外属性。接着,让我们仔细来看看这三个属性。
2.1变量对象(Variable Object)
A variable object is a scope of data related with the execution context. It’s a special object associated with the context and which stores variables and function declarations are being defined within the context.
变量对象(variable object) 是与执行上下文相关的 数据作用域(scope of data) 。它是与上下文关联的特殊对象,用于存储被定义在上下文中的 变量(variables) 和 函数声明(function declarations) 。
变量对象(Variable Object)是一个抽象的概念,不同的上下文中,它表示使用不同的object。例如,在global全局上下文中,变量对象也是全局对象自身[global object]。(这就是我们可以通过全局对象的属性来指向全局变量)。
让我们看看下面例子中的全局执行上下文情况:
var foo = 10;
function bar() {} // // 函数声明
(function baz() {}); // 函数表达式
console.log(
this.foo == foo, // true
window.bar == bar // true
);
console.log(baz); // 引用错误,baz没有被定义
全局上下文中的变量对象(VO)会有如下属性:
全局变量对象
如上所示,函数“baz”如果作为函数表达式则不被不被包含于变量对象。这就是在函数外部尝试访问产生引用错误(ReferenceError) 的原因。请注意,ECMAScript和其他语言相比(比如C/C++),仅有函数能够创建新的作用域。在函数内部定义的变量与内部函数,在外部非直接可见并且不污染全局对象。
那函数以及自身的变量对象又是怎样的呢?在一个函数上下文中,变量对象被表示为活动对象(activation object)。
2.2活动对象(activation object)
当函数被调用者激活,这个特殊的活动对象(activation object) 就被创建了。它包含普通参数(formal parameters) 与特殊参数(arguments)对象(具有索引属性的参数映射表)。活动对象在函数上下文中作为变量对象使用。
即:函数的变量对象保持不变,但除去存储变量与函数声明之外,还包含以及特殊对象arguments 。考虑下面的情况:
function foo(x, y) {
var z = 30;
function bar() {} // 函数声明
(function baz() {}); // 函数表达式
}
foo(10, 20);
“foo”函数上下文的下一个激活对象(AO)如下图所示:
激活对象
同样道理,function expression不在AO的行列。
我们接下去要讲到的是第三个主要对象。众所周知,在ECMAScript中,我们会用到内部函数[inner functions],在这些内部函数中,我们可能会引用它的父函数变量,或者全局的变量。我们把这些变量对象成为上下文作用域对象[scope object of the context]. 类似于之前讨论的原型链[prototype chain],我们在这里称为作用域链[scope chain]。
2.3作用域链(Scope Chains)
A scope chain is a list of objects that are searched for identifiers appear in the code of the context.
作用域链是一个 对象列表(list of objects) ,用以检索上下文代码中出现的 标识符(identifiers) 。
作用域链的原理和原型链很类似,如果这个变量在自己的作用域中没有,那么它会寻找父级的,直到最顶层。
标示符[Identifiers]可以理解为变量名称、函数声明和普通参数。例如,当一个函数在自身函数体内需要引用一个变量,但是这个变量并没有在函数内部声明(或者也不是某个参数名),那么这个变量就可以称为自由变量[free variable]。那么我们搜寻这些自由变量就需要用到作用域链。
在一般情况下,一个作用域链包括父级变量对象(variable object)(作用域链的顶部)、函数自身变量VO和活动对象(activation object)。不过,有些情况下也会包含其它的对象,例如在执行期间,动态加入作用域链中的—例如with或者catch语句。[译注:with-objects指的是with语句,产生的临时作用域对象;catch-clauses指的是catch从句,如catch(e),这会产生异常对象,导致作用域变更]。
当查找标识符的时候,会从作用域链的活动对象部分开始查找,然后(如果标识符没有在活动对象中找到)查找作用域链的顶部,循环往复,就像作用域链那样。
var x = 10;
(function foo() {
var y = 20;
(function bar() {
var z = 30;
// "x"和"y"是自由变量
// 会在作用域链的下一个对象中找到(函数”bar”的互动对象之后)
console.log(x + y + z);
})();
})();
我们假设作用域链的对象联动是通过一个叫做__parent__的属性,它是指向作用域链的下一个对象。这可以在Rhino Code中测试一下这种流程,这种技术也确实在ES5环境中实现了(有一个称为outer链接).当然也可以用一个简单的数据来模拟这个模型。使用__parent__的概念,我们可以把上面的代码演示成如下的情况。(因此,父级变量是被存在函数的[[Scope]]属性中的)。
作用域链
在代码执行过程中,如果使用with或者catch语句就会改变作用域链。with语句用于临时扩展作用域链,
with(object)
statement
这条语句将object添加到作用域链的头部,然后执行statement,最后把作用域链恢复到原始状态。
而这些object都是一些简单对象,他们也会有原型链。这样的话,作用域链会从两个维度来搜寻。
- 首先在原本的作用域链
- 每一个链接点的作用域的链(如果这个链接点是有prototype的话)
我们再看下面这个例子:
Object.prototype.x = 10;
var w = 20;
var y = 30;
console.log(x); // 10
(function foo() {
var w = 40;
var x = 100; // "x" 可以从"Object.prototype"得到,注意值是10哦
with ({z: 50}) { // 因为{z: 50}是从它那里继承的
console.log(w, x, y , z); // 40, 10, 30, 50
} // 在"with"对象从作用域链删除之后
// x又可以从foo的上下文中得到了,注意这次值又回到了100哦
// "w" 也是局部变量
console.log(x, w); // 100, 40
//在浏览器里我们可以通过如下语句来得到全局的w值
console.log(window.w); // 20
})();
我们就会有如下结构图示。这表示,在我们去搜寻__parent__之前,首先会去__proto__的链接中。
with增大的作用域链
注意,不是所有的全局对象都是由Object.prototype继承而来的。上述图示的情况可以在SpiderMonkey中测试。
与with语句类似,当try块发生错误时,程序流程自动转入catch块,并将异常对象推入作用域前端的一个变量对象。在catch块中,函数的所有局部变量都被放在第二个作用域链中。
四. 最佳实践
- 尽量不要使用with语句,使用with语句会使变量的作用域链增大,使得搜寻变量的时间变长,影响程序的效率。在Ecmascript5严格模式下,已经禁用了with语句。
- 使用变量的时候尽量使用var进行声明。在函数体内,将声明的局部变量尽量都放在函数的顶部。
已有 0 人发表留言,猛击->> 这里<<-参与讨论
ITeye推荐