一篇帮你轻松理解 JavaScript 闭包的文章

346 阅读6分钟

理解闭包

提示:对下面定义的掌握,是你真正理解和识别闭包的关键。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。按照《JavaScript 高级程序设计》书中所说:闭包是指有权访问另一个函数作用域中的变量的函数。

一个简单的闭包

直接通过代码来解释闭包才是最有效的。下面,让我们一起来看一段代码。

var name = '葡萄';
function eat() {
    console.log( name );
}
eat(); // '葡萄'

正如代码所示,eat() 函数可以“记住”并访问变量 name。而这样的代码我们肯定写过不止一次,但可能从未意识到,我们其实是在创建一个闭包!

无需感到奇怪!因为变量 name 和函数 eat() 都是在全局作用域中声明的,而该作用域实际上就是一个闭包(只要应用处于运行状态,它就不会消失)。这也是为何,eat() 函数可以访问到外部变量,因为它仍然在作用域内并且是可见的。

另一个闭包

就上面一个例子而言,虽然闭包存在(不明显),但是我们无法对其进行观察,也不明白它是如何工作的。而我们下面这个例子,则将向你清晰地展示出闭包该有的样子。

function eat() {
    var name = '葡萄';
    function fruit() {
        console.log( name );
    }
    return fruit;
}
var eatFruit = eat();
eatFruit(); // '葡萄'

从本例中,我们知道:

  1. fruit() 函数的词法作用域能够访问 eat() 函数的内部作用域
  2. fruit() 函数本身会被当作一个值类型进行返回
  3. 返回值(也就是 fruit() 函数)被赋值给变量 eatFruit 并调用 eatFruit()

虽然,调用 eatFruit(),实际上只是通过不同的标识符引用调用了内部的函数 fruit()。但是,它是在自己定义的词法作用域以外的地方执行的

我们都知道 JavaScript 引擎的垃圾回收器,会释放不再使用的内存空间。而在正常情况下,eat() 函数执行完成后,它的整个内部作用域都会被销毁并被回收,变量 name 将不能再被访问。然而,代码仍按预期运行。为什么会这样呢?

原因是,上面代码中的函数形成了闭包。闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例中,fruit() 还在使用 eat() 的内部作用域。由于 fruit() 所声明的位置,使得它拥有涵盖 eat() 内部作用域的闭包(该闭包不仅包含了函数的声明,还包含了在函数声明时该作用域中的所有变量),因此该作用域能够一直存在,以供 fruit() 在之后任何时间进行引用。

为了便于理解,我们可以结合图1.1来看。

闭包.png

1.1——闭包示意图图1.1——闭包示意图

图中的大圆就代表着本例的闭包(这需要你的想象),只要内部函数 fruit() 一直存在,其闭包就会一直保存着该函数的作用域中的变量。

现在知道什么是闭包了吗?没错,fruit() 函数对 eat() 函数内部作用域的引用就叫作闭包。换句话说,一个函数和对其周围状态(词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包

加强理解

下面展示的的两个案例都对函数类型的值进行了传递。它们都在使用闭包。

案例一

function eat() {
    var name = '葡萄';
    function fruit() {
        console.log( name );
    }
    eatFruit( fruit );
}

function eatFruit(fn) {
    fn(); // 这就是闭包
}

eat()

案例二

var fn;
function eat() {
    var name = '葡萄';
    function fruit() {
        console.log( name );
    }
    fn = fruit; 
}
function eatFruit() {
    fn(); // 这个也是闭包!
}

eat();
eatFruit();

通过对以上案例的观察,我们能够知道,无论使用何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用。因此,无论我们在何处执行这个函数,它必然会使用闭包。

另外,我想大家都可能写过下面这样的代码:

function backFn(value) {
    setTimeout( function handleTimer() {
        console.log( value );
    }, 1000);
}

backFn( "葡萄" );

很显然,将内部函数 handleTimer 传递给 setTimeout,那么 handleTimer 就会具有涵盖 backFn 函数作用域的闭包。所以,在 backFn() 执行1000毫秒后,其内部作用域不会消失,handleTimer 也仍保有对变量 value 的引用。

举这个例子,主要是想告诉大家,在定时器、Ajax 请求、事件监听器或任何其他的异步(或同步)任务中使用回调函数时,实际上就是在使用闭包!这样看来,是不是觉得自己已然使用过很多次闭包了呢!

循环和闭包

对于下面的一段代码,大家应该都不陌生。

for (var i=1; i<=5; i++) {
    setTimeout( function handleTimer() {
        console.log( '我是:' + i );
    }, 1000 );
}

通常来说,我们会认为这段代码将分别输出数字 1~5,然而实际上,这段代码会输出五次 6。

为什么呢?要搞懂这个原因,需要明白两点:

  1. setTimeout 的回调会在循环结束时才执行,即使你将延迟时间设为 0 毫秒。
  2. 循环的终止条件是 i 不再小于等于 5,而条件成立时 i 的值为 6。

因此,输出的值将是循环结束时 i 的最终值,也就是 6。

出现这种错误预期的原因:

  1. 想当然地认为循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。
  2. 没有意识到在各迭代中分别定义的函数,其实都被封闭在一个共享的全局作用域中,而这个作用域中只有一个 i 变量。

事实上,上面的代码同在全局作用域中,定义一个变量 i,然后再将延迟函数的回调重复定义五次,是完全等价的。

那么该如何解决这个问题呢?答案是:使用闭包。

for (var i=1; i<=5; i++) {
    (function(j) {
        setTimeout( function handleTimer() {
            console.log('我是:' + j);
        }, 1000 );
    })(i);
}

在这个例子中,将循环和 IIFE(立即执行函数表达式)进行结合,为每次迭代创建一个闭包并将 i 传递进去。最终,它将会按照我们预期的那样:输出数字 1~5。

问题解决了吗?是的!但是,这种方法有缺陷:它创建了很多闭包作用域。

我们都知道,过多的使用闭包并不是一件好事,因为它在处理速度和内存消耗方面对脚本性能具有负面影响。所以,我们来看一个更好的例子:

for (var i=1; i<=5; i++) {
    let j = i;
    setTimeout( function handleTimer() {
        console.log('我是:' + j);
    }, 1000);
}

学习过 es6 语法的同学们应该知道:let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

在上面例子的 for 循环中,每次迭代都会用 let 声明变量,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量,因此每个闭包都绑定了块作用域的变量,从而使得我们不再需要额外的闭包。完美!当然,这个例子还可写的更简洁一点:

for (let i=1; i<=5; i++) {
    setTimeout( function handleTimer() {
        console.log('我是:' + i);
    }, 1000);
}

使用闭包

常见的使用闭包的情景:回调函数和封装私有方法。

回调函数

回调函数指的是需要在将来不确定的某一时刻异步调用的函数。一般来说,使用这种回调函数,往往都需要访问外部数据。下面是一个在 click 事件的回调函数中使用闭包的简易例子。

<div id="btn">click</div>
<div id="btn2">click 2</div>
<script>
  // 回调函数,设置字体大小
  function handle(size) {
    return function () {
      document.body.style.fontSize = size + 'px';
    };
  }
  // 封装click事件监听,以便能够监听不同的元素
  function clickFn(eleId, size) {
    let ele = document.getElementById(eleId)
    ele.addEventListener('click', handle(size));
  }

  clickFn('btn', 30)
  clickFn('btn2', 20)
</script>

通过监听 div 元素的 click 事件,修改 body 元素的 font-size 属性。

封装私有方法

许多编程语言(比如 Java)都支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。私有方法不仅有利于限制对代码的访问,而且还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。

然而,这一非常有用的特性,原生 JavaScript 并不支持。不过,我们却可以使用闭包来模拟私有方法,这一件值得庆幸的事。

var counter = function() {

  var num = 0; // 私有变量
  
  function countNum(val) { // 私有方法
    num += val;
  }
  
  // 返回两个公共函数,以便外部能够访问变量 num 和 countNum 函数
  return {
    increment: function() { 
      countNum(1);
    },
    value: function() {
      return num;
    }
  }
};

var c1 = counter();
var c2 = counter();

c1.increment();

console.log( c1.value() ); // 1
console.log( c2.value() ); // 0

上述案例中,每次调用 counter() 都会创建一个闭包且有自己的词法环境。每个环境中都包含两个私有项:num 变量和 countNum 函数。它们无法在外部直接访问,只能通过返回的两个公共函数(它们是共享同一个环境的闭包):increment 和 value 才能访问。且每个闭包都引用自己词法作用域内的变量 num ,互不影响。

参考