前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >我从来不理解JavaScript闭包,直到有人这样向我解释它

我从来不理解JavaScript闭包,直到有人这样向我解释它

作者头像
宁荣荣
发布2022-11-02 18:21:32
3030
发布2022-11-02 18:21:32
举报
文章被收录于专栏:基础基础

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第29天,点击查看活动详情

为什么需要闭包

首先我们来看一下为什么需要闭包。先看下嘛的例子:

代码语言:javascript
复制
function eat(){
    var food = "鸡翅";
    console.log(food);
}
eat(); // 鸡翅
console.log(food); // 报错

在上面的例子中,我们声明了一个名为 eat 的函数,并对它进行调用。

JavaScript 引擎会创建一个 eat 函数的执行上下文,其中声明 food 变量并赋值。

当该方法执行完后,上下文被销毁,food 变量也会跟着消失。这是因为 food 变量属于 eat 函数的局部变量,它作用于 eat 函数中,会随着 eat 的执行上下文创建而创建,销毁而销毁。所以当我们再次打印 food 变量时,就会报错,告诉我们该变量不存在。

那么,如何在函数销毁后也能继续使用变量 food 呢?

这就涉及到了要使用闭包。

什么是闭包

要解释闭包,可以从广义狭义上去理解。

  • 广义上的闭包:所有的函数就是闭包。
  • 狭义上的闭包:需要满足两个条件。
    • 一个函数中要嵌套一个内部函数,并且内部函数要访问外部函数的变量
    • 内部函数要被外部引用

关于广义上闭包的含义,估计很多人很难理解,我就正常写个函数,怎么这玩意儿就变成闭包了?

关于这一点,我们稍后再来解释。

我们先来看一下狭义上的闭包。

代码语言:javascript
复制
function eat(){
    var food = '鸡翅';
    return function(){
        console.log(food);
    }
}
var look = eat();
look(); // 鸡翅
look(); // 鸡翅

在这个例子中,eat 函数返回一个函数,并在这个内部函数中访问 food 这个局部变量。调用 eat 函数并将结果赋给 look 变量,这个 look 指向了 eat 函数中的内部函数,然后调用它,最终输出 food 的值。

按照之前的说法,这个 food 变量应该当 eat 函数调用完后就销毁,后续为什么还能通过调用 look 方法访问到这个变量呢?

这就是因为闭包起了作用。返回的内部函数和它外部的变量 food 实际上就是一个闭包。

闭包的实质,就是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使离开了创造它的环境也不例外。

这里提到了自由变量,它又是什么呢?

自由变量可以理解成跨作用域的变量,比如子作用域访问父作用域的变量。

如下代码中,console.log(a)  要得到 a 变量,但是在当前的作用域中没有定义 a(可对比一下 b)。当前作用域没有定义的变量,这成为自由变量 。

代码语言:javascript
复制
var a = 100
function fn() {
    var b = 200
    console.log(a) // 这里的 a 就是一个自由变量,需要顺着作用域链来查找 a 变量的值
    console.log(b)
}
fn()

闭包的原理

接下来,我们来看一下闭包的原理。

要解释闭包的原理,这里需要回答 2 个问题。

(1)为什么函数内部可以访问外部函数的变量?

原因很简单,当一个函数上下文产生的时候,会确定 3 个东西:变量对象、作用域链条以及 this 指向。

正因为有作用域链的存在,所以能够通过作用域链来访问到外部函数的变量。

(2)为什么当外部函数的上下文执行完以后,其中的局部变量还是能通过闭包访问到呢?

其实用上一个问题的答案再延伸一下,这个问题的答案就出来了。

在介绍作用域的时候,我们有介绍过作用域是在函数创建的时候就确定下来了(参阅《作用域》章节)。

所以即使外部函数的上下文结束了,但内部的函数只要不销毁(被外部引用了,就不会销毁),就会一直引用着刚才上下文的作用域链对象,那么包含在作用域链中的变量也就可以一直被访问到。

综上所述,闭包其实就是利用到了作用域链的知识。

把这个理解了,闭包的原理也就明白了。

那么为什么说每一个函数都是一个闭包呢?

因为每一个函数都能通过作用域链访问到全局上下文中的变量,例如:

代码语言:javascript
复制
var stuName = "zhangsan";
function test(){
    console.log(stuName);
}
test();

在上面的代码中,我们在 test 函数中访问了自由变量 stuName,这个被引用的自由变量将和这个函数一同存在。

闭包的优缺点

闭包的优点

先来看看闭包的优点,主要有以下 2 点:

  • 通过闭包可以让外部环境访问到函数内部的局部变量。
  • 通过闭包可以让局部变量持续保存下来,不随着它的上下文环境一起销毁。

看下面这个例子:

代码语言:javascript
复制
var count = 0; // 全局变量
function compute() { // 将计数器加 1
    count++;
    console.log(count);
}
for (var i = 0; i < 100; i++) {
    compute(); // 循环 100 次
}

这个例子是对一个全局变量进行加 1 的操作,一共加 100 次,得到值为 100 的结果。

但是因为使用了全局变量,所以存在全局变量污染的问题。

下面用闭包的方式重构它:

代码语言:javascript
复制
function compute() {
    var count = 0; // 局部变量
    return function () {
        count++; // 内部函数访问外部变量
        console.log(count);
    }
}
var func = compute(); // 引用了内部函数,形成闭包
for (var i = 0; i < 100; i++) {
    func();
}
// 在外面新创建一个 count 的变量,完全不冲突
var count = "Hello";
console.log(count);
for (var i = 0; i < 100; i++) {
    func();
}

这个例子就不再使用全局变量,其中 count 这个局部变量依然可以被保存下来。我们甚至可以在外面新创建一个 count 变量,完全不会和内部的 count 变量产生冲突。

闭包的缺点

说完闭包的优点,接下来来看一下闭包的缺点。

局部变量本来应该在函数退出时被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。也就是说,闭包会将局部变量保存下来。如果大量使用闭包,而其中的变量又未得到清理,闭包的确会使一些数据无法被及时销毁,从而造成内存泄漏。

但是使用闭包的一部分原因,是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量。

把这些变量放在闭包中和放在全局作用域中,对内存方面的影响是一样的,所以这里并不能说成是内存泄漏。如果在将来需要回收这些变量,我们可以手动把这些变量设置为 null

如果要说闭包和内存泄漏有关系的地方,那就是使用闭包的同时比较容易形成循环引用,如果闭包的作用域中保存着一些 DOM 节点,这个时候就有可能造成内存泄漏。

但这本身并非闭包的问题,也并非 JavaScript 的问题。在 IE 浏览器中,由于 BOM 和 DOM 中的对象是使用 C++  以 COM 对象的方式实现的,而 COM 对象的垃圾收集机制采用的是引用计数策略。在基于引用计数策略的垃圾回收机制中,如果两个对象之间形成了循环引用,那么这两个对象都无法被回收,但循环引用造成的内存泄漏在本质上也不是闭包造成的。

同样,如果要解决循环引用带来的内存泄漏问题,我们只需要把循环引用中的变量设为 null 即可。将变量设置为 null 意味着切断变量与它此前引用的值之间的连接。当垃圾收集器下次运行时,就会删除这些值并回收它们占用的内存。

闭包的应用场景

最后,我们来看一下闭包的一些实际的应用场景。

代码语言:javascript
复制
var a = 100;
setTimeout(function () {
    console.log(++a);
}, 1000);

上面是一段很简单的代码,但是这实际上就在你毫无察觉的情况下使用用到了闭包。

在这个例子中,用到了时间函数 setTimeout,并在等待 1 秒钟后对变量 a 进行加 1 的操作。

之所以说这是闭包,是因为 setTimeout 中的匿名函数对外部变量(自由变量)进行访问,然后该函数又被 setTimeout 方法引用。满足了形成闭包的两个条件。所以你看,即使外部上下文结束了,1 秒后仍然能对变量 a 进行加 1 操作。

在 DOM 的事件操作中,也经常用到闭包,比如下面这个例子:

代码语言:javascript
复制
<input id="count" type="button" value="计数">
代码语言:javascript
复制
(function(){
  var cnt = 0; // 计数器
  var count = document.getElementById("count");    
  count.onclick = function(){
    console.log(++cnt);
  }
})()

onclick 指向的函数中访问了外部变量 cnt,同时该函数又被 onclick 事件引用了,满足 2 个条件,是闭包。

所以当外部上下文结束后,你继续点击按钮,在触发的事件处理方法中仍然能访问到变量 cnt。

再比如,img 对象经常用于进行数据上报,如下所示:

代码语言:javascript
复制
const report = function (src) {
    var img = new Image();
    img.src = src;
}
report('http://xxx.com/getUserInfo');

但是通过查询后台的记录我们得知,因为一些低版本的浏览器的实现存在 bug,在这些浏览器下使用 report 函数进行数据上报时会丢失 30%  左右的数据,也就是说,report 函数并不是每一次都成功发起了 HTTP 请求。

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

现在我们把 img 变量用闭包封闭起来,便能解决请求丢失的问题,如下:

代码语言:javascript
复制
const report = (function () {
    var imgs = [];
    return function (src) {
        var img = new Image();
        imgs.push(img);
        img.src = src;
    }
})();

在有些时候,闭包还会引起一些奇怪的问题,比如下面这个例子:

代码语言:javascript
复制
for (var i = 1; i <= 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 1000);
}

我们预期的结果是过 1 秒后分别输出 i 变量的值为 1,2,3。但是,执行的结果是:4,4,4

实际上,问题就出在闭包身上。你看,循环中的 setTimeout 访问了它的外部变量 i,形成闭包。

而 i 变量只有 1 个,所以循环 3 次的 setTimeout 中都访问的是同一个变量。循环到第 4 次,i 变量增加到 4,不满足循环条件,循环结束,代码执行完后上下文结束。但是,那 3 个 setTimeout 等 1 秒钟后才执行,由于闭包的原因,所以它们仍然能访问到变量 i,不过此时 i 变量值已经是 4 了。

既然是闭包引起的问题,那么解决的方法就是去掉闭包。

我们知道形成闭包有两个条件,只要不满足其一,那就不再是闭包。

条件之一,内部函数被外部引用,这个我们没办法去掉。条件二,内部函数访问外部变量。这个条件我们有办法去掉,比如:

代码语言:javascript
复制
for (var i = 1; i <= 3; i++) {
    (function (index) {
        setTimeout(function () {
            console.log(index);
        }, 1000);
    })(i)
}

这样 setTimeout 中就可以不用访问 for 循环声明的变量 i 了。而是采用调用函数传参的方式把变量 i 的值传给了 setTimeout,这样它们就不再形成闭包。也就是说 setTimeout 中访问的已经不是外部的变量 i,所以即使 i 的值增长到 4,跟它内部也没关系,最后达到了我们想要的效果。

当然,解决这个问题还有个更简单的方法,就是使用 ES6 中的 let 关键字。

它声明的变量有块作用域,如果将它放在循环中,那么每次循环都会有一个新的变量 i,这样即使有闭包也没问题,因为每个闭包保存的都是不同的 i 变量,那么刚才的问题也就迎刃而解。

代码语言:javascript
复制
for (let i = 1; i <= 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 1000);
}

另外,使用闭包还可以模拟出面向对象中的私有方法。

过程与数据的结合是形容面向对象中的“对象”时经常使用的表达。

对象以属性的形式包含了数据,以方法的形式包含了过程。

而闭包则是在过程中以环境的形式包含了数据。通常用面向对象思想能实现的功能,用闭包也能够实现,反之亦然。

在 JavaScript 语言的祖先 Scheme 语言中,甚至都没有提供面向对象的原生设计,但却可以使用闭包来实现一个完整的面向对象的系统。

下面我们来看看这段跟闭包相关的代码:

代码语言:javascript
复制
function Test(){
    var value = 0; // 相当于是对象的属性
    return {
        call : function(){
            value++;
            console.log(value);
        }
    }
}
const test = new Test();
test.call(); // 1
test.call(); // 2
test.call(); // 3

如果换成面向对象的写法,那就是如下:

代码语言:javascript
复制
const test = {
    value: 0,
    call: function () {
        this.value++;
        console.log(this.value);
    }
}
test.call(); // 1
test.call(); // 2
test.call(); // 3

或者

代码语言:javascript
复制
function Test() {
    this.value = 0;
}
Test.prototype.call = function () {
    this.value++;
    console.log(this.value);
}
const test = new Test();
test.call(); // 1
test.call(); // 2
test.call(); // 3

灵魂拷问

  • 闭包是什么?闭包的应用场景有哪些?怎么销毁闭包?

闭包是指有权访问另外一个函数作用域中的变量的函数。 因为闭包引用着另一个函数的变量,导致另一个函数已经不使用了也无法销毁,所以闭包使用过多,会占用较多的内存,这也是一个副作用,内存泄漏。 如果要销毁一个闭包,可以 把被引用的变量设置为 null,即手动清除变量,这样下次 JS 垃圾回收机制回收时,就会把设为 null 的量给回收了。 闭包的应用场景:

  1. 匿名自执行函数
  2. 结果缓存
  3. 封装
  4. 实现类和继承
  5. 解决全局污染
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-10-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么需要闭包
  • 什么是闭包
  • 闭包的原理
  • 闭包的优缺点
  • 闭包的应用场景
  • 灵魂拷问
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档