深入理解闭包

Posted by Rimin on 2019-04-18

函数的使用

1)普通用法

2)当值传递:也就是作为值来传递

3)函数作为返回值

以上就是函数常见的几种用法。

后面这两种情况就会涉及到闭包,因此还会详细讨论

1
2
3
4
5
6
7
8
9
函数作为返回值
function fn(){
var min = 10;
return function compare(x){
if(x>max){
console.log(x);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
函数作为值传递
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}

function bar(fn){
fn();
};
foo(); //2

上下文执行环境和作用域

词法作用域(静态作用域)

词法作用域关心的是函数和作用域是如何声明以及在何处声明,而并不是动态作用域

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(a);
}

function bar() {
var a = 3;
foo();
}

var a = 2;

bar (); // 2

(这里打印出的是全局的2,可见,虽然foo()是在bar()中被调用的,但是由于foo()的作用域链是 foo()→全局,因此,并不会搜索到bar()中的a,而如果是动态作用域,就会搜索到bar中的a了。)

上下文环境 EC
1
2
3
4
5
6
7
8
9
10
11
var a = 10;
var fn;
var bar = function(x){
var b = 5;
fn(x+b);
};
fn = function(y){
var c = 5;
console.log(y+c);
};
bar(10);
1
2
3
                                 fn-上下文环境    
bar-上下文环境 bar-上下文环境 bar-上下文环境
全局上下文环境 -> 全局上下文环境 -> 全局上下文环境 -> 全局上下文环境 -> 全局上下文环境

一个完整的闭环,进栈设置为活动状态,也就是保存其上下文数据,出栈即被销毁其上下文数据,干脆利落,但是,后面要说到的闭包就不是这么干脆的说销毁就销毁了(垃圾回收)。

而对于上下文环境
EC

  • 变量对象 VO :(Variable Object)保存此EC中涉及到的变量。
  • 作用域链 ScopeChain: 此EC中的VO与其他EC中的VO的关联关系(能否访问到)
  • this: 在EC被创建时,会确定this的指向

作用域只是一个“地盘”,一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。所以,作用域中变量的值是在执行过程中产生的确定的,而作用域却是在函数创建时就确定了。如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值。

改变或延长作用域
  • 改变可以用 apply(),call(),bind()这些函数
  • 延长可以用 try-catch 语句的catch块,with语句
块级作用域

对于 ES5, 没有块级作用域,也就是花括号括起来的作用域,javascript除了全局作用域之外,只有函数可以创建的作用域 所以,在if,for语句中要特别注意。
比如:

1
2
3
4
5
var a = 10;
if(a>5){
a = 4;
}
console,log(a); //4 if语句里的a也是全局的

闭包

因为作用域链,外部不能访问内部的变量和方法,这时我们就需要通过闭包,返回内部的方法和变量给外部,从而就形成了一个闭包。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;

function bar() {
console.log(a);
}

return bar;
}

var baz = foo();

baz(); // 2

上面这段代码,是函数作为返回值的情况,当foo()执行后,将bar函数赋值给baz,本来应该就跟foo没什么关系了,其上下文就应该会被销毁了,但是因为bar需要打印出a的值,而根据词法作用域,它要在它的作用域链(bar→foo→全局)中寻找,因此可以在foo中找到a,所以,foo()的内部作用域依然存在,因为bar()持有着对该作用域的引用,使得该作用域一直存活,以供bar()在之后的任何时间进行引用。 而这个引用就叫闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var fn;
function foo() {
var a = 2;

function baz() {
console.log(a);
}

fn = baz; // 将baz分配给全局变量
}

function bar() {
fn();
}

foo();

bar(); // 这里也是函数当成值来传递(间接传递),同样是闭包。

接下来,看看for循环的坑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fn(){
var arr = new Array;
var i;
for(i=0;i<10;i++){
arr[i] = function(){
return i;
}
}
return arr;
}
var array = fn();
for(var j=0; j<10;j++){
console.log(array[j]()); //10个10
}

fn函数执行后,返回了一个函数数组,这个数组包含10个会return i的函数,接下来让array执行,这时候,会回去fn函数查找i,而此时i已经是10了,10个函数都回到fn函数的去找i,搜索到的是同一个i,都是10. 那么,有什么办法来解决这个问题呢?

解决的方法:(比较多)

1.我们可以通过创建另一个匿名函数强制将每次的i的值保存在一个单独的闭包中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function fn(){
var arr = new Array;
var i;
for(i=0;i<10;i++){
arr[i] = function(num){
return function(){
return num;
};
}(i);
}
return arr;
}
var array = fn();
for(var j=0; j<10;j++){
console.log(array[j]());//0,1,2,3,4,5,6,7,8,9
}

(
这里返回的数组仍为函数数组,但是每个函数都又返回了一个函数,这个返回的函数return的num是在arr函数中找的,又因为每个arr函数都保存了当时的i,也就是i通过传参保存进去。所以他们能有不同的闭包环境,每个闭包都保存了各自的i。)

2.创建一个匿名函数并返回该匿名函数的立即执行

1
2
3
4
5
6
7
8
9
10
11
12
13
function fn(){
var arr = new Array;
var i;
for(i=0;i<10;i++){
arr[i] = (function(num){
return num;
})(i);
} return arr;
}
var array = fn();
for(var j=0; j<10;j++){
console.log(array[j]);//0,1,2,3,4,5,6,7,8,9
}

3.使用ES6新特性let

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fn(){
var arr = new Array;

for(let i=0;i<10;i++){
arr[i] = function(){
return i;
}
}
return arr;
}
var array = fn();
for(var j=0; j<10;j++){
console.log(array[j]()); //0,1,2,3,4,5,6,7,8,9
}

同样,可以参考高程中的相似例子(settimeout和循环与闭包的使用,改写使得输出10个10)

接下来,还有一个例子:

1
2
3
4
5
for (var i=1; i<=5; i++) {
setTimeout(function timer() {
console.log(i);
}, 1000);
} //打印出5个6

因为for循环不会创建块级作用域,5次执行的回调函数都被封闭在一个共享的全局作用域中了,因此实际上只有一个i。循环结构让我们误以为背后还有更复杂的机制在起作用,但其实没有。

解决方案代码:

1
2
3
4
5
6
7
8
for (var i=1; i<=5; i++) {
(function() {
var j = i;
setTimeout(function timer() {
console.log(j);
}, 1000);
})();
}

也相当于:

1
2
3
4
5
6
7
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j*1000);
})(i);
}

IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
(IIFE即Immediately Invoked Function Expression,立即执行函数表达式。)
还可以更简单:

1
2
3
4
5
for (var i=1; i<=5; i++) {
setTimeout(function timer(i) {
console.log(i);
}, 10, i);
}

闭包的应用

模块模式

闭包的强大威力:应用于模块模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function CoolModule() {
var something = 'cool'; //私有变量
var another = [1, 2, 3];//私有变量

var doSomething() {
console.log(something); //私有方法
}

function doAnother() {
console.log(another.join('!')); //私有方法
}

return {
doSomething: doSomething,
doAnother: doAnother
} //以对象字面量的形式返回一个公共API对象
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3 !

每次调用都会创建一个新的模块实例。

函数柯里化

函数的柯里化,是指将本来接收多个参数的函数,变换为接收更少参数的函数,其中减少的参数,被设为了固定值。柯里化函数的使用,可以在函数被多次调用时,用来减少相同参数的重复输入, 相当于是对原函数进行了分步传值,当且仅当函数所需的所有参数都传入了对应值时,函数才会返回最终的处理结果,否则只会返回新的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Curry(fn) {
var stored_args = Array.prototype.slice.call(arguments, 1) // 将伪数组参数转换为数组,并截取第二个之后的参数
return function () {
var new_args = Array.prototype.slice.call(arguments)
args = stored_args.concat(new_args)
return fn.apply(null, args) // 将参数赋给fn
}
}

var doSomething = function (_do, something) {
console.log(_do+ ',' + something)
}
var newDoSomething = Curry(doSomething, 'hello')
newDoSomething('Jack') // hello,Jack
newDoSomething('Tom') // hello,Tom
创建单例模式

单例模式的定义是产生一个类的唯一实例

1
2
3
4
5
6
7
8
9
10
11
12
var getSinleInstance = (function () {
function Foo() {
this.name = 'Jack'
}
var instance = new Foo();
return function () {
return instance;
}
}())
var instance1 = getSinleInstance();
var instance2 = getSinleInstance();
console.log(instance1 === instance2) // true

原函数形成一个闭包,保存着instance这个被new 出来的实例,因此每一次都是返回相同的instance实例

另一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
var singleton = function( fn ){
var result;
return function(){
return result || ( result = fn .apply( this, arguments ) );
}
}

var createMask = singleton( function(){

return document.body.appendChild( document.createElement('div') );

})

这里也是一个单例模式,这个例子中节点创建一次后就不再创建,因此可以大大节约Dom的开销。(这种方式其实叫桥接模式.)然而它始终还是需要一个变量result来寄存div的引用.遗憾的是js的函数式特性还不足以完全的消除声明和语句.

(最后,欢迎大家围观指正)