闭包(closure)是JavaScript中一个“神秘”的概念,许多人都对它难以理解,我也一直处于似懂非懂的状态,前几天深入了解了一下执行环境以及作用域链,可戳查看详情,而闭包与作用域及作用域链的关系密不可分,所以就再深入去理解了一番。

词法作用域Lexical Scope

首先我们来理解一下作用域的概念:

通常来说,一段程序代码中所用到的标识符并不总是有效/可用的,而限定这个标识符的可用性的代码范围就是这个标识符的作用域

作用域有词法作用域与动态作用域之分,词法作用域也可称为静态作用域,这样与动态作用域看起来更对应。

  • 词法作用域在词法分析阶段就确定了作用域,之后不会再改变;也就是说词法作用域是由你把代码写在哪里来决定的,与之后的运行情况无关
  • 动态作用域在运行时根据程序的流程信息来动态确定作用域;也就是说动态作用域与运行情况有关
  • 大部分编程语言都是基于词法作用域,其中包括JavaScript

下面我们使用代码来说明两者的区别(此处仅仅使用JavaScript来说明两种情况,实际上JavaScript只基于词法作用域)

var cc = 6;function foo() {console.log(cc); // 会输出6还是66?
}function bar() {var cc = 66;foo();
}bar();
  • 如果是词法作用域:会输出6,词法作用域在写代码时就静态确定了,也就是定义foo函数的时候就确定了,foo函数的内部要访问变量cc,由于foo的内部作用域中没有cc变量,所以会根据作用域链访问到全局中的cc变量;这与在何处调用foo函数无关。
  • 如果是动态作用域:会输出66,动态作用域要根据代码的运行情况来确定,它关心foo函数在何处被调用,而不关心它定义在哪里;foo函数的内部要访问变量cc,而foo的内部作用域中没有cc变量时,会顺着调用栈在调用 foo() 的地方查找变量cc,此处是在bar函数中调用的,所以引擎会在bar的内部作用域中查找cc变量,这个cc变量的值为66

词法作用域链Lexical Scope Chain

var cc = 1;function foo() {var dd = 2;console.log(cc);//1console.log(dd);//2
}foo();
console.log(dd); //ReferenceError: dd is not defined

上面这一段代码中,有全局变量cc以及局部变量dd,在foo函数内部可以直接访问全局变量cc,而在foo函数外部无法读取foo函数内的局部变量dd
这种结果的产生源于JavaScript的作用域链,也正是因为这个作用域链才有了生成闭包的可能。
作用域链这一部分在另一篇文章中有详细介绍,可戳JavaScript基础系列---执行环境与作用域链,看完可以帮助更好的理解下文

什么是闭包?

关于闭包没有一个官方的定义,不同的书籍解读可能有些不同

在《JavaScript权威指南》中:

是指函数变量可以被隐藏于作用域链之内,因此看起来是函数将变量“包裹”了起来

在《JavaScript高级程序设计》中:

闭包是指有权访问另一个函数作用域中的变量的函数

在《你不知道的JavaScript--上卷》中:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用
域之外执行

在维基百科的定义:

在计算机科学中,闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。

其中自由变量指:

在函数中使用的,但既不是函数参数也不是函数的局部变量的变量

一开始我也一直纠结于闭包的定义,想确切的知道闭包是什么,但是由于没有官方的定义,难以确定。所以本文中将以维基百科中的定义为准即:

在计算机科学中,闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。

闭包的创建

根据闭包的定义我们可以看出,闭包的产生条件是函数以及该函数引用了自由变量,二者缺一不可。

这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外这一描述是闭包的特性,使用闭包后能观察到的一种现象,而不是闭包产生的条件。所以之前看到有些人说,需要将一个函数的内部函数返回才能算闭包的言论我觉得应该是不正确的,这应该是在使用闭包。

常说的闭包会导致性能问题,也是因为这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外这一闭包特性,按理来说,在函数 执行后,函数的整个内部作用域通常都会被销毁,因为我们知道引擎有垃
圾回收器用来释放不再使用的内存空间,但是闭包可以阻止这件事的发生,从而可能导致内存中保存大量的变量,从而消耗大量内存产生网页性能问题。(注意是可以,可能而非一定)

下面我们直接来看几个栗子:
1.如果考虑全局对象,那么引用了全局变量的函数可以看做创建了闭包,因为全局变量相对于该函数来说是自由变量

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

此处,函数fa引用了自由变量afa创建了闭包

2.更常见的是在一个函数内部创建另一个函数

function outer(){var b = 2;function inner(){console.log(b);}inner();
}
outer();

此处,函数inner引用了自由变量binner创建了闭包。
根据JavaScript基础系列---执行环境与作用域链中的描述我们可以知道,调用outer()后,会进入Function Execution Context outer的创建阶段:

  • 创建作用域链,outer函数的[[Scopes]]属性被加入其中
  • 创建outer函数的活动对象AO(作为该Function Execution Context的变量对象VO),并将创建的这个活动对象AO加到作用域链的最前端
  • 确定this的值

此时Function Execution Context outer可表示为:

outerEC = {scopeChain: {pointer to outerEC.VO,outer.[[Scopes]]},VO: {arguments: {length: 0},b: 2,inner: pointer to function inner(),},this: { ... }
}

接着进入Function Execution Context outer的执行阶段:

  • 当遇到inner函数定义语句,进入inner函数的定义阶段,inner[[Scopes]]属性被确定

    inner.[[Scopes]] = {pointer to outerEC.VO,pointer to globalEC.VO
    }
  • 遇到inner()调用语句,进入inner函数调用阶段,此时进入Function Execution Context inner的创建阶段:

    • 创建作用域链,inner函数的[[Scopes]]属性被加入其中
    • 创建inner函数的活动对象AO(作为该Function Execution Context的变量对象VO),并将创建的这个活动对象AO加到作用域链的最前端
    • 确定this的值
  • 此时Function Execution Context inner可表示为:

    innerEC = {scopeChain: {pointer to innerEC.VO,inner.[[Scopes]]},VO: {arguments: {length: 0},},this: { ... }
    }
  • 接着进入Function Execution Context inner的执行阶段:遇到打印语句console.log(b);,通过inner.[[Scopes]]访问到变量b=2
  • 至此,函数inner执行完毕,Function Execution Context inner的作用域链及变量对象被销毁
  • 然后函数outer也执行完毕,Function Execution Context outer的作用域链及变量对象被销毁。

这种情况下,函数执行完毕后该销毁的都被销毁了,没有占用内存,所以这种情况下闭包是不会对性能有占用内存方面的影响的。

3.最常被讨论的闭包

栗子1

function fa(){var n = 666;function fb(){console.log(n);}return fb;
}
var getN = fa();
getN();

此处,函数fb引用了自由变量nfb创建了闭包,并且fb被传递到了创造它的环境以外(所在的词法作用域以外)。

这段代码的执行情况与上面类似,鉴于篇幅就不一一展开详细描述了,大家可以自己推一遍;现在主要描述一下不同之处,在fa函数的最后,fa函数将它的内部函数fb返回了,按理说返回之后fa函数就执行完毕了,其作用域链和活动对象应该被销毁,但是闭包fb阻止了这件事的发生:

  • 函数fb定义之后其[[Scopes]]属性被确定,这个属性至此之后一直保持不变,直至函数fb被销毁,可以表示为

    fb.[[Scopes]] = {pointer to fa.VO,pointer to globalEC.VO
    }
  • 函数fa执行完毕后,将其返回值--fb函数赋给了全局变量getN,这样一来由于getN是全局变量,而全局变量是在Global Execution Context中的,需要等到应用程序退出后 —— 如关闭网页或浏览器 —— 才会被销毁,那么也就意味着fb函数也要到这时才会被销毁
  • fb函数的[[Scopes]]属性中引用了fa函数的变量(活动)对象,意味着fa函数的变量(活动)对象可能随时还需要用到,这样一来fa函数执行完毕之后,只有Function Execution Context fa的作用域链会被销毁,而变量(活动)对象仍然会在内存中
  • 这样遇到getN()语句时,实际上就是调用fb函数,于是顺着fb的作用域链找到变量n并打印出来

这里我们分析一下,变量n是闭包fb引用的自由变量,创造这个n这个自由变量的是函数fa,此时fa执行完毕之后,自由变量n仍然可以访问到(仍然存在),并且在fa函数外也能访问到(离开fa之后)。这一点也就正对应于这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外

除了将内部函数return这种方式之外,还有其他方式可以使用闭包,这些方式的共同之处是:将内部函数传递到创造它的环境以外(所在的词法作用域以外),之后无论在何处执行这个函数就都会使用闭包。

  • 栗子2

    function foo() {var a = 2;function baz() {console.log( a ); // 2}bar( baz );
    }
    function bar(fn) {fn();
    }
    foo();

    这个栗子中,是通过函数传参来将内部函数baz传递到它所在的词法作用域以外的

  • 栗子3

    var fn;
    function foo() {var a = 2;function baz() {console.log( a );}fn = baz; // 将baz 赋给全局变量
    }
    foo();
    fn(); // 2

    这个栗子中,是通过赋值给全局变量fn来将内部函数baz传递到它所在的词法作用域以外的。

在栗子1和栗子3这种情况下呢,闭包使得它自己的变量对象以及包含它的函数的变量对象都存在于内存中,如果滥用就很有可能导致性能问题。所以在不需要闭包后,最好主动解除对闭包的引用,告诉垃圾回收机制将其清除,比如在上面这些例子中进行getN = null;fn = null的操作。

4.经常用但可能并没有意识到它就是闭包的闭包

  • 栗子1

    function wait(msg) {setTimeout( function timer() {console.log( msg );}, 1000 );
    }
    wait( "Hello, closure!" );

    上面的代码其实可以理解为下面这样:

    function wait(msg) {function timer(){console.log( msg );}setTimeout( timer, 1000 );
    }
    wait( "Hello, closure!" );

    内部函数timer引用了自由变量msgtimer创建了闭包,然后将timer传递给setTimeout(..),也就是将内部函数timer传递到了所在的词法作用域以外。

    wait(..) 执行1000 毫秒后,wait的变量对象并不会消失,timer函数可以访问变量msg,只有当setTimeout(..)执行完毕后,wait的变量对象才会被销毁。

  • 栗子2

    function bindName(name, selector) {$( selector ).click( function showName() {console.log( "This name is: " + name );} );
    }
    bindName( "Closure", "#closure" );

    上面的代码其实可以理解为下面这样:

    function bindName(name, selector) {function showName(){console.log( "This name is: " + name );}$( selector ).click( showName );
    }
    bindName( "Closure", "#closure" );

    内部函数showName引用了自由变量nameshowName创建了闭包,然后将showName传递给click事件作为回调函数,也就是将内部函数showName传递到了所在的词法作用域以外。
    bindName(..)执行之后,bindName的变量对象并不会消失,每当这个click事件触发的时候showName函数可以访问变量name

5.同一个调用函数创建的闭包共享引用的自由变量

function change() {var num = 10;return{up:function() {num++;console.log(num);},down:function(){num--;console.log(num);}}
}
var opt = change();
opt.up();//11
opt.up();//12
opt.down();//11
opt.down();//10

opt.upopt.down共享变量num的引用,它们操作的是同一个变量num,因为调用一次change只会创建并进入一个Function Execution Context change,通过闭包留在内存中的变量对象只有一个。

6.不同调用函数创建的闭包互不影响

function change() {var num = 10;return{up:function() {num++;console.log(num);},down:function(){num--;console.log(num);}}
}
var opt1 = change();
var opt2 = change();
opt1.up();//11
opt1.up();//12
opt2.down();//9
opt2.down();//8

change函数被调用了两次,分别赋值给opt1opt2,此时opt1.up,opt2.up以及opt1.down,opt2.down是互不影响的,因为每调用一次就会创建并进入一个新的Function Execution Context change,也就会有新的变量对象,所以不同调用函数通过闭包留在内存中的变量对象是独立的,互不影响的。

7.关于上面提到的两点,有一个谈到闭包就被拿出来的例子:

for(var i=1;i<6;i++){setTimeout(function(){console.log(i);},i*1000);
}

上述例子乍一看会觉得输出的结果是:每隔1s分别打印出1,2,3,4,5;然而实际上的结果是:每隔1s分别打印出6,6,6,6,6

那么是为什么会这样呢?下面就来解析一下(ES6之前没有let命令,不存在真正的块级作用域):

变量i此处为全局变量,我们考虑全局变量,那么传递给setTimeout(...)的这个匿名函数创建了闭包,因为它引用了变量i;虽然循环中的五个函数是在各次迭代中分别定义的,但是它们引用的是全局变量i,这个i只有一个,所以它们引用的是同一个变量(如果在此处将全局对象想象成一个仅调用了一次的函数的返回值,那么这个现象便可以对应于 ———— 同一个调用函数创建的闭包共享引用的自由变量)

setTimeout()的回调会在循环结束时才执行,即使每个迭代中执行的是setTimeout(.., 0),而循环结束时全局变量i的值已经变成6了,所以最后输出的结果是每隔1s分别打印出6,6,6,6,6

要解决上面这个问题,最简单的方式当然是ES6中喜人的let命令了,仅需将var改为let即可,for 循环头部的let 声明会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

抛开喜人的ES6,又该怎么解决呢,既然上面的问题是由于共享同一个变量而导致的,那么我想办法让它不共享,而是每个函数引用一个不同的变量不就好了。上面提到了 ———— 不同调用函数创建的闭包互不影响,我们就要利用这个来解决这个问题:

for(var i=1;i<6;i++){waitShow(i);
}function waitShow(j){setTimeout(function(){console.log(j);},j*1000);
}

我们将循环内的代码改成了一个函数调用语句waitShow(i),而waitShow函数的内容就是之前循环体内的内容;waitShow内部传递给setTimeout(...)的这个匿名函数仍然创建了闭包,只不过这次引用的是waitShow的参数j

现在每迭代一次,便会调用waitShow一次,而我们从上文中已经知道不同调用函数创建的闭包互不影响,所以就可以解决问题了!当然,这还不是你常见的样子,现在我们稍稍改动一下,就变成非常常见的IIFE形式了:

for(var i=1;i<6;i++){(function(j){setTimeout(function(){console.log(j);},j*1000);})(i)
}

balabala说了这么多,其实我们平常写代码的时候经常无意识的就创建了闭包,但是创建了我们不一定会去使用闭包,而闭包的“威力”需要通过使用才能看得到。

闭包的应用

闭包到底有什么用呢?我觉得总结成一句话就是:

“冻结”闭包的包含函数调用时的变量对象(使其以当前值留在内存中),并只有通过该闭包才能“解冻”(访问/操作留在内存中的变量对象)

粗看可能不是很能理解,下面我们结合具体的应用场景来理解:

  1. 恩。。。首先我们来看一个老朋友,刚刚见过面的老朋友

    for(var i=1;i<6;i++){(function(j){setTimeout(function(){console.log(j);},j*1000);})(i)
    }

    在这个栗子中,每个IIFE自调用时,其内部创建的闭包将其当时的变量对象“冻结”了,并且通过将这个闭包作为setTimeout的参数传递到IIFE作用域以外;所以第一次循环“冻结”的j的值是1,第二次循环“冻结”的j的值是2......当循环结束后,延迟时间到了后,setTimeout的回调执行(即使用闭包),“解冻”了之前“冻结”的变量j,然后打印出来。

  2. 既然提到setTimeout,那再来看看另外一个应用,我们知道在标准的setTimeout是可以向延迟函数传递额外的参数的,形式是这样:setTimeout(function[, delay, param1, param2, ...]),,一旦定时器到期,它们会作为参数传递给function。但是万恶的IE搞事情,在IE9及其之前的版本中是不支持传递额外参数的。那有时候我们确实有需要传参数,怎么办呢。通常的解决方法有下面这些:

    function fullName( givenName ){let familyName = "Swift";console.log("The fullName is: " + givenName + " " + familyName);
    }
    setTimeout(fullName,1000,"Taylor Alison");
    • 使用一个匿名函数包裹
    setTimeout(function(){fullName("Taylor Alison");
    },1000);
    • 使用bindES5引入)
    setTimeout(fullName.bind(undefined,"Taylor Alison"),1000);
    • polyfill
    • 使用闭包

      function fullName( givenName ){let familyName = "Swift";return function(){console.log("The fullName is: " + givenName + " " + familyName);}}
      let showFullName = fullName("Taylor Alison");
      setTimeout(showFullName,1000);

      fullName内的匿名函数创建了闭包,并作为返回值返回,调用fullName()后返回值赋给变量showFullName,此时fullName的变量对象被“冻结”,只能通过showFullName才能“解冻”,定时器到期后,showFullName被调用,通过之前被“冻结”的变量对象访问到givenNamefamilyName

  3. 待续(有时间补上)

JavaScript基础系列---闭包及其应用相关推荐

  1. javascript基础系列(入门前须知)

    -----------------------小历史---------------------------- javascript与java是两种语言,他们的创作公司不同,JavaScript当时是借 ...

  2. 【JavaScript基础系列】决定你的人生能走多远的,是基础。

    前言 javaScript门槛非常低,一点语法,一个dom,一个bom就可以使用它开发大部分js应用,再加上现在层出不穷的框架极大的简化抽象了javaScript的使用方式,但是我们始终不能忘记的一点 ...

  3. javascript基础系列:数组常用方法解析

    javascript基础系列:数组常用方法解析 今天是比较特殊的日子,我们编程人员共同的节日,1024,祝每个编程人员节日快乐! 数组是javascript必不可少的一项,今天让我们来总结一下数组操作 ...

  4. javascript基础系列:javascript中的变量和数据类型(一)

    javascript基础系列:javascript中的变量和数据类型(一) 今天开始去重新系统温习一遍js基础,并作下记录 javascript是由三部分组成: ECMASCRIPT(ES): 描述了 ...

  5. JavaScript基础系列之五 浏览器

    JavaScript基础系列之五 浏览器 浏览器 由于JavaScript的出现就是为了能在浏览器中运行,所以,浏览器自然是JavaScript开发者必须要关注的. 目前主流的浏览器分这么几种: IE ...

  6. JavaScript基础系列之四 面向对象编程

    JavaScript基础系列之四 面向对象编程 面向对象编程 JavaScript的所有数据都可以看成对象,那是不是我们已经在使用面向对象编程了呢? 当然不是.如果我们只使用Number.Array. ...

  7. 【javascript基础——系列10】js中隐藏元素的几种方法以及代码

    系列文章 [javascript基础--系列1]前端页面ajax连接后台服务器传输数据 [javascript基础--系列2]前端页面axios连接后台服务器传输数据 [javascript基础--系 ...

  8. 2017/5 JavaScript基础9 --- 闭包、作用域

    2019独角兽企业重金招聘Python工程师标准>>> 一.理解闭包 1.闭包的例子 //一般函数 function outer(){var localVal = 30; //局部变 ...

  9. JavaScript基础之闭包

    文章目录 一.闭包(closure) 从作用域链理解闭包 面试中的闭包 解决方法 一.闭包(closure) 来自红宝书: 闭包是指有权访问另外一个函数作用域中的变量的函数.关键在于下面两点: 是一个 ...

最新文章

  1. rasa算法_(六)RASA NLU意图分类器
  2. leetcode处女作
  3. Eclipse error: “The import XXX cannot be resolved”
  4. JVM中GC对象配置
  5. ASP.NET中 Repeater嵌套
  6. PWN-PRACTICE-BUUCTF-19
  7. c# 扩展方法奇思妙用高级篇五:ToString(string format) 扩展
  8. 如何唤醒计算机,待机后如何唤醒计算机?介绍睡眠待机的优势
  9. 一文详解,RocketMQ事务消息
  10. 聚类的概念和一般步骤
  11. 推荐系统系列教程之十六:深度和宽度兼具的融合模型
  12. 在磁盘上给文件快速预留一大片空间
  13. 悲剧,当用cywin 写Linux脚本
  14. R|数据处理|list的转化与转置
  15. Robot Framework installation not found. To run tests, you need to install Robot Framework separately
  16. LGA1155、LGA1156、LGA1366、LGA2011的CPU插槽对应的都是什么型号的CPU
  17. unity屏幕渐变黑白效果
  18. 批发/零售商家如何合理控制库存?做好优化库存结构
  19. 39.(前端)欢迎页面的设置
  20. word转换成pdf,包括导航目录和图片不变黑

热门文章

  1. 浏览器端JS导出EXCEL
  2. Microservice 微服务的理论模型和现实路径
  3. [Android] Gradle 安装
  4. Cocos本地存储LocalStorage
  5. 6.2 IP子网划分
  6. getAttribute() 与 attr() 的区别
  7. Tensorflow BatchNormalization详解:4_使用tf.nn.batch_normalization函数实现Batch Normalization操作...
  8. 中小企业市场 一些超级IT企业的动向
  9. 增大减小LV大小和文件系统
  10. 书------数据库(SQL Server)