资讯专栏INFORMATION COLUMN

JavaScript基础系列---闭包及其应用

leoperfect / 2970人阅读

摘要:所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。所以本文中将以维基百科中的定义为准即在计算机科学中,闭包,又称词法闭包或函数闭包,是引用了自由变量的函数。

闭包(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);//1
  console.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说了这么多,其实我们平常写代码的时候经常无意识的就创建了闭包,但是创建了我们不一定会去使用闭包,而闭包的“威力”需要通过使用才能看得到。

闭包的应用

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

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

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

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

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,然后打印出来。

既然提到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

待续(有时间补上)

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/95533.html

相关文章

  • JS 闭包(closure)

    摘要:对数组函数而言,相当于产生了个闭包。关于对象在闭包中使用对象也会导致一些问题。不过,匿名函数的执行环境具有全局性,因此其对象通常指向。由于声明函数时与声明函数时的值是不同的,因此闭包与闭包貌似将会表示各自不同的值。 这几天看到闭包一章,从工具书到各路大神博客,都各自有着不同的理解,以下我将选择性的抄(咳咳,当然还是会附上自己理解的)一些大神们对闭包的原理及其使用文章,当作是自己初步理解...

    nihao 评论0 收藏0
  • 【重温基础】22.内存管理

    摘要:内存泄露内存泄露概念在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。判断内存泄漏,以字段为准。 本文是 重温基础 系列文章的第二十二篇。 今日感受:优化学习方法。 系列目录: 【复习资料】ES6/ES7/ES8/ES9资料整理(个人整理) 【重温基础】1-14篇 【重温基础】15.JS对象介绍 【重温基础】16.JSON对象介绍 【重温基础】1...

    Pandaaa 评论0 收藏0
  • 前端基础

    摘要:谈起闭包,它可是两个核心技术之一异步基于打造前端持续集成开发环境本文将以一个标准的项目为例,完全抛弃传统的前端项目开发部署方式,基于容器技术打造一个精简的前端持续集成的开发环境。 这一次,彻底弄懂 JavaScript 执行机制 本文的目的就是要保证你彻底弄懂javascript的执行机制,如果读完本文还不懂,可以揍我。 不论你是javascript新手还是老鸟,不论是面试求职,还是日...

    graf 评论0 收藏0
  • JS笔记

    摘要:从最开始的到封装后的都在试图解决异步编程过程中的问题。为了让编程更美好,我们就需要引入来降低异步编程的复杂性。异步编程入门的全称是前端经典面试题从输入到页面加载发生了什么这是一篇开发的科普类文章,涉及到优化等多个方面。 TypeScript 入门教程 从 JavaScript 程序员的角度总结思考,循序渐进的理解 TypeScript。 网络基础知识之 HTTP 协议 详细介绍 HTT...

    rottengeek 评论0 收藏0
  • JavaScript 工作原理之三-内存管理及如何处理 4 类常见的内存泄漏问题(译)

    摘要:这是因为我们访问了数组中不存在的数组元素它超过了最后一个实际分配到内存的数组元素字节,并且有可能会读取或者覆写的位。包含个元素的新数组由和数组元素所组成中的内存使用中使用分配的内存主要指的是内存读写。 原文请查阅这里,本文有进行删减,文后增了些经验总结。 本系列持续更新中,Github 地址请查阅这里。 这是 JavaScript 工作原理的第三章。 我们将会讨论日常使用中另一个被开发...

    weknow619 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<