首页 > 编程笔记

JS闭包的理解(非常详细)

这篇文章将介绍与 JavaScript 有关的函数式编程(Functional Programming)的基本概念。

函数式编程以函数为中心,每个操作都是一个函数,通过对函数的组合和复用来形成复杂的业务逻辑。

函数式编程的最终目的是只需调用一次函数,就可以完成所有业务逻辑,它属于声明式编程范式(Declarative Programming Paradigm),而我们之前看到的代码则大部分属于命令式编程范式(Imperative Programming Paradigm),即完成一个业务所关注的重点在于有哪些步骤。

闭包(Closure)指的是一种语法形式和变量查找机制,在一系列嵌套的函数中,所有内部的函数都可以访问外部函数及全局作用域中定义的变量、对象和函数(以下简称内容)等。

按这样的说法,JavaScript 中的函数全部都是闭包。因为在全局作用域中定义的函数,可以访问全局作用域的内容,在函数中定义的子函数则可以访问外层函数直到全局作用域中的所有内容。

例如定义一个 sayHello() 函数,可接收一个人名 name 作为参数,打印出“你好!”,并带上人名,但是打印的代码放到 sayHello() 的子函数 message() 中,在 sayHello() 内部调用 message(),代码如下:
function sayHello(name){
    function message(){
        console.log("你好!"+name);
    }
    message();
}
sayHello("李明");  //你好!李明
上方示例会输出:
你好!李明

从输出结果看,message() 函数成功地访问了 sayHello() 函数中的 name 参数的值,这样的结构就形成了一个闭包。

在闭包中,内部的函数可以捕获(Capture)外部函数作用域中的内容,如变量、其他函数等,这样即便把内部函数作为返回值从外部函数中返回再进行调用,它还是可以继续使用外部函数作用域中的变量和函数。通过捕获机制可以避免在多次调用函数时,需要重复向函数传递参数的问题。

假设有一个需求,可以对一个初始数值进行自定义步长的自增操作,如果使用普通函数定义,则需要多次传递初始值,代码如下:
function increment(initialValue,step){
    return initialValue+step;
}
let result=increment(10,1);  //11
result=increment(result,1);  //12
result=increment(result,2);  //14
示例中对 10 进行一次步长为 1 的自增,然后把结果 11 保存到 result 变量中,接着又对 result 进行步长为 1 的自增操作,此时仍然需要传递一次自增参数,得到结果 12 后,又把它保存到变量 result 中,再进行一次步长为 2 的自增,这一次仍然需要把 result 作为参数传递给 increment() 函数,这些调用反复使用 result 参数和步长值,有很多重复代码,但是如果把代码改成使用闭包的形式,则可以避免这种情况。

例如把 increment() 函数的定义改成闭包的形式,代码如下:
function increment(initialValue){
    let result=initialValue;
    return function by(step){
        result+=step;
        return result;
    };
}
这里的 increment() 函数接收一个 initialValue 参数,用于指定初始值,之后对它进行自增操作,然后在 increment() 函数内部定义一个 result 变量用于保存自增结果,并返回一个子函数 by()。

by() 函数接收一个 step 参数,用于指定自增步长,它会把外部函数中 result 的值加上 step 的值之后返回。

这时调用 increment() 函数并返回 by() 函数后,by() 函数会捕获 result 变量的值,使每次调用都能够记住 result 而不用再次传递了,所以只需传递步长参数,代码如下:
const incFiveBy=increment(5);
console.log(incFiveBy(2));  //7
console.log(incFiveBy(4));  //11
这里,代码首先使用 increment() 函数设置了初始值 5,然后使用 incFiveBy() 保存返回值,即内部的 by() 函数,这时 by() 函数就已经捕获了 result 的值 5,并形成了一个闭包,之后调用 incFiveBy(2),会对 5 进行加 2 操作,并把结果再次赋值给 result 并返回,此时 result 的值为 7,再次调用 incFiveBy(4) 进行加 4 操作就会基于 7 进行操作,结果返回 11。

从结果可以看到,incFiveBy() 中的 result 值是共享的,可以把它称为状态(State),每次调用 incFiveBy() 的时候都会修改状态,这个是闭包的用途之一,在多次函数调用之间共享状态。

不过,状态值只在同一个闭包内部共享,对于每次创建的新的闭包,它们之间的状态不会互相影响,是各自独立的。

例如再对一个数字 10 进行自定义步长的自增操作,那么它不会影响之前对 5 的操作,代码如下:
const incTenBy=increment(10);
console.log(incTenBy(3));  //13
console.log(incTenBy(5));  //18
console.log(incFiveBy(1)); //12
为了达到调用一次函数就可以完成所有操作的目的,并且消除重复传递 step 步长参数,还可以对上方示例代码进行精简,把特定步长的自增再单独定义成函数,这时函数将不再接收参数,而是在函数内部直接把步长参数写死。

例如,把对 5 进行自增 2 和自增 4 的操作定义成没有参数的函数,代码如下:
const incFiveBy=increment(5);
const incFiveByTwo=()=> incFiveBy(2);
const incFiveByFour=()=>incFiveBy(4);
这样每次在调用 incFiveByTwo() 和 incFiveByFour() 时,都会对结果进行自增 2 和自增 4 的操作,代码如下:
console.log(incFiveByTwo());   //7
console.log(incFiveByFour());  //11
console.log(incFiveByFour());  //15
闭包还有一个用处:定义私有的状态。由于在闭包的外部,无法访问内部作用域,因此可以对内部状态起到保护作用,调用者只能使用闭包暴露出来的函数或对象等对状态进行修改,除此之外就没有其他办法修改内部的状态了。

例如,对于一组数据,允许访问当前元素,并且有向前和向后移动索引的操作,但不允许修改数据的值(可以想象为轮播图或音乐播放器),那么可以通过闭包的形式定义数据和操作数据的函数,然后通过一个对象把这些函数暴露给外界,用以移动索引,代码如下:
function data(){
    let arr=[1,3,5,7,9];
    let index=0;
    return{
        value(){
            return arr[index];
        },
        next(){
            index=++index%arr.length;
        },
        pre(){
            index=(--index+arr.length)%arr.length;
        },
    };
}
这里使用对象形式返回了 3 个函数:
调用 data() 函数的代码如下:
const myData=data();
console.log(myData.value());  //1
myData.next();                //index:1
myData.next();                //index:2
console.log(myData.value());  //5
myData.pre();                 //index:1
myData.pre();                 //index:0
myData.pre();                 //index:4
console.log(myData.value());  //9
可以看到,除了使用 data() 函数暴露出来的 3 个函数访问数组之外,就再也无法在 data() 外部使用任何方式篡改 arr 数组和 index 索引的值了,另外在 data() 的外部作用域中,如果定义同名的 arr 或 index,也不会把 data() 内部的数组和值给覆盖掉。

从上述例子可以看出,data() 函数的名字并不重要,可以使用匿名函数,但是 JavaScript 不能直接使用 function(){} 这样的语句定义匿名函数,而需要把它保存到变量中,并且仍然需要给变量起名字。

要解决这个问题,可以在定义匿名函数的时候就立即调用它,然后使用一个变量保存它的返回结果,这种在定义的同时直接进行调用的函数称为立即执行函数表达式(Immediately Invoked Function Expression,IIFE),它的形式是使用 () 把匿名函数包裹起来,然后在后边使用另一对 () 调用它,代码如下:
const myData=(function(){
    let arr=[1,3,5,7,9];
    //...省略内部逻辑
})();
这样定义的函数会被立即执行,然后把结果保存到 myData 中,之后的调用和上例中一样。

很多前端库会以这样的形式提供 API,其目的就是防止不同的库之间的作用域互相影响,从而导致某些库的数据被另一些库给覆盖。

使用闭包还能解决一个常见的、由全局作用域引发的问题,代码如下:
for(var i=0;i<3;i++){
    setTimeout(()=>{
        console.log(i);
    });
}
setTimeout() 用于推迟一段代码的执行,它接收两个参数,第1个是回调函数,第2个是延迟时间,回调函数中的代码会在指定延迟时间之后执行,如果忽略了第2个参数,则会在 for 循环完成之后立即执行回调函数。

代码中使用循环创建了3个要延迟执行的代码,均为打印 i 的值。

代码的运行结果很容易就会被认为是 0 1 2,但实际上是 3 3 3。原因在于,使用 var 定义的变量的作用域是全局的,在 for 循环结束的时候 i 的值已经变成了3,那么后边打印 i 的值就全部都是3了。

要解决这个问题可以使用立即执行函数创建一个闭包,通过把 i 当作参数传递给它来捕获 i 的值,从而可以打印出 0 1 2,代码如下:
for(var i=0;i<3;i++){
    (function(i){
        setTimeout(()=>{
            console.log(i);
        });
    })(i);
}
或者另一个解决方法是直接使用 let 定义指示变量 i,这样它的作用域为块级,每次在 for 循环开始时会产生一个新的作用域,这样每个 setTimeout() 中 i 的值就不会受影响了。

之前提到了任何一个 JavaScript 函数都会形成一个闭包,这是由 JavaScript 语言本身的特性决定的。

JavaScript 的作用域为词法作用域,与之相关的有词法环境(Lexical Environment),它是 ECMAScript 规范中描述的一种特殊的对象类型,不能实际访问或者对其操作。

词法环境会在代码执行到全局作用域、函数声明、块级作用域时创建,它包含两部分:环境记录(Environment Record)和外层词法环境的引用。

环境记录包含了当前作用域中定义的变量、函数等的绑定关系。变量的定义是把变量值绑定到变量标识符的过程,这样环境记录中就保存了这种绑定关系。这里以伪代码的形式展示了词法环境的结构,代码如下:
{
    variable1:value1,
    variable2:value2,
    function1:function(){},
    ...,
    outer:<外部词法环境引用>
}
在某个作用域的代码执行前,JavaScript 会把该作用域中变量的声明、函数的定义先行记录到词法环境中。

这里需要注意的是,词法环境中首先记录的是变量的声明,仅仅包含标识符,它对应的变量值会被设置为 undefined,而函数的定义(包括函数体)则会被全部记录到词法环境中。在记录函数时,还会把当前词法环境保存到函数内部的 [[Environment]] 属性中。

之后在运行代码时,如果遇到变量定义语句,则会对当前词法环境中的变量进行赋值;如果遇到新的作用域(如内部函数),则会用同样的过程创建一个新的词法环境,并把 outer 设置为上一层的词法环境。

在内部的作用域中,如果要访问某个变量或函数,则会首先在本身的词法作用域中寻找,如果没有,则会到 outer 引用的外层词法作用域中寻找,直到全局词法环境中;如果找到了,则会返回相应的值,如果没找到就返回 undefined。全局词法环境对应的是全局作用域。

因为本文介绍闭包,所以这里以它为例来介绍一下词法环境的创建过程,代码如下:
function sayHello(name){
    return function message(){
        console.log("你好!"+name);
    }
}
let greet=sayHello("李明");
greet();  //"你好!李明
这个示例把之前的示例代码稍做了一些改动,让 sayHello() 直接返回 message() 函数,并在外边调用,可以看到 greet() 函数在外边调用时还能访问 name 的值。

代码在执行前,会先创建全局词法环境,代码如下:
globalEnv
{
    greet:undefined,
    sayHello:function(name){/*省略代码体*/}
    outer:null
}
globalEnv 是为了方便描述所起的假想的名字,它代表全局词法环境对象,它会记录 greet 变量的声明和 sayHello() 函数的定义,对外层词法环境的引用为 null,因为它本身是全局词法环境,没有再高一层的词法环境了。同时,globalEnv 词法本身也保存到 sayHello() 的 [[Environment]] 属性中了。

接下来代码执行 let greet=sayHello("李明"),调用 sayHello() 函数,此时在进入 sayHello() 函数时会创建一个新的词法环境,这里称它为 sayHelloEnv,代码如下:
sayHelloEnv
{
    name:"李明",
    message:function(){},
    outer:globalEnv  //即sayHello中[[Environment]]属性的值
}
在 sayHelloEnv 这个词法环境中,记录了参数 name 和 message() 函数的定义,并把外层词法环境设置为 globalEnv,作为 sayHello() 函数中 [[Environment]] 属性的值,然后 sayHelloEnv 会作为 [[Environment]] 属性值保存到 message() 函数中。

在 sayHello() 函数返回后,会把 globalEnv 中名为 greet 的标识符绑定为 sayHello("李明") 的返回值。接着调用 greet 保存的函数,此时进入 message() 函数体中,又会创建一个新的词法环境,这里称它为 messageEnv,代码如下:
messageEnv
{
    outer:sayHelloEnv
}
其中没有定义任何其他变量和函数,所以直接把它的 outer 设置为 sayHelloEnv 词法环境。

在执行它里边的代码时,需要使用 name 的值,此时 messageEnv 本身并没有这个变量,所以它会到 outer 指向的 sayHelloEnv 中去寻找,结果发现了 name 变量,值为“李明”,那么它就可以正确地被打印出结果了。

如果再有一个 greet2 变量,保存了 sayHello() 函数的调用结果并传递了不同的 name 属性值,则后边在调用 greet2() 时会打印出不同的 name 属性值,同时也不会影响 greet() 的返回结果,代码如下:
let greet2=sayHello("张三");
greet2();  //"你好!张三"
greet();   //"你好!李明"
这是因为 greet() 和 greet2() 指向了不同的词法环境。在调用 sayHello("李明") 时会创建 sayHelloEnv 词法环境,而在调用 sayHello("张三") 时又会创建新的 sayHelloEnv2 词法环境,它们的 name 变量分别为“李明”和“张三”,且互不影响。

可以看到,通过这个词法环境机制,每个函数都保存了外层词法环境的引用,这样内部的函数都可以通过一条链的引用(可称作环境链,Environment Chain,或作用域链,Scope Chain),访问直至全局词法环境中记录的所有内容。

词法这个概念,简单来讲,就是代码的字面结构,直接可以根据大括号、函数等的位置,就能确定它们的作用域和词法环境,所以称它为静态的,而与它相对的,则是动态的,需要在程序运行时才能确定作用域的内容,这都与编程语言的实现机制有关。

推荐阅读