[译文] JavaScript 之闭包

原文地址:A Simple Explanation of JavaScript Closures

回调 ( callbacks ),事件句柄 ( event handlers ),高优先级函数 ( higher-order function ① ) 等这些之所以可以访问其作用域外的变量,就是因为闭包;闭包是面向函数范式编程里一个非常重要的概念,同时也是 JavaScript 面试时经常被问到的一个知识点

① 在函数内操作的其他函数(接受它们作为参数或返回它们)称为高优先级函数

虽然闭包随处可见,但其实真正理解并不简单;如果你对理解闭包还差那么一层窗户纸,那么这篇文章就是帮你捅破那层窗户纸

首先,我会给你们介绍两个基本术语:作用域和词法作用域( scope and lexical scope ),当你了解这些基础以后,你离理解闭包也就只有一步之遥了。文章的最后,我会通过一个真实世界的例子来试图解释闭包

开始之前,我强烈建议你不要跳过作用域和词法作用域这两个小节,这两个概念对理解闭包非常重要

1. 作用域( The scope )

当你定义一个变量,你总是希望能在一定的范围内用它。比如:你定义一个变量 result ,看起来这个变量就应该在名为 calculate() 的函数内,用它来保存这个函数的计算结果并返回 。在 calculate() 函数外部,这个变量并没什么用

作用域就是管理变量的可访问性的,你可以在定义该变量的作用域内访问该变量,但是在这个变量的作用域外,你就禁止访问该变量。 在 JavaScript 种,作用域一般是由函数或者代码块产生

让我们来看看变量 count 是怎么被作用域影响的, count 属于函数 foo() 创造的一个作用域内的变量 :

function foo() {
  // The function scope
  let count = 0;
  console.log(count); // logs 0
}

foo();
console.log(count); // ReferenceError: count is not defined

变量 count 在函数 foo() 内可被自由访问。但是在函数 foo() 外,count 变量就被禁止访问,如果你试图访问,就会抛异常 ReferenceError: count is not defined

“ 作用域的作用是规定变量是否可以被访问 ”

因为作用域隔绝了变量,所以在不同的作用域内,你可以取相同的变量名。比如不同方法内使用一些很通用的变量名( i,count,index,current,value 等等),示例如下:

function foo() {
    // "foo" function scope
    let count = 0;
    console.log(count); // logs 0
}

function bar() {
    // "bar" function scope
    let count = 1;
    console.log(count); // logs 1
}

foo();
bar();

变量 count 在函数 foo(),bar() 内都被定义了,而代码也良好的运行,并没有产生冲突

2. 作用域嵌套(Scopes nesting)

作用域很简单,对不对?那我们就进阶点难度,在一个作用域内嵌套另一个作用域会怎么样呢? 方法 innerFunc() 是嵌套在 outerFunc() 方法内:

这两个方法的作用域是怎么相互影响的呢?在 innerFunc() 函数的作用域内是否可以访问变量 outerVar 呢?我们用个例子来说明一下:

function outerFunc() {
    // the outer scope
    let outerVar = 'I am outside!';
    function innerFunc() {
        // the inner scope
        console.log(outerVar); // => logs "I am outside!"
    }
    innerFunc();
}
outerFunc();

从打印结果来看,函数 innerFunc() 作用域内可以访问变量 outerVar ,从这点可以推断以下2个结论:

  • 作用域可以嵌套
  • 外层作用域的变量可以被内层作用域访问

3. 词法作用域(The lexical scope)

JavaScript 是如何将函数 outerFunc() 作用域的变量 outerVar 映射到 innerFunc() 函数作用域内的呢?

因为 JavaScript 实现了一个作用域机制,名叫 词法作用域 (或者静态作用域) 。 词法作用域确定了变量的可访问性由变量所在源代码中的位置所决定 。简单一句话就是,词法域规定:在嵌套作用域的环境中,内部作用域可以访问外部作用域的变量

它被称为词法(或静态),因为引擎(在词法分析时)仅通过查看 JavaScript 源代码来确定作用域的嵌套,而不执行它 。下面是 JavaScript 引擎理解上面示例代码的过程:

  • 1. 我发现你定义了一个函数 outFunc(),该函数内包含一个变量 outerVar
  • 2. 在outFunc() 内部,我发现你定义了一个函数 innderFunc()
  • 3. 在innerFunc()内部,我发现一个变量 outerVar ,但是在该函数内部并未定义这个变量。因为使用词法作用域,那么我就认为,函数innderFunc()内部的变量 outerVar 其实就是 函数 outerFunc() 内的变量 outerVar

所以,词法作用域可概括为:

词法作用域由静态确定的外部作用域组成

示例如下:

const myGlobal = 0;

function func() {
    const myVar = 1;
    console.log(myGlobal); // logs "0"
    function innerOfFunc() {
        const myInnerVar = 2;
        console.log(myVar, myGlobal); // logs "1 0"
        function innerOfInnerOfFunc() {
            console.log(myInnerVar, myVar, myGlobal); // logs "2 1 0"
        }
        innerOfInnerOfFunc();
    }
    innerOfFunc();
}

func();

函数 innerOfInnerOfFunc() 的词法作用域由 函数innerOfFunc()的作用域,函数 func() 的作用域,以及全局作用域三部分组成。在函数 innerOfInnerOfFunc() 内你可以通过词法作用域访问 变量 myVar 和 myGlobal

函数 func() 的词法作用域 仅仅是由全局作用域组成,所以在函数 func() 内你可以访问全局变量 myGlobal ,但是并不能访问变量 myInnerVar

4. 闭包(The closure)

通过上面的知识,我们知道词法作用域允许静态地访问外部作用域的变量 ,这离理解闭包又进了一步

让我们回头再来看包含函数 outerFunc() 和 innderFunc() 的代码片段:

function outerFunc() {
  let outerVar = 'I am outside!';
  function innerFunc() {
    console.log(outerVar); // => logs "I am outside!"
  }
  innerFunc();
}
outerFunc();

我们知道,在函数 innerFunc() 作用域内,通过词法作用域可以访问外部变量 outerInner ,请仔细注意,函数 innerFunc() 的调用是在其词法作用域内(函数outerFunc() 的作用域)

我们来稍微改动一下,让 函数 innerFunc() 的执行在词法作用域范围之外,这样的话,函数 innderFunc() 是否依然能访问 变量 outerVar 呢? 代码片段如下:

function outerFunc() {
  let outerVar = 'I am outside!';
  function innerFunc() {
    console.log(outerVar); // => logs "I am outside!"
  }
  return innerFunc;
}
const myInnerFunc = outerFunc();
myInnerFunc();

现在,函数 innerFunc() 被执行的时候,是在定义函数 innerFunc() 的词法作用域之外了,重点来了:

函数 innerFunc() 依然能正常访问它词法作用域内的变量,即便它是在定义它的词法作用域外被执行

一句话,函数 innerFunc() 能通过词法作用域映射( 也可以理解成捕获,记住等) 变量 outerVar . 也就是说,innerFunc() 是个闭包函数,因为它通过词法作用域映射了变量 outerVar

所以呢,闭包就是一个函数访问其词法作用域内的变量,哪怕这个函数是在词法作用域外被执行

更简单点说,闭包就是一个函数记住了它定义处词法域内的变量以及变量的值,不管这个函数以后在哪儿被执行。所以确定闭包很简单:如果在函数中看到一个外来变量(未在函数中定义),那么该函数很可能是一个闭包,因为它捕获了外来变量

在上文的代码片段中, 变量 outerVar 对于闭包函数 InnerFunc() 来说,是个外来变量,它来自函数 outerFunc() 的作用域

让我们用更多例子来说明闭包吧

5. 闭包的例子(Closure examples)

5.1 事件句柄 (Event Handler)

让我们来显示一个按钮被点击了多少次:

let countClicked = 0;

myButton.addEventListener('click', function handleClick() {
  countClicked++;
  myText.innerText = `You clicked ${countClicked} times`;
});

打开示例 然后点击按钮,文本会更新你点击以后的次数。当按钮被点击以后,函数 handleClick() 会在DOM 代码中执行,这时已经远离定义它的位置了,但是因为闭包的原因,函数 handleClick() 通过词法域捕获了变量 countClicked 并刷新它的值。 当前, myText 其实也是通过词法作用域捕获的

5.2 回调(callbacks)

通过词法作用域捕获变量在回调中经常被用到,比如 setTimeout() 的回调方法

const message = 'Hello, World!';

setTimeout(function callback() {
  console.log(message); // logs "Hello, World!"
}, 1000)

函数 callback() 就是一个闭包,因为它捕获了变量 message . 又比如 forEach()的迭代器函数:

let countEven = 0;
const items = [1, 5, 100, 10];
items.forEach(function iterator(number) {
  if (number % 2 === 0) {
    countEven++;
  }
});
countEven; // => 2

函数 iterator 就是一个闭包,因为它捕获了 变量 countEvent

5.3 函数范式编程 (Functional programming)

柯里化函数就是调用一个函数会返回另一个函数 ,直到参数被完全提供。例子如下:

function multiply(a) {
  return function executeMultiply(b) {
    return a * b;
  }
}

const double = multiply(2);
double(3); // => 6
double(5); // => 10

const triple = multiply(3);
triple(4); // => 12

multiply 就是一个柯里化函数。对于函数范式编程来说,柯里化是个很重要的感念,而之所以能实现柯里化函数,还得感谢闭包。 excuteMultiply就是一个闭包函数,因为它从词法作用域捕获了变量 a , 当这个闭包函数被调用的时候,被捕获的变量 a 就和传入的参数 b 一起执行计算 a * b

6. 一个真实世界的闭包例子

我知道闭包很难掌握,但是一旦你get到它的点,你就永远掌握了它(就好比游泳,骑自行车),你可以根据如下的方式去理解闭包

想象一下你有一根神笔,如果你用它在纸上画现实世界的某些物品,那么这幅画就是跟这些物品相关联的窗口,通过这个窗口,无论你在世界的哪个角落,你都可以通过这幅画伸手进去移动这些物品,而真实世界里的这些物品也会被相应的移动。( 哇,这不就是一个任意门吗?好神奇 )

这幅神奇的画就是 闭包,而画里面的物品就是 词法作用域

7. 总结

作用域是 JavaScript 中变量是否可被访问的规则,可以是一个函数或一个范围块;词法作用域允许函数作用域从外部作用域静态地访问变量

发表评论

电子邮件地址不会被公开。 必填项已用*标注