对闭包的理解

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

1. 执行上下文环境

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

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

scss 复制代码
    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. 闭包的用途

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

  • 结果缓存

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

javascript 复制代码
    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()函数。

javascript 复制代码
    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的索引值
xml 复制代码
<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"。 采取使用闭包的方法可以很好地解决这个问题。

ini 复制代码
    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循环在一起使用,总会出现一些意想不到的结果,我们看看下面的代码。

ini 复制代码
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"。 通过闭包可以解决这个问题,代码如下所示。

css 复制代码
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属性时。

javascript 复制代码
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对象本身。

javascript 复制代码
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. 多个相同函数名问题
scss 复制代码
// 第一个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对象无法被销毁,造成内存泄漏。
ini 复制代码
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。

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

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

相关推荐
inksci1 小时前
Vue 3 中通过 this. 调用 setup 暴露的函数
前端·javascript·vue.js
未来之窗软件服务2 小时前
monaco-editor 微软开源本地WEB-IDE-自定义自己的开发工具
开发语言·前端·javascript·编辑器·仙盟创梦ide
白白糖2 小时前
二、HTML
前端·html
子燕若水2 小时前
continue dev 的配置
java·服务器·前端
学习HCIA的小白2 小时前
关于浏览器对于HTML实体编码,urlencode,Unicode解析
前端·html
向明天乄2 小时前
Vue3 后台管理系统模板
前端·vue.js
香蕉可乐荷包蛋3 小时前
vue 常见ui库对比(element、ant、antV等)
javascript·vue.js·ui
彩旗工作室3 小时前
Web应用开发指南
前端
孙俊熙4 小时前
react中封装一个预览.doc和.docx文件的组件
前端·react.js·前端框架
wuhen_n4 小时前
CSS元素动画篇:基于当前位置的变换动画(四)
前端·css·html·css3·html5