JavaScript高级程序设计——闭包

js-closure

前言

有很多人搞不清匿名函数闭包这两个概念,经常混用。闭包是指有权访问另一个函数作用域中的变量的函数。匿名函数就是没有实际名字的函数。

闭包

概念

闭包,其实是一种语言特性,它是指的是程序设计语言中,允许将函数看作对象,然后能像在对象中的操作搬在函数中定义实例(局部)变量,而这些变量能在函数中保存到函数的实例对象销毁为止,其它代码块能通过某种方式获取这些实例(局部)变量的值并进行应用扩展。

条件

闭包是允许函数访问局部作用域之外的数据。即使外部函数已经退出,外部函数的变量仍可以被内部函数访问到。

因此闭包的实现需要三个条件:

内部函数实用了外部函数的变量
外部函数已经退出
内部函数可以访问

1
2
3
4
5
6
7
8
9
10
11
12
13
function a() {
var x = 0;
return function(y) {
x = x + y;
// return x;
console.log(x);
}
}
var b = a();
b(1); //1
b(1); //2

上述代码在执行的时候,b得到的是闭包对象的引用,虽然a执行完毕后,但是a的活动对象由于闭包的存在并没有被销毁,在执行b(1)的时候,仍然访问到了x变量,并将其加1,若再执行b(1),则x是2,因为闭包的引用b并没有消除。(后面会解释,闭包返回了函数,函数可以创建独立的作用域)

闭包,其实就是指程序语言中能让代码调用已运行的函数中所定义的局部变量。

但是你只需要知道应用的两种情况即可——函数作为返回值,函数作为参数传递。

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); //15
}
};
(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
12
1//全局作用域
2 function fn() {
3 var max = 10;
4 // fn作用域
5 return function bar(x) {
6 if (x > max) {
7 console.log(x);
8 }
9 }; //bar作用域
10 }
11 var f1 = fn();
12 f1(15);

全局作用域为:代码1-12行;fn作用域为:代码2-10行;bar作用域为:代码5-9行。


举例

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

js-closure

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

第三步,执行完第17行,fn()调用完成。按理说应该销毁掉fn()的执行上下文环境,但是这里不能这么做。注意,重点来了:

因为执行fn()时,返回的是一个函数。函数的特别之处在于可以创建一个独立的作用域。而正巧合的是,返回的这个函数体中,还有一个自由变量max要引用fn作用域下的fn()上下文环境中的max。因此,这个max不能被销毁,销毁了之后bar函数中的max就找不到值了。

因此,这里的fn()上下文环境不能被销毁,还依然存在与执行上下文栈中。

——即,执行到第18行时,全局上下文环境将变为活动状态,但是fn()上下文环境依然会在执行上下文栈中。另外,执行完第18行,全局上下文环境中的max被赋值为100。如下图:
js-closure
第四步,执行到第20行,执行f1(15),即执行bar(15),创建bar(15)上下文环境,并将其设置为活动状态。
js-closure
执行bar(15)时,max是自由变量,需要向创建bar函数的作用域中查找,找到了max的值为10。这个过程在作用域链一节已经讲过。

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

总结:使用闭包会增加内容开销

第五步,执行完20行就是上下文环境的销毁过程,这里就不再赘述了。

闭包与变量

概念

闭包只能取得包含函数中任何变量的最后一个值,闭包所保存的是整个变量对象,而不是某个特殊变量。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createFunctions() {
var result = new Array();
for (var i = 0; i < 10; i++) {
result[i] = function() {
return i;
};
}
return result;
}
var funcs = createFunctions();
//每个函数都输出10
for (var i = 0; i < funcs.length; i++) {
document.write(funcs[i]() + "<br />");
}

总结:每个函数的作用域链中都保存着createFunctions()函数的活动对象,所以它们引用的都是同一个变量i。当createFunctions()函数返回后,变量i的值为10。

我们可以通过创建另一个匿名函数强制让闭包的行为符合预期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createFunctions() {
var result = new Array();
for (var i = 0; i < 10; i++) {
result[i] = function(x) {
return function() {
return x;
};
}(i);
}
return result;
}
var funcs = createFunctions();
//循环输出0-10
for (var i = 0; i < funcs.length; i++) {
document.write(funcs[i]() + "<br />");
}

总结:没有直接把闭包赋值给数组,而是定义了一个匿名函数,并通过立即执行该匿名函数的结果赋值给数组,并带了for循环的参数i进去,让x能找到传入的参数值为0-10,这就解释了函数参数是按值传递的,所以会将变量i的当前值复制给参数x。而这个匿名函数内部又创建并返回了一个访问x的闭包。这样以来result数组中的每个函数都有自己x变量的一个副本,所以会符合我们的预期输出不同的值。

个人小例子(2017年8月7日补充)

html结构代码:

1
2
3
4
5
6
7
8
9
html结构
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
</ul>

js结构代码:

1
2
3
4
5
6
7
8
// 闭包不符合预期
var clickBoxs = new Array();
clickBoxs = $("li");
for (var i = 0; i < 10; i++) {
clickBoxs[i].onclick = function() {
console.log(i);
};
}

解决方法:

1
2
3
4
5
6
7
8
9
10
11
// 闭包1
var clickBoxs = new Array();
clickBoxs = $("li");
for (var i = 0; i < 10; i++) {
clickBoxs[i].onclick = (function(x) {
return function() {
console.log(x);
return x;
}
})(i);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 闭包2
var clickBoxs = new Array();
clickBoxs = $("li");
function foo(i) {
var onclick = function(e) {
console.log(i);
}
return onclick;
}
for (var i = 0; i < 10; i++) {
clickBoxs[i].onclick = foo(i);
}
1
2
3
4
5
6
7
8
// es6语法
var clickBoxs = new Array();
clickBoxs = $("li");
for (let i = 0; i < 10; i++) {
clickBoxs[i].onclick = function() {
console.log(i);
};
}

函数按值传递

函数传参就两个类型,基本类型和引用类型,大家纠结的都是引用类型的传递。

引用类型作为参数传入函数,传的是个地址值,或者指针值,不是那个引用类型本身,它还好好的呆在堆内存呢。赋值给argument的同样是地址值或者指针。所以说是value值传递一点没错,传的是个地址值。通过两个例子看懂就行了。

例子1:

1
2
3
4
5
6
7
8
9
10
11
function setName(obj) {
obj.name = 'aaa';
var obj = new Object(); // 如果是按引用传递的,此处传参进来obj应该被重新引用新的内存单元
obj.name = 'ccc';
return obj;
}
var person = new Object();
person.name = 'bbb';
var newPerson = setName(person);
console.log(person.name + ' | ' + newPerson.name); // aaa | ccc

从结果看,并没有显示两个’ccc’。这里是函数内部重写了obj,重写的obj是一个局部对象。当函数执行完后,立即被销毁。

引用值:对象变量它里面的值是这个对象在堆内存中的内存地址。因此如果按引用传递,它传递的值也就是这个内存地址。那么var obj = new Object();会重新给obj分配一个地址,比如是0x321了,那么它就不在指向有name = ‘aaa’;属性的内存单元了。相当于把实参obj和形参obj的地址都改了,那么最终就是输出两个ccc了。

例子2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = {
num:'1'
};
var b = {
num:'2'
};
function change(obj){
obj.num = '3';
obj = b;
return obj.num;
}
var result = change(a);
console.log(result + ' | ' + a.num); // 2 | 3

首先把a的值传到change函数内,obj.num = ‘3’;后a.name被修改为3;
a的地址被换成b的地址;
返回此时的a中a.num。

闭包中使用this对象

概念

this对象是在运行时基于函数的执行环境绑定的:全局函数中,this等于window;当函数被作用某个对象的方法调用时,this等于那个对象。

但在匿名函数中,由于匿名函数的执行环境具有全局性,因此this对象通常指向window(在通过call或apply函数改变函数执行环境的情况下,会指向其他对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
return function(){
return this.name;
};
}
};
alert(object.getNameFunc()()); //"The Window"

通过修改把作用域中的this对象保存在一个闭包能够访问到的变量里,就可以让闭包访问该对象了。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var name = "The Window";
var object = {
name : "My Object",
getNameFunc : function(){
var that = this;
return function(){
return that.name;
};
}
};
alert(object.getNameFunc()()); //"MyObject"

变量声明提前

1
2
3
4
5
6
7
var scope="global";
function scopeTest() {
console.log(scope);
var scope="local";
}
scopeTest(); //undefined

此处的输出是undefined,并没有报错,这是因为在前面我们提到的函数内的声明在函数体内始终可见,上面的函数等效于:

1
2
3
4
5
6
7
var scope="global";
function scopeTest() {
var scope;
console.log(scope);
scope="local";
}
scopeTest(); //undefined

注意,如果忘记var,那么变量就被声明为全局变量了。结果就是global

没有块级作用域

和其他我们常用的语言不同,在Javascript中没有块级作用域:

1
2
3
4
5
6
7
8
9
10
11
12
function scopeTest() {
var scope = {};
if (scope instanceof Object) {
var j = 1;
for (var i = 0; i < 10; i++) {
console.log(i); //输出0-9
}
console.log(i); //输出10
}
console.log(j); //输出1
}
scopeTest();

在javascript中变量的作用范围是函数级的,即在函数中所有的变量在整个函数中都有定义,这也带来了一些我们稍不注意就会碰到的“潜规则”:

1
2
3
4
5
6
var scope = "hello";
function scopeTest() {
console.log(scope);//①
var scope = "no";
console.log(scope);//②
}

在①处输出的值竟然是undefined,简直丧心病狂啊,我们已经定义了全局变量的值啊,这地方不应该为hello吗?其实,上面的代码等效于:

1
2
3
4
5
6
7
var scope = "hello";
function scopeTest() {
var scope;
console.log(scope);//①
scope = "no";
console.log(scope);//②
}

声明提前、全局变量优先级低于局部变量,根据这两条规则就不难理解为什么输出undefined了。

坚持原创技术分享,您的支持将鼓励我继续创作!
Fork me on GitHub