Closure

36 阅读14分钟

闭包

定义

Closure 这个词最早出现在1964 年的computer journal 杂志上的《The Mechanical Evaluation of Expression》论文[academic.oup.com/comjnl/arti…]。

它包含控制和环境两个部分,在JavaScript中,以函数能够访问其定义时的环境中变量的方式得以实现。

《JavaScript 编程全解 [(日)》

闭包是一种特殊函数,这种函数会在被调用时保持当时的变量名查找的执行环境。

红宝书(p178 )

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

一个函数和对其周围状态的引用捆绑在一起,这样的组合就是闭包。

闭包简单理解为定义在一个函数内部的函数。其中一个内部函数在包含它们的外部函数之外被调用时,就会形成闭包。

闭包就是记录函数实例在运行期的“可访问标识符”(identifiers in lexical scope)的结构。因此一个函数实例的一次执行,就会带来一个新的执行期作用域,即一个闭包。ECMAScript 在执行环境(执行上下文)中用词法环境来指代这些可访问标识符。

 function outerTest() {
     var num = 0
     function innerTest() {
         ++num
         console.log(num)
     }
     return innerTest
 }
 ​
 var fn1 = outerTest()
 fn1()
 fn1()
 fn1()
 ​
 // logs
 1
 2
 3
 // ES6
 function makeClosureFunc(...args) {
     var func = args.shift()
     return function(...rest) {
         return func.apply(null, args.concat(rest))
     }
 }

一个闭包内对变量的修改,不会影响到另外一个闭包中的变量。

产生原因

局部变量无法共享和长久的保存,而全局变量可能造成变量污染,希望有一种机制既可以长久的保存变量又不会造成全局污染。

特性

  1. 函数嵌套函数
  2. 函数内部可以引用外部的参数和变量
  3. 返回的是一个函数,并且这个函数对局部变量存在引用,形成了闭包的包含关系
  4. 参数和变量不会被垃圾回收机制回收

优缺点

优点:可以设计私有的方法和变量

  • 特权方法(privileged method):可以访问私有变量的公共方法
  • 模块模式

缺点:常驻内存,会增大内存使用量,使用不当很容易造成内存泄漏。

 // 创建一个闭包
 // element 元素的事件处理程序创建了一个循环引用
 function assignHandler() {
     let element = document.getElementById('someElement')
     element.onclick = () => console.log(element.id)
 }
 ​
 // 修改:
 // 闭包改为引用一个保存着element.id 的变量id,从而消除了循环引用
 // 解除了对这个COM 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收
 function assignHandler() {
     let element = document.getElementById('someElement')
     let id = element.id
     element.onclick = () => console.log(id)
     element = null
 }

闭包的使用

在语法分析阶段,JS 能从函数的代码文本中得到以下两组信息:

  • varDecls:列表(VarDeclaredNames, and VarScopedDeclarations)是指所有顶层(指函数自身的声明,而不包括其内嵌子级的声明)的变量声明(var)。
  • lexicallyDecls:列表(LexicallyDeclaredNames, and LexicallyScopedDeclarations)是指所有顶层的词法声明,包括各种具名函数、let/const 声明、标签化语句中的标签,以及export 中导出的名字等。
运行期的闭包

当函数开始执行时,JS 会创建一个执行环境(Environment),并将其可用的标识符列表指向函数实例中的作用域,从而完成从作用域(Scope)到闭包(Closure)的、在概念上的映射。

闭包是运行期的,所以是变化的、有状态的、可存储的。

第一个可变的标识符是this。函数调用的行为在预处理完成之后就会调用绑定this(BindThis),使this 引用成为闭包中动态添加的第一个信息。

接下来是执行代码(EvaluateBody)。

闭包中的可访问标识符

EvaluateBody 并不是用户代码开始执行的起点,最先做的是初始化闭包。

在闭包初始化时,JS 会将varDecls 和lexicallyDecls 两组信息合并起来形成一份可访问标识符列表。这个标识符列表由两部分构成:变量环境、词法环境

但这两个环境与varDecls 和lexicallyDecls 并不是严格对应的。如果是在非严格模式下,那么这两个部分引用自同一个结构,是相同的;在严格模式中,后者指向前者(类似将变量环境作为词法环境的原型,作用域是指向lexicallyDecls,并由后者再指向varDecls)。因此,通过变量环境可能访问不到词法环境,而反过来从词法环境总是可以访问到全部的标识符列表,所以静态来看,这个列表也称为词法作用域(lexical scope)。

在这个阶段,JS 还会处理另外两个列表信息:顶层的子函数列表,参数列表。这两个列表中的名字,也会被添加到可访问标识符列表的变量环境中。而arguments 是作为一个特殊的名字来处理的,具体的规则是:

  • 如果arguments 出现在参数列表中,它或是函数名,或是已经在lexicallyDecls 中声明过的名字,则忽略后续的参数绑定操作
  • 否则基于该函数的形式参数信息(formals and argumentsList)来创建一个参数对象,如果当前是非严格模式,则该对象支持“形式参数映射(mapped)”,否则它是非映射的(unmapped);
  • 然后将名字arguments 添加到参数列表中,并将上述参数对象以该名字绑定到访问标识符列表的变量环境中

于是得到了整个标识符列表。这些标识符包括这个函数内顶层的各种变量、常量、函数名、参数名、类名和标签,以及arguments。

在这个列表中的所有名字都可能有它们对应的值。如,函数、类等声明总是在语法解析期结束后就能决定要绑定的值,而var/const/let 等需要赋值过程的就不会绑定值,后者包括所有需要初始器(Initializer)的数据。

另外需要绑定值的,是在调用这个函数时传入的参数,因为闭包是函数被调用时产生的,因此在决定创建闭包时已经可以知道实际参数,并通常被记为argumentList —它们也会在这个阶段被绑定到它对应的形式参数名上。如果参数是非简单的(默认参数、剩余参数以及模板参数),那么在绑定参数前,JS 会先多创建一层环境,并将相关的初始过程(例如默认值以及它的读取过程)放到这个多出来的环境中,作为一个动态添加出来的作用域。这样既隔离了引擎级别的初始过程与用户代码,又能使argumentsList 在(调用初始过程来)绑定参数时通过作用域访问到它。

然后,那些绑定了值的参数将优先被添加到标识符列表的变量环境中。而之后再绑定其他声明的名字时,这些被形式参数名预先占用掉的名字会被跳过而不会重复绑定,也不会被重复地初始化。

用户代码导致的闭包变化

引擎的行为在用户代码来看是不可见的。

 // 示例1
 function myFunc(num) {
     var num = num + 1
     return num
 }
 console.log(myFunc(10)) // num 指向参数名,显示11

【分析】

  • 在第5 行代码调用myFunc() 时,JS 引擎为myFunc() 函数实例准备好了一个闭包,包括两个可访问标识符和它的信息:

    // 闭包的标识符列表(varDecls and lexicalDecls)
    identifiers = {
        "num": {存放num 信息的结构,指明它是一个已绑定的、值为10的、可变的变量声明}
        "arguments": {绑定到特定的arguments 对象,是一个可变的变量声明}
    }
    
  • 由于变量名num 实际上是通过形式参数名来绑定的,所以var num = num + 1 中的var 其实没有起到任何作用。它原本会导致varDecls 中的对应记录在identifiers 中创建一个标识符,但是如前所述:这个名字被函数的形式参数优先使用了,因此代码行2 最终只是执行了一个一般的赋值语句而已。

    • 绑定过程的原则:

      • 1 内部函数声明覆盖参数名
      • 2 内部函数或参数名中有‘arguments’ 名称的标识符时,将覆盖函数的arguments 对象
      • 3 函数内的varDecls 声明中的标识符如果已经在其他位置被声明(如,内部函数名或参数名),将不再新创建变量

根据原则3 ,记录在varDecls 中的变量num 并没有生效,它被忽略了。因此在语法分析之后、引擎为函数准备执行环境—闭包、标识符系统等正式运行之前,这个标识符就指向argumentNames 中的名字。

// 示例2
function myFunc(num) {
    myFunc = num + 1
}

在刚刚给出的闭包的标识符列表identifiers 中,并没有myFunc 这个名字。myFunc = num + 1他访问到upvalue,并且重写了这个函数本身。

console.log(typeof myFunc, myFunc) // number 11
函数表达式的特殊性
// 示例3
var msg = (function myFunc(num) {
    return myFunc = typeof myFunc
})(10) + ", and upvalue's type is: " + typeof myFunc
> console.log(msg)
function, and upvalue's type is: undefined

可见在myFunc() 函数内能访问和重写这个标识符,但是在它的外部却无法得到这个引用。因为JS 在处理函数表达式的闭包时,与之前的函数声明并不相同。尽管它们貌似都是相同的字面量声明,但在JS 中却是不同的语法元素。

首先,在它们的作用域中都有相同的标识符列表;然而JS 在为函数表达式构建闭包时使用了双层的作用域。外层的作用域,称为“函数环境(funcEnv)” ,其中只有简单的一个标识符,之后才是该函数自己的作用域:

outerScope = {
    identifiers: {
        "myFunc": {存放myFunc 信息的结构,该函数表达式闭包作为值绑定到该标识符}
    },
    parent: {指向赋值表达式所在的作用域}
}

myFuncScope = {
    identifiers: {
        "num": {存放num 信息的结构,指明它是一个已绑定的、值为10的、可变的变量声明}
    	"arguments": {绑定到特定的arguments 对象,是一个可变的变量声明}
    },
    parent: outerScope
}

而myFunc 的闭包只需要在运行环境中引用myFuncScope 来作为词法作用域并形成链(lexical scope chain)。

这样一来,在myFunc 函数内既能访问传入参数和内部的其他变量声明(示例1),也能像示例2 一样重写作用域链上的myFunc 这个标识符,又不会影响赋值表达式及其之外的作用域。因为这个outerScope是插入在当前表达式和myFunc 的闭包(所引用的myFuncScope 作用域)之间的。

闭包的内存模型

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())
  • 当 JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  • 在编译过程中,遇到内部函数 setName,JavaScript 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 myName 变量。
  • 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 JavaScript 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
  • 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

image-20220414141751398

当执行到 foo 函数时,闭包就产生了;当 foo 函数执行结束之后,返回的 getName 和 setName 方法都引用“closure(foo)”对象,所以即使 foo 函数退出了,“ closure(foo)”依然被其内部的 getName 和 setName 方法引用。所以在下次调用bar.setName或者bar.getName时,创建的执行上下文中就包含了“closure(foo)”。

产生闭包的核心有两步:

  • 需要预扫描内部函数
  • 把内部函数引用的外部变量保存到堆中

应用场景

代码模块化

闭包可以在一定程度上保护函数内的变量安全。

Java、php 等语言中有支持将方法声明为私有,它们只能被同一个类中的其它方法所调用。JS 没有这种原声支持,但是可以使用闭包来模拟私有方法。

私有方法不仅有利于限制对代码的访问权限,还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

// 使用闭包来定义公共函数,并让它可以访问私有函数和变量
var Counter = (function() {
    var privateCounter = 0
    function changeBy(val) {
        privateCounter += val
    }
    return {
        increment: function() {
            changeBy(1)
        },
        decrement: function() {
            changeBy(-1)
        },
        value: function() {
            return privateCounter
        }
    }
})()

console.log(Counter.value()) // 0
Counter.increment() // 递增
Counter.increment() // 递增
console.log(Counter.value()) // 2
Counter.decrement() // 递减
console.log(Counter.value()) // 1

IIFE匿名函数包含两个私有数据:名为 privateCounter 变量changeBy函数,而这两项都无法在这个匿名函数外部直接访问。必须通过匿名函数返回的三个公共函数接口来进行访问。

increment()、decrement()、value()这三个公共函数是共享同一个作用域执行上下文环境的变量对象,它们都可以访问 privateCounter变量 和 changeBy 函数。

异步回调

在定时器、事件监听、Ajax 请求、跨窗口通信、Web Workers或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。

事件监听(for 循环)
<button>Button0</button>
<button>Button1</button>
<button>Button2</button>
<button>Button3</button>
<button>Button4</button>
window.onload = function() {
    var btns = document.getElementsByTagName('button')
    for (var i = 0; i < btns.length; i++) {
        btns[i].onclick = function() {
            console.log(i)
        }
    }
}
// 不论点击哪个按钮,均输出5

Onclick 是被异步触发的,也就是等着用户事件被触发时,for 循环早已结束。此时变量i 的值已经是5 ,所以当onclick 事件函数顺着作用域链从内往外查找变量i 时,找到的值总是5,也就是这个变量i 已经在外层的变量对象中一直保存的都是最终值。

如果想要每次打印出所对应的索引号,就可以使用闭包。

window.onload = function() {
    var btns = document.getElementsByTagName('button')
    for (var i = 0; i < btns.length; i++) {
        (function(i) {
            btns[i].onclick = function() {
                console.log(i)
            }
        })()
    }
}
定时器(for 循环)
for(var i = 1; i <= 5; i ++){
    setTimeout(function timer(){
        console.log(i)
    }, 0)
}

因为setTimeout 为宏任务,由于JS 中单线程eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后setTimeout中的回调才依次执行,但输出i 的时候当前作用域没有,往上一级再找,发现了i,此时循环已经结束,i变成了6。因此会全部输出6。

解决方法

法1、利用IIFE,当每次for 循环时,把此时的i 变量传递到定时器中

for(var i = 1; i <= 5; i++){
    (function(j){
        setTimeout(function timer(){
            console.log(j)
        }, 0)
    })(i)
}

法2、给定时器传入第三个参数,作为timer 函数的第一个函数参数

for(var i=1; i<=5; i++){
    setTimeout(function timer(j){
        console.log(j)
    }, 0, i)
}

法3、使用ES6 中的let

for(let i = 1; i <= 5; i++){
    setTimeout(function timer(){
        console.log(i)
    }, 0)
}
// let 使JS 发生革命性的变化,让JS 由函数作用域变为了块级作用域,用let 后作用域链不复存在。
结果缓存

设想有一个处理过程很耗时的函数对象,每次调用都会花费很长时间,那么就需要将计算出来的值存储起来,当调用这个函数的时候,首先在缓存中查找,如果找不到,则进行计算,然后更新缓存并返回值;如果找到了,直接返回查找到的值即可。闭包可以做到这一点,因为它不会释放外部的引用,从而函数内部的值可以得以保留。