「JS-Learning」理解JS的闭包

本篇涉及到的名词:作用域,词法作用域,作用域链,闭包等。

一、引言

  • 《JavaScript高级程序设计(第3版)》
    • 闭包是指有权访问另一个函数作用域中的变量的函数。(P178)
  • 《你不知道的JavaScript(上)》
    • 当函数可以记住并访问所在的词法作用域时,就产生了闭包。(P44)
  • MDN
    • 函数和对其词法环境的引用捆绑在一起构成闭包,闭包可以让你从内部函数访问外部函数作用域。(原链接

以上是几种对闭包概念的描述,比较专业,但是各有说法、比较拗口。

其实不必要太纠结于概念,因为如何定义闭包不会影响到实际的使用,了解闭包是如何产生的,这才是本质的东西。下面就根据我的理解,用通俗的语言梳理下闭包的前因后果。

二、作用域

在《JavaScript高级程序设计(第3版)》对闭包的定义中提到了「函数作用域」,所以先理解 JS 中的作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

1. 全局作用域和函数作用域

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。(ES3 开始,try /catch 分句结构和 with 中也具有块作用域,这里不加讨论)

  • 全局作用域 中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域 就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

在 ES6 之前,JavaScript 只支持这两种作用域,相较而言,其他语言则都普遍支持块级作用域。块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 {} 都可以被看作是一个块级作用域。

简单来讲,如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁。

遗憾的是,JavaScript 在 ES6 之前是不支持块级作用域的。没有块级作用域,对作者当时设计 JavaScript 来说会比较简单快速,但这也直接导致了「变量提升」的问题。本文点到为止,不展开讲,防止跑题。

2. 块级作用域

为了解决变量提升带来的一系列问题,ES6 引入了 letconst 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域

需要注意的是,**只有 letconst + 大括号({})才能构成块级作用域,否则单纯的大括号({})只是用作代码分割,让代码阅读起来更简单轻快一点,纯粹代码维护上的需求。

通过 let 或者 const 声明的变量会在进入块级作用域的时候被创建,但是在该变量没有赋值之前,引用该变量 JavaScript 引擎会抛出错误,这就是「暂时性死区」,对于暂时性死区的问题,本文不展开讨论。

三、词法作用域

在《你不知道的JavaScript(上)》对闭包的定义中提到了「词法作用域」,在 JS 中什么是词法作用域呢?

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。(标识符:在 JS 中所有可以由我们自主命名的都可以称为是标识符,例如:变量名、函数名、属性名都属于标识符)

预先说一个结论:词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。这句话什么意思,别急,先留个印象,下面开始讲解作用域链,这也是一个耳熟能详的名词术语。

四、作用域链

作用域链也称词法作用域链,顾名思义,它跟词法作用域有关。

作用域链本质上就是查找变量的链条(确定变量来自于哪里,变量是否可以访问,或者说,确定一个变量来自于哪个作用域)。

简单来说,作用域链可以用以下几句话来概括:

  • Step1 查看当前作用域,如果当前作用域声明了这个变量,就确定结果;
  • Step2 查找当前作用域的上级作用域,也就是当前函数的上级函数,看看上级函数中有没有声明;
  • Step3 再查找上级函数的上级函数,直到全局作用域为止;
  • Step4 如果全局作用域中也没有,我们就认为这个变量未声明(抛出异常:xxx is not defined)。

这么讲可能不太好理解,你可以看下面这张图:
词法作用域链

词法作用域链

前面说过,词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。什么意思呢,看下面这个例子:

function innerFunction() {
    console.log(myName)
}
function outerFunction() {
    var myName = "程序员博客"
    innerFunction()
}

var myName = "文渊博客"
outerFunction()

要知道上述代码执行后打印出来的内容是什么,就要分析下这两段代码的执行流程了。

从代码中可以看出,全局执行上下文和 outerFunction 函数的执行上下文中都包含变量 myName,那 innerFunction 函数里面 myName 的值到底该选择哪个呢?

根据上面提到的查找顺序,查找方式如下:

  • Step1 查看当前作用域,innerFunction 函数里面不存在变量 myName
  • Step2 innerFunction 函数中使用了外部变量,向上级查找。

重点来了,由于 innerFunction 函数里面不存在变量 myName ,此时 JavaScript 引擎会去全局执行上下文中查找,而不是它的调用方 outerFunction 函数的执行上下文。

这是因为在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

而前面已经说过,这里再重复一遍:词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系。(重要的话说三遍)

既然如此,根据词法作用域,outerFunctioninnerFunction 的上级作用域都是全局作用域,所以如果 outerFunction 或者 innerFunction 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。

五、闭包是什么

理解了作用域链,下面就可以聊聊闭包了。关于闭包,理解起来可能会是一道坎,特别是在你不太熟悉 JavaScript 这门语言的时候,接触闭包很可能会让你产生一些挫败感,因为你很难通过理解背后的原理来彻底理解闭包,从而导致学习过程中似乎总是似懂非懂。最要命的是,JavaScript 代码中还总是充斥着大量的闭包代码。

我发现网上所有的文章都喜欢上一段一段的代码,代码中函数的命名又很随意,一下子就让初学者望而却步。接下来我用一个人性化点的示例 —— 博客制造机,相信结合下面这段代码你一定能理解什么是闭包。

function blogFactory() {
    var name = "程序员博客"
    const domain = 'www.wenyuanblog.com'
    let age = 2
    var blogMaker = {
        getName:function(){
            console.log(age)
            return name
        },
        setName:function(newName){
            name = newName
        }
    }
    return blogMaker
}

var myBlog = blogFactory()
myBlog.setName("文渊博客")
myBlog.getName()
console.log(myBlog.getName())

从上面的代码可以看出,blogFactory 是一个对象,包含了 getNamesetName 的两个方法(通常我们把对象内部的函数称为方法)。你可以看到,这两个方法都是在 blogFactory 函数内部定义的,并且这两个方法内部使用了 namedomain 两个变量。

根据词法作用域的规则,内部函数 getNamesetName 总是可以访问它们的外部函数 blogFactory 中的变量,所以当 blogMaker 对象返回给全局变量 myBlog 时,虽然 blogFactory 函数已经执行结束,但是 getNamesetName 函数依然可以使用 blogFactory 函数中的变量 namedomain

换言之,blogFactory 函数执行完成之后,其执行上下文销毁,但是由于返回的 setNamegetName 方法中使用了 blogFactory 函数内部的变量 namedomain,所以这两个变量依然保存在内存中。这像极了 setNamegetName 方法背的一个专属背包,无论在哪里调用了 setNamegetName 方法,它们都会背着这个 blogFactory 函数的专属背包。

之所以是专属背包,是因为除了 setNamegetName 函数之外,其他任何地方都是无法访问该背包的,我们就可以把这个背包称为 blogFactory 函数的闭包。

好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 blogFactory,那么这些变量的集合就称为 blogFactory 函数的闭包。

六、闭包的作用

  • 模块化(利用闭包的原理,将一个大的系统放在一个自调用函数中)
  • 防止变量被破坏(封装私有变量,保护函数内的变量安全)

如下代码所示,在开发一些组件的时候,要实现模块化就可以使用闭包:

var common=(function(){
    return {
        isStr:function(){

        },
        isNumber:function(){

        }
    }
})();

七、闭包内存释放

如果闭包使用不正确,会很容易造成内存泄漏的,关注闭包是如何回收的能让你正确地使用闭包。

通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。

如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包的时候,你要尽量注意一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

当然了,如果想释放以全局变量形式存在的闭包,也可以用下面这种方式:

function foo(){
    var a = 5;
    return function(){
        a++;
        console.log(a);
    }
}
var bar = foo();

// 要想释放 bar 里面保存的a,只能通过释放 bar
bar = null;    // 或者 bar = undefined

八、面试官问

回到大家最关心的,如果面试官问:「简述什么是闭包,闭包的作用是什么。」,该怎么回答呢?

  1. 当函数嵌套时,内层函数引用了外层函数作用域下的变量,并且内层函数在全局作用域下可访问时,就形成了闭包。
  2. 模块化;防止变量被破坏。

注意:以上只是我的一种回答,并不是标准答案,每个人都应该有自己的理解和侧重点。


参考
《JavaScript高级程序设计(第3版)》
《你不知道的JavaScript(上)》
MDN
极客时间


  目录