重温——作用域与闭包

有兴趣的童鞋,还可以看看闭包相关的另一篇文章,读书笔记你不知道的javascript作用域与闭包

“准备工作”——执行上下文中完成了哪些工作?

很多童鞋可能不知道什么是执行上下文,也就是执行上下文环境,即javscript引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。也正是词法作用域的核心内容。

因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。也就是执行上下文中所需要完成的工作。有兴趣童鞋可以看看你不知道的javascript提升

那么,具体是哪些工作呢?

  • 变量、函数表达式——变量声明,默认赋值为undefined
  • this——赋值;
  • 函数声明——赋值;

这三种数据的准备情况我们称之为“执行上下文”或者“执行上下文环境”。
如果代码段是函数体,那么在此基础上需要附加:

  • 参数——赋值
  • arguments——赋值
  • 自由变量的取值作用域——赋值

给执行上下文环境下一个通俗的定义——在执行代码之前,把将要用到的所有的变量都事先拿出来,有的直接赋值了,有的先用undefined占个空。

javascript在执行一个代码段之前,都会进行这些“准备工作”来生成执行上下文。这个“代码段”其实分三种情况——全局代码,函数体,eval代码

函数每被调用一次,都会产生一个新的执行上下文环境,不同的调用可能就会有不同的参数。另外一点不同在于,函数在定义的时候(不是调用的时候),就已经确定了函数体内部自由变量的作用域。
例如:

1
2
3
4
5
6
7
8
9
var a = 10;
function fn(){
console.log(a);//a是自由变量
} //函数确定时,就确定了a要取值的作用域
function bar(f){
var a = 20;
f(); //打印10.而不是20. //f是bar函数的一个参数。最后一行bar(fn),将fn传入赋值给了f参数,因此可以直接执行f()
}
bar(fn);

执行上下文栈

执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境。当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境。

其实这是一个压栈出栈的过程——执行上下文栈。如下图:

这里写图片描述

可根据以下代码来详细介绍上下文栈的压栈、出栈过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 10,     //进入全局上下文环境
fn,
bar = function(x){
var b = 5;
fn(x + b); //进入fn函数上下文环境
};

fn = function(y){
var c = 5;
console.log(y + c);
}

bar(10); //进入bar函数上下文环境

如上代码,在执行代码之前,首先将创建全局上下文环境:

这里写图片描述

然后是代码执行。代码执行到第12行之前,上下文环境中的变量都在执行过程中被赋值:

这里写图片描述

执行到第13行,调用bar函数。
跳转到bar函数内部,执行函数体语句之前,会创建一个新的执行上下文环境。

这里写图片描述

并将这个执行上下文环境压栈,设置为活动状态。

执行到第5行,又调用了fn函数。进入fn函数,在执行函数体语句之前,会创建fn函数的执行上下文环境,并压栈,设置为活动状态…以此类推到函数销毁。

但是,其我们所演示的是一种比较理想的情况。有一种情况,而且是很常用的一种情况,无法做到这样干净利落的说销毁就销毁。这种情况就是伟大的——闭包。

要说闭包,还得先从自由变量和作用域说起。

简介作用域

提到作用域,有一句话大家(有js开发经验者)可能比较熟悉:“javascript没有块级作用域”。所谓“块”,就是大括号“{}”中间的语句。
所以,我们在编写代码的时候,不要在“块”里面声明变量,要在代码的一开始就声明好了。以避免发生歧义。如:

1
2
3
4
var i;
for(i = 0;i<10;i++){}
//建议不要
for(var i = 0;i < 10; i++){}

其实,你光知道“javascript没有块级作用域”是完全不够的,你需要知道的是——javascript除了全局作用域之外,只有函数可以创建的作用域。

所以,我们在声明变量时,全局代码要在代码前端声明,函数中要在函数体一开始就声明好。除了这两个地方,其他地方都不要出现变量声明。而且建议用“单var”形式。

1
2
3
4
5
6
7
var a =10,b=20; //全局作用域
function aaa(){//fn作用域
var a = 100,c = 300;
function bbb(){//bar作用域
var a = 1000,d = 4000;
}
}

全局代码和aaa、bbb两个函数都会形成一个作用域。而且,作用域有上下级的关系,上下级关系的确定就看函数是在哪个作用域下创建的。例如,aaa作用域下创建了bbb函数,那么“aaa作用域”就是“bbb作用域”的上级。

作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。例如以上代码中,三个作用域下都声明了“a”这个变量,但是他们不会有冲突。各自的作用域下,用各自的“a”。

在jQuery源码中,声明了大量的变量,这些变量将通过一个函数被限制在一个独立的作用域中,而不会与全局作用域或者其他函数作用域的同名变量产生冲突。

jQuery源码的最外层是一个自动执行的匿名函数:

1
2
3
4
5
6
7
(function(window,undefined){
var a;
var b;
var c;

//....
}(window));

世界的开发者都在用jQuery,如果不这样做,很可能导致jQuery源码中的变量与外部javascript代码中的变量重名,从而产生冲突。

作用域和上下文环境

这里写图片描述

如上图,我们在上文中已经介绍了,除了全局作用域之外,每个函数都会创建自己的作用域,作用域在函数定义时就已经确定了。而不是在函数调用时确定。

作用域只是一个“地盘”,一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。

同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。所以,作用域中变量的值是在执行过程中产生的确定的,而作用域却是在函数创建时就确定了。

所以,如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值

不同的调用产生不同的上下文环境。

从自由变量到作用域链

先解释一下什么是“自由变量”:在A作用域中使用的变量x,却没有在A作用域中声明(即在其他作用域中声明的),对于A作用域来说,x就是一个自由变量
如下代码:

1
2
3
4
5
var u = 10;
function fn(){
var i = 20;
console.log(u+i);//这里的u在这里就是一个自由变量
}

如上程序中,在调用fn()函数时,函数体中第6行。取b的值就直接可以在fn作用域中取,因为b就是在这里定义的。而取x的值时,就需要到另一个作用域中取。到哪个作用域中取呢?

有人说过要到父作用域中取,其实有时候这种解释会产生歧义。
如下代码:

1
2
3
4
5
6
7
8
9
10
11
var	x = 10;
function fn(){
console.log(x);
}
function show(f){
var x = 20;
(function (){
f();//10,而不是20
})();
}
show(fn);

所以,“到父作用域中取”的说法是错误的。应该是:要到创建这个函数的那个作用域中取值——是“创建”,而不是“调用”,这就是所谓的“静态作用域”。

对于本文第一段代码,在fn函数中,取自由变量x的值时,要到哪个作用域中取?——要到创建fn函数的那个作用域中取——无论fn函数将在哪里调用

提示:当我看了你不到的javscript上中的附录A适,终于知道了这是由于:JavaScript不具有动态作用域,它只有词法作用域的缘故。动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是作用域嵌套。

上面描述的只是跨一步作用域去寻找,如果跨了一步,还没找到呢?——接着跨!——一直跨到全局作用域为止。要是在全局作用域中都没有找到,那就是真的没有了。

这个一步一步“跨”的路线,我们称之为——作用域链

我们拿文字总结一下取自由变量时的这个“作用域链”过程:(假设a是自由量)

第一步,现在当前作用域查找a,如果有则获取并结束。如果没有则继续;
第二步,如果当前作用域是全局作用域,则证明a未定义,结束;否则继续;
第三步,(不是全局作用域,那就是函数作用域)将创建该函数的作用域作为当前作用域;
第四步,跳转到第一步。
例如:

1
2
3
4
5
6
7
8
9
10
var a = 10;
function fn(){
var b = 20;
function bar (){
console.log(a+b); // 30
}
return bar;
}
var x = fn(),b=200;
x();

以上代码中:第13行,fn()返回的是bar函数,赋值给x。执行x(),即执行bar函数代码。

取b的值时,直接在fn作用域取出。取a的值时,试图在fn作用域取,但是取不到,只能转向创建fn的那个作用域中去查找,结果找到了。

闭包

上下文环境和作用域的知识,除了了解这些知识之外,还是理解闭包的基础。

阮一峰老师的博客中是这样说明闭包的:闭包就是能够读取其他函数内部变量的函数。由于在Javascript语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成”定义在一个函数内部的函数”。所以,在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁。

闭包的用途

闭包可以用在许多地方。它的最大用处有两个,一个是前面提到的可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。

怎么来理解这句话呢?请看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function f1(){

    var n=999;

    nAdd=function(){n+=1}

    function f2(){
      alert(n);
    }

    return f2;

  }

  var result=f1();

  result(); // 999

  nAdd();

  result(); // 1000

在这段代码中,result实际上就是闭包f2函数。它一共运行了两次,第一次的值是999,第二次的值是1000。这证明了,函数f1中的局部变量n一直保存在内存中,并没有在f1调用后被自动清除。

为什么会这样呢?原因就在于f1是f2的父函数,而f2被赋给了一个全局变量,这导致f2始终在内存中,而f2的存在依赖于f1,因此f1也始终在内存中,不会在调用结束后,被垃圾回收机制(garbage collection)回收。

这段代码中另一个值得注意的地方,就是”nAdd=function(){n+=1}”这一行,首先在nAdd前面没有使用var关键字,因此nAdd是一个全局变量,而不是局部变量。其次,nAdd的值是一个匿名函数(anonymous function),而这个匿名函数本身也是一个闭包,所以nAdd相当于是一个setter,可以在函数外部对函数内部的局部变量进行操作。

闭包应用的两种情况即可——函数作为返回值,函数作为参数传递

第一,函数作为返回值:

1
2
3
4
5
6
7
8
9
10
function fn(){
var max = 10;
return function bar(x){
if(x>max){
console.log(x);
}
};
}
var f1 = fn();
f1(15);

如上代码,bar函数作为返回值,赋值给f1变量。执行f1(15)时,用到了fn作用域下的max变量的值。至于如何跨作用域取值,可以参考上一节。

第二 函数做为参数被传递

1
2
3
4
5
6
7
8
9
10
var max = 10,
fn = function(x){
if(x>max){
console.log(x);
}
};
(function (f){
var max = 100;
f(15);
})(fn);

如上代码中,fn函数作为一个参数被传递进入另一个函数,赋值给f参数。执行f(15)时,max变量的取值是10,而不是100。

上一节讲到自由变量跨作用域取值时,曾经强调过:要去创建这个函数的作用域取值,而不是“父作用域”。理解了这一点,以上两端代码中,自由变量如何取值应该比较简单。

另外,讲到闭包,除了结合着作用域之外,还需要结合着执行上下文栈来说一下:

当一个函数被调用完成之后,其执行上下文环境将被销毁,其中的变量也会被同时销毁。但是有些情况下,函数调用完成之后,其执行上下文环境不会接着被销毁。这就是需要理解闭包的核心内容:
如下代码:

1
2
3
4
5
6
7
8
9
10
11
function fn(){
var max = 10;
return function bar(x){
if(x>max){
console.log(x);
}
};
}
var f2 = fn(),
max = 100;
f2(15);

第一步,代码执行前生成全局上下文环境,并在执行时对其中的变量进行赋值。此时全局上下文环境是活动状态。

第二步,执行第9行代码时,调用fn(),产生fn()执行上下文环境,压栈,并设置为活动状态。

第三步,执行完第9行,fn()调用完成。按理说应该销毁掉fn()的执行上下文环境,但是这里不能这么做。注意,重点来了:因为执行fn()时,返回的是一个函数。函数的特别之处在于可以创建一个独立的作用域。而正巧合的是,返回的这个函数体中,还有一个自由变量max要引用fn作用域下的fn()上下文环境中的max。因此,这个max不能被销毁,销毁了之后bar函数中的max就找不到值了。

因此,这里的fn()上下文环境不能被销毁,还依然存在与执行上下文栈中。——即,执行到第10行时,全局上下文环境将变为活动状态,但是fn()上下文环境依然会在执行上下文栈中。另外,执行完第10行,全局上下文环境中的max被赋值为100。

第四步,执行到第11行,执行f2(15),即执行bar(15),创建bar(15)上下文环境,并将其设置为活动状态。

执行bar(15)时,max是自由变量,需要向创建bar函数的作用域中查找,找到了max的值为10。这个过程在作用域链一节已经讲过。

这里的重点就在于,创建bar函数是在执行fn()时创建的。fn()早就执行结束了,但是fn()执行上下文环境还存在与栈中,因此bar(15)时,max可以查找到。如果fn()上下文环境销毁了,那么max就找不到了。

所以,使用闭包会增加内容开销,不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

第五步,执行完11行就是上下文环境的销毁过程。

所以,无论你是想了解一个经典的框架/类库,还是想自己开发一个插件或者类库,像闭包、原型这些基本的理论,是一定要知道的。否则,到时候出了BUG你都不知道为什么,因为这些BUG可能完全在你的知识范围之外。

下面举几个闭包的日常应用例子:
例一:闭包常见错误之循环闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
document.body.innerHTML = "<div id='div1'>aaa</div>"
+ "<div id='div2'>bbb</div>" + "<div id='div3'>ccc</div>";

for(var i = 1;i < 4;i++){
document.getElementById('div' + i).addEventListener('click',function(){
alert(i);//all are 4;
})
}

//正确的写法 加自执行函数:
document.body.innerHTML = "<div id='div1'>aaa</div>"
+ "<div id='div2'>bbb</div>" + "<div id='div3'>ccc</div>";
for(var i = 1;i < 4;i++){
!function(i){
document.getElementById('div' + i).addEventListener('click',function(){
alert(i);//1 2 3 4;
});
}(i);
}

这里顺便解释一下立即执行函数:

立即执行函数.解释器在解释一个语句时,如果以function开头,就会理解为函数声明。而前面加一个!可以让解释器理解为函数表达式,这样就可以立即调用了,例如:

1
2
3
~function(){console.info(1)}()
!function(){console.info(2)}()
(function(){console.info(3)})()

着三种方式效果都是一样的.

例二:闭包 – 封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(function(){
var _userId = 23492;
var _typeId = 'item';
var exportt = {};

function converter(userId){
return +userId;
}

exportt.getUserId = function(){
return converter(_userId);
}

exportt.getTypeId = function(){
return _typeId;
}

window.exportt = exportt; //暴露export
}());

console.log(exportt.getUserId());//23492
console.log(exportt.getTypeId());//item

console.log(exportt.userId);//underfined
console.log(exportt._typeId);//underfined
console.log(exportt.converter);//underfined

例三: 闭包面试题:

1
2
3
4
5
6
7
8
9
var test = (function(a){
this.a = a;
return function (b){
return this.a+b;
}
}(function(a,b){
return a;
}(1,2)));
alert(test(4)); //5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function fun(n,o) {
console.log(o)
return {
fun:function(m){
return fun(m,n)
}
}
}
var a = fun(0); //执行外部fun(0,o),返回一个对象赋值给a
a.fun(1) //调用内部函数,fun(1),返回外部函数fun(1,0),产生闭包,但未赋值给任何对象
a.fun(2) //同理这里还是调用内部函数 fun(2,0) ,所以输出是0
a.fun(3)//即输出为: undefined,0,0,0

//下面这种等于无限返回一个新对象:
//fun(0):返回 一个未调用的对象
//fun(0).fun(1): 返回执行fun(1,0)
//fun(0).fun(1).fun(2): 返回执行 fun(2,1)
//fun(0).fun(1).fun(2).fun(3): 返回执行 fun(3,2)
//以此类推
var b = fun(0).fun(1).fun(2).fun(3)//undefined,0,1,2

var c = fun(0).fun(1)
c.fun(2)
c.fun(3)//undefined,0,1,1




注:本文是当时查看王福朋前辈的博客:深入理解javascript原型和闭包 系列文字做的笔记,以及自己的一些理解、见解,以上如果有不理解的童鞋,可以访问前辈的文章链接进行查看。