对闭包的理解

  在正常情况下,如果定义了一个函数,就会产生一个函数作用域,在函数体中的变量会在这个作用域中使用。一旦函数执行完成,函数所占空间就会被回收,存在于函数中的局部变量同样被回收,回收后将不能被访问到。那么如果我们期望在函数执行完成后,函数中的局部变量仍然可以被访问到,该怎么办呢?闭包可以实现这个目标,在学习闭包前,我们需要掌握一个概念:执行上下文环境。

1. 执行上下文环境

  JavaScript每段代码的执行都会存在于一个执行上下文环境中,而任何一个执行上下文环境都会存在于整体的执行上下文环境中。根据栈先进后出的特点,全局环境产生的执行上下文会最先压入栈中,存在于栈底。当心的函数产生调用时,会产生心的执行上下文环境,也会压入栈中。当函数调用完成后,这个上下文环境及其中的数据都会被销毁,并弹出栈,从而进入之前的执行上下文环境中。
  需要注意的是,处理活跃状态的执行上下文环境只能同时有一个,如下图深色背景部分。


执行上下文环境.png

  我们通过以下代码了解执行上下文环境的变化过程。

var a = 10;//1.进入全局执行上下文环境
var fn = function (x) {
    var c = 10;
    console.info(c + x);
} 
var bar = function (y) {
    var b = 5;
    fn(y + b)//3.进入fn()函数执行上下文环境
}
bar(20);//2.进入bar()函数执行上下文环境

从第一行代码开始,进入全局执行上下文环境,此时执行上下文环境中只存在全局执行上下文环境。
当代码执行到第十行时,调用bar()函数,进入bar()函数执行上下文环境中。
执行到10行后,进入bar()函数,执行到第八行时,执行fn()函数,进入fn()函数执行上下文环境中。
进入fn()中执行第五行代码后,fn()函数执行上下文环境会被销毁,从而弹出栈。
fn()函数执行上下文环境被销毁后,回到bar()函数执行上下文环境中,执行完成第九行后,bar()函数执行上下文环境也将被销毁,从而弹出栈。
最后全局上下文环境执行完毕,栈被清空,流程执行结束。
上面的这种代码执行完毕,执行上下文环境将会被销毁的场景,是一种比较理想的情况。
有一种情况,虽然代码执行完毕,但执行上下文环境却无法被感觉地销毁,这就是讲到的闭包。

2. 闭包的概念

  对于闭包的概念,官方有一个通用的解释:一个拥有许多变量和绑定了这些变量的执行上下文环境的表达式,通常是函数。
闭包有两个明显特点:

  • 函数拥有外边变量的引用,在函数返回时,该变量仍处于活跃状态。
  • 闭包作为一个函数返回时,其执行上下文环境不会被销毁,仍处于执行上下文环境中。

在JavaScript中存在一种内部函数,即函数声明和函数表达式可以处于另一个函数的函数体内,在内部函数中可以访问外部函数声明的变量,在这个内部函数在包含他们的外部函数之外被调用时,机会形成闭包。
   我们来看下以下代码。

    function fn() {
        var max = 10;
        return function bar(x) {
            if (x > max) {
                console.info(x)
            }
        }
    }
    var f1 = fn();
    f1(11);//11

代码执行后,生成全局上下文环境,并压入栈中。
代码执行到第九行时,进入fn()函数中,生成fn()函数执行上下文环境,并将其压入栈中。
fn()函数返回一个bar()函数,并将其赋给变量f1。
当代码执行到第10行时,调用f1()函数,注意此时是一个关键节点,f1()函数包含了对max变量的引用,而max变量存在于外部函数fn()中的,此时fn()函数执行上下文环境并不会被直接销毁,依然存在于执行上下文环境中。


闭包执行上下文环境

等到第10行代码执行结束后,bar()函数执行完毕,bar()函数执行上下文环境也被销毁,同时因为max变量引用会被释放,fn()函数执行上下文环境也一同被销毁。
最后全局执行上下文环境执行完毕,栈被清空,流程执行结束。
闭包所存在最大的问题就是消耗内存,如果闭包使用越来越多,内存消耗将越来越大。

3. 闭包的用途

  在了解闭包之后,我们可以结合闭包的特点,写出一些更加简洁优雅的代码,并且能在某些方面提升代码的执行效率。

  • 结果缓存

在开发过程中,我们可能会遇到这样的场景,假如有一个处理很耗时的函数对象,每次调用都会消耗很长时间。
我们可以将其处理结果在内存中缓存起来。这样在代码执行时,如果内存中有,则直接返回;如果内存中没有,则调用函数进行计算,更新缓存并返回结果。
因为闭包不会释放外部变量的引用,所以能将外部变量值缓存在内存中。

    var checkedBox = (function (){
        //缓存的容器
        var cache = {};
        return {
            searchBox: function (id){
                // 如果再内存中,则直接返回
                if(id in cache){
                    return `查找的缓存结果为:${cache[id]}`
                }
                //经过一段很耗时的dealFn()函数处理
                var result = dealFn(id);
                //更新缓存结果
                cache[id] = result;
                //返回计算的结果
                return `查找的结果为:${result}`
            }
        }
    })()
    //处理很耗时的函数
    function dealFn(id) {
        console.info('这是很耗时的操作')
        return id;
    }
    //两次调用searchBox函数
    console.info(checkedBox.searchBox(1))
    console.info(checkedBox.searchBox(1))

在上面的代码中,末尾两次调用searchBox(1)()函数,在第一次调用时,id为1的值并未在缓存对象cache中,因为会执行很耗时的函数,输出的结果为“1”。
这是很耗时的操作
查找的结果为:1
而第二次执行searchBox(1)函数时,由于第一次已经将结果更新到cache对象中,并且该对象引用并未被回收,因此会直接从内存的cache对象中读取,直接返回“1”,最后输出的结果为“1”。
查找的缓存结果为:1
这样并没有执行很耗时的函数,还间接提高了执行效率。

  • 封装

  在JavaScript中提倡的模块化思想是希望将具有一定特征的属性封装到一起,只需要对外暴露对应的函数,并不关心内部逻辑的实现。
例如,我们可以借助数组实现一个栈,只对外暴露出表示入栈和出栈的push()函数和pop()函数,以及表示栈长度的size()函数。

    var stack = (function () {
       //使用数组模仿栈的实现
        var arr = [];
        //栈
        return{
            push:function (value){
                arr.push(value)
            },
            pop:function () {
                return arr.pop()
            },
            size:function () {
                return arr.length
            }
        }
    })()
    stack.push('abc');
    stack.push('def');
    console.info(stack.size())//2
    stack.pop();
    console.info(stack.size())//1

上面的代码中存在一个立即执行函数,在函数内部会产生一个执行上下文环境,最后返回一个表示栈的对象并赋给stack变量。在匿名函数执行完毕后,其执行上下文环境并不会被销毁,因为在对象的push()、pop()、size()等函数中包含了对arr变量的引用,arr变量会继续存在于内存中,所以后面几次对stack变量的操作会使stack变量的长度产生变化。
接下来我们将通过几道练习题加深大家对闭包的理解。

1. ul中有若干个li,每次但击li,输出li的索引值
<ul>
   <li>1</li>
   <li>2</li>
   <li>3</li>
   <li>4</li>
   <li>5</li>
</ul>
<script>
   var lis = document.getElementsByTagName('ul')[0].children;
   for (var i = 0; i < lis.length; i++) {
       lis[i].onclick = function () {
           console.log(i);
       };
   }
</script>

但是真正运行后却发现,结果并不如自己所想,每次单击后输出的并不是索引值,而一直都是“5”。
这是为什么呢?因为在我们单击li,触发li的click事件之前,for循环已经执行结束了,而for循环结束的条件就是最后一次i++执行完毕,此时i的值为5,所以每次单击li后返回的都是“5”。
采取使用闭包的方法可以很好地解决这个问题。

    var lis = document.getElementsByTagName('ul')[0].children;
    for (let i = 0; i < lis.length; i++) {
        (function (index) {
            lis[i].onclick = function () {
                console.info(index)
            }
        })(i)
    }

在每一轮的for循环中,我们将索引值i传入一个匿名立即执行函数中,在该匿名函数中存在对外部变量lis的引用,因此会形成一个闭包。而闭包中的变量index,即外部传入的i值会继续存在于内存中,所以当单击li时,就会输出对应的索引index值。

2. 定时器问题

定时器setTimeout()函数和for循环在一起使用,总会出现一些意想不到的结果,我们看看下面的代码。

var arr = ['one', 'two', 'three'];
for(var i = 0; i < arr.length; i++) {
   setTimeout(function () {
       console.log(arr[i]);
   }, i * 1000);
}

在这道题目中,我们期望通过定时器从第一个元素开始往后,每隔一秒输出arr数组中的一个元素。
但是运行过后,我们却会发现结果是每隔一秒输出一个“undefined”,这是为什么呢?
setTimeout()函数与for循环在调用时会产生两个独立执行上下文环境,当setTimeout()函数内部的函数执行时,for循环已经执行结束,而for循环结束的条件是最后一次i++执行完毕,此时i的值为3,所以实际上setTimeout()函数每次执行时,都会输出arr[3]的值。而因为arr数组最大索引值为2,所以会间隔一秒输出“undefined”。
通过闭包可以解决这个问题,代码如下所示。

var arr = ['one', 'two', 'three'];
for(var i = 0; i < arr.length; i++) {
   (function (time) {
       setTimeout(function () {
           console.log(arr[time]);
       }, time * 1000);
   })(i);
}

通过立即执行函数将索引i作为参数传入,在立即函数执行完成后,由于setTimeout()函数中有对arr变量的引用,其执行上下文环境不会被销毁,因此对应的i值都会存在内存中。所以每次执行setTimeout()函数时,i都会是数组对应的索引值0、1、2,从而间隔一秒输出“one”“two”“three”。

3. 作用域链问题

闭包往往会涉及作用域链问题,尤其是包含this属性时。

var name = 'outer';
var obj = {
   name: 'inner',
   method: function () {
       return function () {
           return this.name;
       }
   }
};
console.log(obj.method()());  // outer

在调用obj.method()函数时,会返回一个匿名函数,而该匿名函数中返回的是this.name,因为引用到了this属性,在匿名函数中,this相当于一个外部变量,所以会形成一个闭包。
在JavaScript中,this指向的永远是函数的调用实体,而匿名函数的实体是全局对象window,因此会输出全局变量name的值“outer”。
如果想要输出obj对象自身的name属性,应该如何修改呢?简单来说就是改变this的指向,将其指向obj对象本身。

var name = 'outer';
var obj = {
   name: 'inner',
   method: function () {
       // 用_this保存obj中的this
       var _this = this;
       return function () {
           return _this.name;
       }
   }
};
console.log(obj.method()());  // inner

在method()函数中利用_this变量保存obj对象中的this,在匿名函数的返回值中再去调用_this.name,此时_this就指向obj对象了,因此会输出“inner”。

4. 多个相同函数名问题
// 第一个foo()函数
function foo(a, b) {
   console.log(b);
   return {
      // 第二个foo()函数
       foo: function (c) {
          // 第三个foo()函数
          return foo(c, a);
       }
   }
}
var x = foo(0); x.foo(1); x.foo(2); x.foo(3);
var y = foo(0).foo(1).foo(2).foo(3);
var z = foo(0).foo(1); z.foo(2); z.foo(3);

在上面的代码中,出现了3个具有相同函数名的foo()函数,返回的第三个foo()函数中包含了对第一个foo()函数参数a的引用,因此会形成一个闭包。
在完成这道题目之前,我们需要搞清楚这3个foo()函数的指向。
首先最外层的foo()函数是一个具名函数,返回的是一个具体的对象。
第二个foo()函数是最外层foo()函数返回对象的一个属性,该属性指向一个匿名函数。
第三个foo()函数是一个被返回的函数,该foo()函数会沿着原型链向上查找,而foo()函数在局部环境中并未定义,最终会指向最外层的第一个foo()函数,因此第三个和第一个foo()函数实际是指向同一个函数。
理清3个foo()函数的指向后,我们再来看看具体的执行过程。
var x = foo(0); x.foo(1); x.foo(2); x.foo(3);
(1)在执行foo(0)时,未传递b值,所以输出“undefined”,并返回一个对象,将其赋给变量x。
在执行x.foo(1)时,foo()函数闭包了外层的a值,就是第一次调用的0,此时c=1,因为第三层和第一层为同一个函数,所以实际调用为第一层的的foo(1, 0),此时a为1,b为0,输出“0”。
执行x.foo(2)和x.foo(3)时,和x.foo(1)是相同的原理,因此都会输出“0”。
第一行输出结果为“undefined,0,0,0”。
var y = foo(0).foo(1).foo(2).foo(3);
(2)在执行foo(0)时,未传递b值,所以输出“undefined”,紧接着进行链式调用foo(1),其实这部分与(1)中的第二部分分析一样,实际调用为foo(1, 0),此时a为1,b为0,会输出“0”。
foo(1)执行后返回的是一个对象,其中闭包了变量a的值为1,当foo(2)执行时,实际是返回foo(2, 1),此时的foo()函数指向第一个函数,因此会执行一次foo(2, 1),此时a为2,b为1,输出“1”。
foo(2)执行后返回一个对象,其中闭包了变量a的值为2,当foo(3)执行时,实际是返回foo(3, 2),因此会执行一次foo(3, 2),此时a为3,b为2,输出“2”。
第二行输出结果为“undefined,0,1,2”。
var z = foo(0).foo(1); z.foo(2); z.foo(3);
(3)前两步foo(0).foo(1)的执行结果与(1)、(2)的分析相同,输出“undefined”和“0”。
foo(0).foo(1)执行完毕后,返回的是一个对象,其中闭包了变量a的值为1,当调用z.foo(2)时,实际是返回foo(2, 1),因此会执行foo(2, 1),此时a为2,b为1,输出“1”。
执行z.foo(3)时,与z.foo(2)一样,实际是返回foo(3, 1),因此会执行foo(3, 1),此时a为3,b为1,输出“1”。
第三行输出结果为“undefined,0,1,1”。

4. 小结

闭包如果使用合理,在一定程度上能提高代码执行效率;如果使用不合理,则会造成内存浪费,性能下降。接下来总结闭包的优点和缺点。

1. 闭包的优点
  • 保护函数内变量的安全,实现封装,防止变量流入其他环境发生命名冲突,造成环境污染。
  • 在适当的时候,可以在内存中维护变量并缓存,提高执行效率。
2. 闭包的缺点
  • 消耗内存:通常来说,函数的活动对象会随着执行上下文环境一起被销毁,但是,由于闭包引用的是外部函数的活动对象,因此这个活动对象无法被销毁,这意味着,闭包比一般的函数需要消耗更多的内存。
  • 泄漏内存: 在IE9之前,如果闭包的作用域链中存在DOM对象,则意味着该DOM对象无法被销毁,造成内存泄漏。
function closure() {
   var element = document.getElementById("elementID");
   element.onclick = function () {
       console.log(element.id);
   };
}

  在closure()函数中,给一个element元素绑定了click事件,而在这个click事件中,输出了element元素的id属性,即在onclick()函数的闭包中存在了对外部元素element的引用,那么该element元素在网页关闭之前会一直存在于内存之中,不会被释放。
  如果这样的事件处理的函数很多,将会导致大量内存被占用,进而严重影响性能。
  对应的解决办法是:先将需要使用的属性使用临时变量进行存储,然后在事件处理函数时使用临时变量进行操作;此时闭包中虽然不直接引用element元素,但是对id值的调用仍然会导致element元素的引用被保存,此时应该手动将element元素设置为null。

function closure() {
   var element = document.getElementById("elementID");
   // 使用临时变量存储
   var id = element.id;
   element.onclick = function () {
       console.log(id);
   };
   // 手动将元素设置为null
   element = null;
}

闭包既有好处,也有坏处。我们应该合理评估,适当使用,尽可能地发挥出闭包的最大用处。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容