深入理解闭包

2,167 阅读9分钟

一、闭包

闭包已经成为近乎神话的概念,它非常重要又难以掌握,而且还难以定义。它是javascript语言的一个难点,也是它的特色,很多高级应用都是依靠闭包实现。

如何站好队

理解闭包,首先必须理解变量作用域。上章提到,javascript有两种作用域:全局作用域和函数作用域。

var a = 123;
function fn1(){
    console.log(a);
}
fn1();// 123

上面代码中,函数fn1可以读取全局变量a

但是,函数外部无法读取函数内部声明的变量

1\. function fn1(){
2\.     var a = 123;
3\. }
4\. console.log(a); //Uncaught ReferenceError: a is not defined

上面代码中,函数fn1内部声明的变量a,函数外是无法读取的

如果处于种种原因,需要得到函数内的局部变量。正常情况下,这是办不到的,但是通过变通方法才能实现。在函数内部,再定义一个函数

function fn1(){
    var a = 123;
    function fn2(){
        console.log(a); //123
    }
}

上面代码中,函数fn2就在函数fn1内部,这时fn1内部的所有局部变量,对fn2都是可见的。但是反过来就不行,fn2内部的局部变量,对fn1就是不可见的。这就是javascript语言特有的链式作用域结构(chain scope),子对象会一级一级地向上寻找所有父对象的变量。所以,父对象的所有变量,对子对象都是可见,反之则不成立。

既然fn2可以读取fn1的局部变量,那么只要把fn2作为返回值,我们不就可以fn1外部读取它的内部变量了吗!

function fn1(){
    var a = 123;
    function fn2(){
        console.log(a); //123
    }
    return fn2;
}
var result = fn1();
console.log(result()); //123

上面代码中,函数fn1的返回值就是函数fn2,由于fn2可以读取fn1的内部变量,所以就可以在外部获得fn1的内部变量

闭包就是函数fn2,既能够读取其它函数内部变量的函数。由于在javascript语言中,只有函数内部的子函数才能读取父函数的内部变量,因为可以把闭包简单理解为:定义在一个函数内部的函数

闭包最大的特点:就是它可以记住诞生的环境,比如fn2记住了它诞生的环境fn1,所以在fn2可以得到fn1的内部变量。

本质上,闭包就是函数内部和函数外部链接的一座桥梁

闭包的用途

【1】读取函数内部的变量,让这些变量始终保持在内存中,即闭包可以使得它诞生环境一直存在。(制作计时器)

例子:闭包使得内部变量记住上一次调用时的运算结果

使用闭包的时候,要小心内存的泄露

function a() {
    var start = 5;
    function b() {
        return start++;
    };
    return b;
}
var inc = a();
inc();// 5
inc();// 6
inc();// 7
//释放内存
inc = null;

上面代码中,start是函数a的内部变量。通过闭包(函数a的内部函数b被函数a外的一个变量引用的时候,就创建了一个闭包),start的状态被保留了,每一次调用都是在上一次调用的基础上进行计算。从中可以看出,闭包inc使得函数a的内部环境一直存在。所以,闭包可以看作是函数内部作用域的一个接口。

为什么会这样呢?原因就在于inc始终在内存中,而inc的存在依赖与a,因此也始终在内存中,不会在调用结束后,被垃圾回收机制回收。

在Javascript中,如果一个对象不再被引用,那么这个对象就会被GC回收。如果两个对象互相引用,而不再被第3者所引用,那么这两个互相引用的对象也会被回收。因为函数a被b引用,b又被a外的inc引用,这就是为什么函数a执行后不会被回收的原因。

【2】封装对象的私有属性和私有方法

function Person(name){
    var _age;
    function setAge(n){
        _age = n;
    }
    function getAge(){
        return _age;
    }
    return {
        name:name,
        getAge:getAge,
        setAge:setAge
    }
}
var p1 = Person('mjj');
p1.setAge(18);
p1.getAge();//18
p1 = null//使用完成之后一定要置空

上面代码中,函数Person的内部变量_age,通过闭包getAgesetAge,变成了返回对象p1的私有变量。

注意,外层函数每次运行完,都会生成一个新的闭包,而这个闭包又会保留外层函数的内部变量,所以消耗很大。因此不能滥用闭包,否则会造成网页的性能问题。

使用闭包的注意点

  1. 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在IE中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。

  2. 闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

  3. 每个复函数调用完成,都会形成新的闭包,复函数中的变量始终会在内存中,相当于缓存,小心内存的消耗问题。

  4. www.jianshu.com/p/26c81fde2…

总结

闭包需要满足三个条件:

【1】访问所在作用域【2】函数嵌套 【3】在所在作用域外被调用

二、立即执行函数

实现

在 Javascript 中,圆括号()是一种运算符,跟在函数名之后,表示调用该函数。比如,fn()就表示调用fn函数。

但有时需要定义函数之后,立即调用该函数。这种函数就叫做立即执行函数,全称为立即调用的函数表达式IIFE(Imdiately Invoked Function Expression)

注意:javascript引擎规定,如果function关键字出现在行首,一律解释成函数声明语句

函数声明语句需要一个函数名,由于没有函数名,所以报错

function (){}();
//Uncaught SyntaxError: Unexpected token (

解决方法就是不要让function出现在行首,让引擎将其理解成一个表达式。最简单的处理,就是将其放在一个圆括号里面

常用的两种写法
(function(){/* code */}());
//或者
(function(){/* code */})();

上面两种写法都是以圆括号开头,引擎就会认为后面跟的是一个表示式,而不是函数定义语句,所以就避免了错误。

注意

注意,上面两种写法最后的分号都是必须的。如果省略分号,遇到连着两个 IIFE,可能就会报错。

// 报错
(function(){ /* code */ }())
(function(){ /* code */ }())

上面代码的两行之间没有分号,JavaScript 会将它们连在一起解释,将第二行解释为第一行的参数。

其它写法

推而广之,任何让解释器以表达式来处理函数定义的方法,都能产生同样的效果,比如下面三种写法。

var i = function(){return 10}();
true && function (){/* code */}();

0,function(){/* code */}();
!function(){ /* code */ }();
~function(){ /* code */ }();
-function(){ /* code */ }();
+function(){ /* code */ }();

new function(){ /* code */ };
new function(){ /* code */ }();

用途

IIFE一般用于构造私有变量,避免全局污染。

接下来用一个需求实现来更直观地说明IIFE的用途。假设有一个需求,每次调用函数,都返回加1的一个数字(数字初始值为0)

【1】全局变量

一般情况下,我们会使用全局变量来保存该数字状态

var a = 0;
function add(){
    return ++a;
}
console.log(a);//1
console.log(b);//2

变量a实际上只和add函数相关,却声明为全局变量,不太合适。

【2】自定义属性

将变量a更改为函数的自定义属性更为恰当

function add(){
    return ++add.count;
}
add.count = 0;
console.log(add());//1
console.log(add());//2

有些代码可能会无意中将add.count重置

【3】IIFE

使用IIFE把计数器变量保存为私有变量更安全,同时也可以减少对全局空间的污染

var add = (function (){
    var counter = 0;
    return function (){
        return ++counter;
    }
})();

使用立即执行函数,可以邓庄室友的属性,同时可以减少对全局变量的污染

注意事项

执行如下代码会报错,提示此时的a是undefined

vvar a = function(){
    return 1;
}
(function(){
    console.log(a());//报错
})();

这是因为没有加分号,浏览器将上面代码解释成如下所示

var a = function(){
    return 1;
}(function(){
    console.log(a());//报错
})();

如果加上分号,就不会出错了

var a = function(){
    return 1;
};
(function(){
    console.log(a());//1
})()

三、对循环和闭包的错误理解

1、容易犯错的一件事

function foo(){
    var arr = [];
    for(var i = 0; i < 2; i++){
        arr[i] = function (){
            return i;
        }
    }
    return arr;
}
var bar = foo();
console.log(bar[0]());//2

犯错原因是在循环的过程中,并没有把函数的返回值赋值给数组元素,而仅仅是把函数赋值给了数组元素。这就使得在调用匿名函数时,通过作用域找到的执行环境中储存的变量的值已经不是循环时的瞬时索引值,而是循环执行完毕之后的索引值

2、IIFE解决容易犯错的问题

可以利用IIF传参和闭包来创建多个执行环境来保存循环时各个状态的索引值。因为函数传参是按值传递的,不同的参数的函数被调用时,会创建不同的执行环境

function foo() {
    var arr = [];
    for (var i = 0; i < 2; i++) {
        arr[i] = (function(j) {
            return function (){
                return j;
            };
        })(i);
    }
    return arr;
}
var bar = foo();
console.log(bar[1]()); //1

或者

function foo() {
    var arr = [];
    for (var i = 0; i < 2; i++) {
        (function(i) {
            arr[i] = function() {
                return i;
            }
        })(i)
    }
    return arr;
}
var bar = foo();
console.log(bar[1]());

3、使用let块作用域解决问题

使用IIFE还是较为复杂,使用块作用域则更为方便

由于块作用域可以将索引值i重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值,相当于为每一次索引值都创建一个执行环境

function foo(){
    var arr = [];
    for(let i = 0; i < 2; i++){
        arr[i] = function(){
            return i;
        }
    }
    return arr;
}
var bar = foo();
console.log(bar[1]());//0

在编程中,如果实际和预期结果不符,就按照代码顺序一步一步地把执行环境图示画出来,会发现很多时候就是在想当然

四、闭包的的10种形式

根据闭包的定义,我们知道,无论何种手段,只要将内部函数传递到所在的作用域以外,它都会持有对原始作用域的引用,无论在何处执行这个函数都会使用闭包,接下来,将详细介绍闭包的10中形式

1、返回值

最常用的一种形式是函数作为返回值被返回

var fn = function(){
    var a = 'mjj';
    var b = function(){
        return a;
    }
    return b;
}
console.log(fn()());

2、函数赋值

把当前的闭包函数赋值给外部的函数

就是一种变形,变形形式是将内部的函数赋值给外部的一个变量

var fn2;
var fn = function () {
    var name = 'mjj';
    var b = function () {
        return name;
    }
     fn2 = b;
}
fn();
console.log(fn2());

3、函数参数

闭包可以通过函数参数传递函数形式来实现

var fn2 = function(fn){
    console.log(fn());
}
var fn = function(){
    var a = 'mjj';
    var b = function(){
        return a;
    }
    fn2(b);
}
fn();

4、IIFE

由函数参数演变

        function fn3(f) {
            console.log(f());
        }

        (function () {
            var name = 'mjj';
            var a = function () {
                return name;
            }
            fn3(a)
        })();

5、循环赋值

        function foo(){
            var arr = [];
            for(var i=0;i<10;i++){
               /*  (function(i){
                    arr[i] = function(){
                        return i
                    }
                })(i); */
                 arr[i] = (function(n){
                    return function(){
                        return n ;
                    }
                })(i); 
            }
            return arr;
        }
        var bar = foo();
        console.log(bar[1]());//1
        console.log(bar[2]());//2
        console.log(bar[3]());//3

6、getter和setter

通过闭包来封装一些私有属性和方法的变量。

我们通过提供getter()和setter()函数来将要操作的变量保存在函数内部,防止其暴露在外部。

        var getter,setter;//get是获取值,set是赋值,对原有的值进行改变
        (function(){
            var num = 0;
            getter = function(){
                return num;
            }
            setter = function(x){
                /* if(typeof x === 'number'){
                    num = x;
                } */
                num = x;//在这一步之前可以对传入的参数类型进行一个判断,判断是否为正确的属性
            }
        })();
        console.log(getter());//0
        setter(10);
        console.log(getter());//10

7、迭代器(计数器)

我们经常使用闭包来实现一个累加器

        var add = (function(){
            num = 0;
            return function(){
                return ++num;
            }
        })();
        console.log(add());
        console.log(add());

类似地,使用闭包可以很方便的实现一个迭代器

       var color = ['red','blue','yellow','black'];
        function a(arr){
            num = 0;
            return function(){
                return arr[num++];
            }
        }
        var b = a(color);
        console.log(b());//red
        console.log(b());//blue
        console.log(b());//yellow
        console.log(b());//black

8、区分首次

当首次调用封装的这个函数的时候,希望得到的返回值是true。反之为false。

        var first = (function(){
            var list = [];
            return function(id){
                if(list.indexOf(id) >= 0 ){
                    //字符长度大于0,表示已经调用过了
                    return false;
                }else{
                    //indexOf < 0;字符长度小于0,表示为首次调用
                    list.push(id);
                    return true;
                }
            }
        })();
        console.log(first(10));//true
        console.log(first(10));//false
        console.log(first(20));//true
        console.log(first(30));//true

9、缓存机制

通过闭包加入缓存机制,使得相同的参数不用重复计算,来提高函数的性能

9.1、未加入缓存机制前的代码如下
        var x = function(){
            var num = 0;
            for(var i = 0; i<arguments.length;i++){
                num = num + arguments[i]; 
            }

            return num;
        }

        console.log(x(1,2,3,4));
        console.log(x(1,2,3,4));
9.2、加入缓存机制后,代码如下

模拟一个对象的key,看该对象中是否有相同的key,如果有则直接获取value返回

        var mult = (
            function () {
                var cache = {};
                var calculate = function () {
                    var num = 1;
                    //arguments是传入的实参
                    for(var i = 0; i < arguments.length; i++){
                        num = num + arguments[i];
                    }
                    return num;
                }
                return function(){
                    var args = Array.prototype.join.call(arguments,',');
                    if(args in cache){
                        return cache[args];
                    }
                    return cache[args] = calculate.apply(null,arguments);
                }

            }
        )();
        console.log(mult(1,1,1,2,3,3)); 

10、img对象图片上报

img对象经常用于数据上报(new Image() 进行数据上报)

var report = function (src){
    var img = new Image();
    img.src = src;
}
report('http://xx.com/getUserInfo');

但是,在一些低版本的浏览器中,使用report函数进行数据上报会丢失30%左右的数据,也就是说,report函数并不是每一次都成功地发起了HTTP请求

原因是img是report函数中的局部作用域,当report函数的调用结束后,img局部变量随即被销毁,而此时或许还没来得及发出HTTP请求,所以此次请求就会丢失掉

现在把img变量用闭包封存起来,就能解决请求丢失的问题

var report = (function(){
   var imgs = [];
    return function(src){
        var img = new Image();
        imgs.push(img);
        img.src = src;
    }
})()
report('http://xx.com/getUserInfo');