之前写过一篇JavaScript 闭包究竟是什么的文章理解闭包,觉得写得很清晰,可以简单理解闭包产生原因,但看评论都在说了解了作用域链和活动对象才能真正理解闭包,起初不以为然,后来在跟公司同事交流的时候发现作用域和执行环境确实很重要,又很基础,对理解JavaScript闭包很有帮助,所以在写一篇对作用域和执行环境的理解。
作用域就是变量和函数的可访问范围,控制着变量和函数的可见性与生命周期,在JavaScript中变量的作用域有全局作用域和局部作用域。
单纯的JavaScript作用域还是很好理解的,在一些类C编程语言中花括号内的每一段代码都有各自的作用域,而且变量在声明它们的代码段外是不可见的,称之为块级的作用域,JavaScript容易让初学者误会的地方也在于此,JavaScript并没有块及的作用域,只有函数级作用域:变量在声明它们的函数体及其子函数内是可见的。
变量没有在函数内声明或者声明的时候没有带var就是全局变量,拥有全局作用域,window对象的所有属性拥有全局作用域;在代码任何地方都可以访问,函数内部声明并且以var修饰的变量就是局部变量,只能在函数体内使用,函数的参数虽然没有使用var但仍然是局部变量。
var a=3; //全局变量 function fn(b){ //局部变量 c=2; //全局变量 var d=5; //局部变量 function subFn(){ var e=d; //父函数的局部变量对子函数可见 for(var i=0;i<3;i++){ console.write(i); } alert(i);//3, 在for循环内声明,循环外function内仍然可见,没有块作用域 } } alert(c); //在function内声明但不带var修饰,仍然是全局变量
只要是理解了JavaScript没有块作用域,简单的JavaScript作用域很好理解,还有一点儿容易让初学者迷惑的地方是JavaScript变量可函数的与解析或者声明提前,好多种叫法但说的是一件事情,JavaScript虽然是解释执行,但也不是按部就班逐句解释执行的,在真正解释执行之前,JavaScript解释器会预解析代码,将变量、函数声明部分提前解释,这就意味着我们可以在function声明语句之前调用function,这多数人习以为常,但是对于变量的与解析乍一看会很奇怪
console.log(a); //undefined var a=3; console.log(a); //3 console.log(b); //Uncaught ReferenceError: b is not defined
上面代码在执行前var a=3; 的声明部分就已经得到预解析(但是不会执行赋值语句),所以第一次的时候会是undefined而不会报错,执行过赋值语句后会得到3,上段代码去掉最后一句和下面代码是一样的效果。
var a; console.log(a); //undefined a=3; console.log(a); //3
如果只是这样那么JavaScript作用域问题就很简单了,然而由于函数子函数导致的问题使作用域不止这样简单。大人物登场——执行环境或者说运行期上下文(好土鳖):执行环境(execution context)定义了变量或函数有权访问的其它数据,决定了它们的各自行为。每个执行环境都有一个与之关联的变量对象(variable object, VO),执行环境中定义的所有变量和函数都会保存在这个对象中,解析器在处理数据的时候就会访问这个内部对象。
全局执行环境是最外层的一个执行环境,在web浏览器中全局执行环境是window对象,因此所有全局变量和函数都是作为window对象的属性和放大创建的。每个函数都有自己的执行环境,当执行流进入一个函数的时候,函数的环境会被推入一个函数栈中,而在函数执行完毕后执行环境出栈并被销毁,保存在其中的所有变量和函数定义随之销毁,控制权返回到之前的执行环境中,全局的执行环境在应用程序退出(浏览器关闭)才会被销毁。
当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain,不简称sc)来保证对执行环境有权访问的变量和函数的有序访问。作用域第一个对象始终是当前执行代码所在环境的变量对象(VO)
function a(x,y){ var b=x+y; return b; }
在函数a创建的时候它的作用域链填入全局对象,全局对象中有所有全局变量
如果执行环境是函数,那么将其活动对象(activation object, AO)作为作用域链第一个对象,第二个对象是包含环境,下一个是包含环境的包含环境。。。。。
function a(x,y){ var b=x+y; return b; } var tatal=a(5,10);
这时候 var total=a(5,10);语句的作用域链如下
在函数运行过程中标识符的解析是沿着作用域链一级一级搜索的过程,从第一个对象开始,逐级向后回溯,直到找到同名标识符为止,找到后不再继续遍历,找不到就报错。
之前博客曾经总结道:只要存在调用内部函数的可能,JavaScript就需要保留被引用的函数。而且JavaScript运行时需要跟踪引用这个内部函数的所有变量,直到最后一个变量废弃,JavaScript的垃圾收集器才能释放相应的内存空间。回头再看看好理解了很多,父函数定义的变量在子函数的作用域链中,子函数没有被销毁,其作用域链中所有变量和函数就会被维护,不会被销毁。
for(var i=0;i){ elements[i].onclick=function(){ alert(i); } }
这是上篇博客提到过的经典错误,每次element点击alert都是length,这段代码中为element绑定的click事件处理程序的作用域链是这样的
由于内部函数(click事件处理程序时刻有调用可能),所以其作用域链不能被销毁(更别说本例中i在全局作用域中,只能页面卸载是销毁),i的值一直保持for循环执行完后的length值,所以每次触发onclick的时候才会alert length。
for(var i=0;i){ (function(n){ elements[n].onclick=function(){ alert(n); } })(i); }
为什么这样就行了呢,这时候onclick引用的变量变成了n,而由于立即执行函数的原因,每个onclick函数在作用域链中分别保持着对应的n(0~length-1),这时候就可以了。
其实理解了执行环境和作用域链后,闭包翻了变成显而易见的东西,但是也不能滥用闭包,从上面例子可以看出,闭包会使子函数保持其作用域链的所有变量及函数与内存中,内存消耗很大,在使用的时候尽量销毁父函数不再使用的变量。
(1)作用域
一个变量的作用域(scope)是程序源代码中定义的这个变量的区域。
1. 在JS中使用的是词法作用域(lexical scope)
不在任何函数内声明的变量(函数内省略var的也算全局)称作全局变量(global scope)
在函数内声明的变量具有函数作用域(function scope),属于局部变量
局部变量优先级高于全局变量
1
2
3
4
5
6
|
var
name=
"one"
;
function
test(){
var
name=
"two"
;
console.log(name);
//two
}
test();
|
函数内省略var的,会影响全局变量,因为它实际上已经被重写成了全局变量
1
2
3
4
5
6
7
|
var
name=
"one"
;
function
test(){
name=
"two"
;
}
test();
console.log(name);
//two
|
函数作用域,就是说函数是一个作用域的基本单位,js不像c/c++那样具有块级作用域 比如 if for 等
1
2
3
4
5
6
7
8
9
10
|
function
test(){
for
(
var
i=0;i<10;i++){
if
(i==5){
var
name =
"one"
;
}
}
console.log(name);
//one
}
test();
//因为是函数级作用域,所以可以访问到name="one"
|
当然了,js里边还使用到了高阶函数,其实可以理解成嵌套函数
1
2
3
4
5
6
7
|
function
test1(){
var
name =
"one"
;
return
function
(){
console.log(name);
}
}
test1()();
|
test1()之后将调用外层函数,返回了一个内层函数,再继续(),就相应调用执行了内层函数,所以就输出 ”one"
嵌套函数涉及到了闭包,后面再谈..这里内层函数可以访问到外层函数中声明的变量name,这就涉及到了作用域链机制
2. JS中的声明提前
js中的函数作用域是指在函数内声明的所有变量在函数体内始终是可见的。并且,变量在声明之前就可以使用了,这种情况就叫做声明提前(hoisting)
tip:声明提前是在js引擎预编译时就进行了,在代码被执行之前已经有声明提前的现象产生了
比如
1
2
3
4
5
6
7
8
|
var
name=
"one"
;
function
test(){
console.log(name);
//undefined
var
name=
"two"
;
console.log(name);
//two
}
test();
|
上边就达到了下面的效果
1
2
3
4
5
6
7
8
9
|
var
name=
"one"
;
function
test(){
var
name;
console.log(name);
//undefined
name=
"two"
;
console.log(name);
//two
}
test();
|
再试试把var去掉?这是函数内的name已经变成了全局变量,所以不再是undefined
1
2
3
4
5
6
7
8
|
var
name=
"one"
;
function
test(){
console.log(name);
//one
name=
"two"
;
console.log(name);
//two
}
test();
|
3. 值得注意的是,上面提到的都没有传参数,如果test有参数,又如何呢?
1
2
3
4
5
6
7
8
9
|
function
test(name){
console.log(name);
//one
name=
"two"
;
console.log(name);
//two
}
var
name =
"one"
;
test(name);
console.log(name);
// one
|
之前说过,基本类型是按值传递的,所以传进test里面的name实际上只是一个副本,函数返回之后这个副本就被清除了。
千万不要以为函数里边的name="two"把全局name修改了,因为它们是两个独立的name
(2)作用域链
上面提到的高级函数就涉及到了作用域链
1
2
3
4
5
6
7
|
function
test1(){
var
name =
"one"
;
return
function
(){
console.log(name);
}
}
test1()();
|
1. 引入一大段话来解释:
每一段js代码(全局代码或函数)都有一个与之关联的作用域链(scope chain)。
这个作用域链是一个对象列表或者链表,这组对象定义了这段代码中“作用域中”的变量。
当js需要查找变量x的值的时候(这个过程称为变量解析(variable resolution)),它会从链的第一个对象开始查找,如果这个对象有一个名为x的属性,则会直接使用这个属性的值,如果第一个对象中没有名为x的属性,js会继续查找链上的下一个对象。如果第二个对象依然没有名为x的属性,则会继续查找下一个,以此类推。如果作用域链上没有任何一个对象含有属性x,那么就认为这段代码的作用域链上不存在x,并最终抛出一个引用错误(ReferenceError)异常。
2. 作用域链举例:
在js最顶层代码中(也就是不包括任何函数定义内的代码),作用域链由一个全局对象组成。
在不包含嵌套的函数体内,作用域链上有两个对象,第一个是定义函数参数和局部变量的对象,第二个是全局对象。
在一个嵌套的函数体内,作用域上至少有三个对象。
3. 作用域链创建规则:
当定义一个函数时(注意,是定义的时候就开始了),它实际上保存一个作用域链。
当调用这个函数时,它创建一个新的对象来储存它的参数或局部变量,并将这个对象添加保存至那个作用域链上,同时创建一个新的更长的表示函数调用作用域的“链”。
对于嵌套函数来说,情况又有所变化:每次调用外部函数的时候,内部函数又会重新定义一遍。因为每次调用外部函数的时候,作用域链都是不同的。内部函数在每次定义的时候都要微妙的差别---在每次调用外部函数时,内部函数的代码都是相同的,而且关联这段代码的作用域链也不相同。
(tip: 把上面三点理解好,记住了,最好还要能用自己的话说出来,不然就背下来,因为面试官就直接问你:请描述一下作用域链...)
举个作用域链的实用例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
var
name=
"one"
;
function
test(){
var
name=
"two"
;
function
test1(){
var
name=
"three"
;
console.log(name);
//three
}
function
test2(){
console.log(name);
// two
}
test1();
test2();
}
test();
|
上边是个嵌套函数,相应的应该是作用域链上有三个对象
那么在调用的时候,需要查找name的值,就在作用域链上查找
当成功调用test1()的时候,顺序为 test1()->test()->全局对象window 因为在test1()上就找到了name的值three,所以完成搜索返回
当成功调用test1()的时候,顺序为 test2()->test()->全局对象window 因为在test2()上没找到name的值,所以找test()中的,找到了name的值two,就完成搜索返回
还有一个例子有时候我们会犯错的,面试的时候也经常被骗到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
<
html
xmlns
=
"http://www.w3.org/1999/xhtml"
>
<
head
>
<
script
type
=
"text/javascript"
>
function buttonInit(){
for(var i=1;i<
4
;i++){
var
b
=
document
.getElementById("button"+i);
b.addEventListener("click",function(){
alert("Button"+i); //都是 Button4
},false);
}
}
window.onload
=
buttonInit
;
head
>
<
body
>
<
button
id
=
"button1"
>Button1
button
>
<
button
id
=
"button2"
>Button2
button
>
<
button
id
=
"button3"
>Button3
button
>
body
>
html
>
|
为什么?
根据作用域链中变量的寻找规则:
1
2
3
|
b.addEventListener(
"click"
,
function
(){
alert(
"Button"
+i);
},
false
);
|
这里有一个函数,它是匿名函数,既然是函数,那就在作用域链上具有一个对象,这个函数里边使用到了变量i,它自然会在作用域上寻找它。
查找顺序是 这个匿名函数 -->外部的函数buttonInit() -->全局对象window
匿名函数中找不到i,自然跑到了buttonInit(), ok,在for中找到了,
这时注册事件已经结束了,不要以为它会一个一个把i放下来,因为函数作用域之内的变量对作用域内是一直可见的,就是说会保持到最后的状态
当匿名函数要使用i的时候,注册事件完了,i已经变成了4,所以都是Button4
那怎么解决呢?
给它传值进去吧,每次循环时,再使用一个匿名函数,把for里边的i传进去,匿名函数的规则如代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
<
html
xmlns
=
"http://www.w3.org/1999/xhtml"
>
<
head
>
<
script
type
=
"text/javascript"
>
function buttonInit(){
for(var i=1;i<
4
;i++){
(function(data_i){
var
b
=
document
.getElementById("button"+data_i);
b.addEventListener("click",function(){
alert("Button"+data_i);
},false);
})(i);
}
}
window.onload
=
buttonInit
;
head
>
<
body
>
<
button
id
=
"button1"
>Button1
button
>
<
button
id
=
"button2"
>Button2
button
>
<
button
id
=
"button3"
>Button3
button
>
body
>
html
>
|
这样就可以 Button1..2..3了
4.上述就是作用域链的基本描述,另外,with语句可用于临时拓展作用域链(不推荐使用with)
语法形如:
with(object)
statement
这个with语句,将object添加到作用域链的头部,然后执行statement,最后把作用域链恢复到原始状态
简单用法:
比如给表单中各个项的值value赋值
一般可以我们直接这样
1
2
3
4
|
var
f = document.forms[0];
f.name.value =
""
;
f.age.value =
""
;
f.email.value =
""
;
|
引入with后(因为使用with会产生一系列问题,所以还是使用上面那张形式吧)
1
2
3
4
5
|
with
(document.forms[0]){
f.name.value =
""
;
f.age.value =
""
;
f.email.value =
""
;
}
|
另外,假如 一个对象o具有x属性,o.x = 1;
那么使用
1
2
3
|
with
(o){
x = 2;
}
|
就可以转换成 o.x = 2;
假如o没有定义属性x,它的功能就只是相当于 x = 2; 一个全局变量罢了。
因为with提供了一种读取o的属性的快捷方式,但他并不能创建o本身没有的属性。
要理解变量的作用域范围就得先理解作用域链
用var关键字声明一个变量时,就是为该变量所在的对象添加了一个属性。
作用域链:由于js的变量都是对象的属性,而该对象可能又是其它对象的属性,而所有的对象都是window对象的属性,所以这些对象的关系可以看作是一条链
链头就是变量所处的对象,链尾就是window对象
看下面的代码: