原文地址:https://dmitripavlutin.com/simple-explanation-of-javascript-closures/
多亏了闭包,回调函数(callback)、事件处理函数(event handler)、高阶函数(higher-order function)才能访问作用域外的变量。在函数式编程中,闭包(Closures)是一个非常重要的概念,并且它经常会在 JavaScript 面试中被问起。
闭包无处不在,并且掌握起来比较困难。如果你还没有轻松掌握闭包,那这篇文章就是为你准备的。
我将从一些函数式的术语说起:作用域(scope)和词法作用域(lexical scope)。先掌握一些基础,然后你就能一步一步理解闭包。
在文章末,还有一个彩蛋:使用闭包的概念来解释生活中的真实事件。
在开始前,我建议你不要跳过作用域和词法作用域的这一部分,这些概念对理解闭包至关重要,如果你能掌握好这俩概念,那么理解闭包就是顺理成章的事了。
1. 作用域
定义变量时,你希望它在某些范围内可以访问。例如:将变量 result
声明在 calculate()
内部它是有意义的。而在 calculate()
函数外,该变量没什么用。
使用作用域(scope)来管理变量的可访问性。你可以在变量定义的作用域内随意访问。但是在该作用域外你是不能读取它的。
我们来看下变量 count
可访问性的实际效果。通过函数 foo()
创建了一个作用域, count
就属于这个作用域内的变量。
function foo() {
// 函数作用域
let count = 0;
console.log(count); // 打印 0
}
foo();
console.log(count); // ReferenceError: count is not defined
count
在函数 foo()
内部可以随便访问。
然而, foo()
函数外的作用域, count
是不可访问的。如果你尝试在函数作用域外访问,JavaScript 将会抛出一个错误: ReferenceError: count is not defined
。
在 JavaScript 中,作用域这样定义:如果你在函数或者代码块中定义了一个变量,那么你仅能在该函数或者代码块中使用这个变量。上面这个例子就展示了这样一种行为。
现在,让我们来看下常规公式:
作用域是一种空间策略,它控制变量的可访问性。
可以看到一个很明显的特性:作用域隔离了变量。这很棒!因为不同的作用域可以有相同的变量名。
你可以在不同的作用域内,复用常见的变量名( count
、 index
、 current
、 value
等)而不用担心命名冲突。
foo()
和 bar()
函数都有它们各自的作用域,但它们可以拥有相同但变量名 count
:
function foo() {
// foo 函数作用域
let count = 0;
console.log(count); // 打印 0
}
function bar() {
// bar 函数作用域
let count = 1;
console.log(count); // 打印 1
}
foo();
bar();
count
变量来自 foo()
函数和 bar()
函数作用域,但是它们并不冲突。
2. 作用域嵌套
我们再来尝试下作用域,把一个作用域放到另一个作用域内。
函数 innerFunc()
嵌套在函数 outeFunc()
内部。
这 2 个函数作用域互相影响吗?在 innerFunc()
函数中能访问 outerFunc()
函数中的变量 outerVar
吗?
我们来动手试试这个例子:
function outerFunc() {
// 外部作用域
let outerVar = 'I am outside!';
function innerFunc() {
// 内部作用域
console.log(outerVar); // => 打印 "I am outside!"
}
innerFunc();
}
outerFunc();
实际上, outerVar
变量在 innerFunc()
范围内是可访问的。外部作用域的变量可以在内部作用域内部访问。
现在你知道了 2 件有趣的事情:
- 作用域可以嵌套
- 可以在内部作用域访问外部作用域的变量
3. 词法作用域
在 JavaScript 中如何理解:函数 innerFunc()
内部的 outerVar
对应于 outerFunc()
的变量 outerVar
呢?
这是因为 JavaScript 实现了一种作用域机制叫做:词法作用域(或者叫静态作用域)。词法作用域意味着变量的可访问性由嵌套范围内代码中变量的位置决定。
简单来讲,词法作用域意味着在内部作用域内可以访问其外部作用域的变量。
之所以称为词法(或静态),是因为 JavaScript 引擎(在词法化时)仅通过查看 JavaScript 代码而不用执行代码即可确定作用域的嵌套关系。
JavaScript 引擎是如何理解前面的代码片段的:
- 我看到你在
outerFunc()
中定义了一个变量outerVar
。很好! - 我看到你在
outerFunc()
函数内部定义了函数innerFunc()
。 - 在
innerFunc()
内部,我看到了变量outerVar
但是并未声明。由于我使用词法作用域,我认为在innerFunc()
内部的变量outerVar
和outerFunc()
中声明的变量outerVar
是同一个变量。
词法作用域概念提炼:
词法作用域由外部作用域静态组成。
举个例子:
const myGlobal = 0;
function func() {
const myVar = 1;
console.log(myGlobal); // 打印 0
function innerOfFunc() {
const myInnerVar = 2;
console.log(myVar, myGlobal); // 打印 1 0
function innerOfInnerOfFunc() {
console.log(myInnerVar, myVar, myGlobal); // 打印 2 1 0
}
innerOfInnerOfFunc();
}
innerOfFunc();
}
func();
innerOfInnerOfFunc()
的词法作用域是由 innerOfFunc()
、 func()
和全局作用域(最外层作用域)组成。在 innerOfInnerOfFunc()
内部,你可以访问词法作用域变量 myInnerVar
、 myVar
和 myGlobal
。
innerFunc()
的词法作用域是由 func()
和全局作用域组成。在 innerOfFunc()
内部,你可以访问词法作用域变量 myVar
和 myGlobal
。
最后, func()
的词法作用域由全局作用域组成。在 func()
内部,你可以访问词法作用域变量 myGlobal
。
4. 闭包
OK,词法作用域允许访问外部作用域的静态变量。这距离闭包仅一步之遥。
我们再来看一下 outerFunc()
和 innerFunc()
这个例子:
function outerFunc() {
let outerVar = 'I am outside!';
function innerFunc() {
console.log(outerVar); // => 打印 "I am outside!"
}
innerFunc();
}
outerFunc();
在 innerFunc()
内部,变量 outerVar
是能够被词法作用域访问的。这是我们已经知道的。
请注意 innerFunc()
被调用发生在词法作用域内( outerFunc()
的作用域)。
我们来改一下:让 innerFunc()
在词法作用域外部( outerFunc()
外部)被调用。那么 innerFunc()
仍然能访问变量 outerVar
吗?
来调整一下代码片段(8 行、12 行):
function outerFunc() {
let outerVar = 'I am outside!';
function innerFunc() {
console.log(outerVar); // => 打印 "I am outside!"
}
return innerFunc;
}
const myInnerFunc = outerFunc();
myInnerFunc();
现在 innerFunc()
在词法作用域外部被执行。并且重点是:
_innerFunc()_
仍然能从词法作用域访问变量 _outerVar_
,即使它已经在作用于外部调用。
换句话说, innerFunc()
封闭了(也可以称为:“捕获”,记住!)来自词法作用域的变量 outerVar
。
换句话说, innerFunc()
就是一个闭包,因为它从其词法作用域封闭了变量 outerVar
。
你离理解闭包只差最后一步:
闭包 是一个访问其词法范围的函数,即使在其词法范围之外执行。
更简单地说,闭包就是一个函数,它可以从定义它的地方记住变量,而不管它以后在哪里执行。
识别闭包的经验:如果在函数内看到了外部变量(未在函数内定义的),则该函数很可能是闭包,因为它捕获了来自外部的变量。
在前面的代码片段中, outerVar
就是在 innerFunc()
变量中的外部变量,并且它被 innerFunc()
函数作用域捕获。
来继续看几个例子,为什么说闭包非常有用。
5. 闭包的例子
5.1 事件处理函数(Event handler)
我们来展示一下一个按钮被点击了多少次:
let countClicked = 0;
myButton.addEventListener('click', function handleClick() {
countClicked++;
myText.innerText = `You clicked ${countClicked} times`;
});
打开这个例子的 Demo @CodesandBox,点击按钮,这个文字将将展示按钮被点击的次数。
单击该按钮后, handleClick()
将在 DOM 代码内部的某处执行。执行发生在远离它被定义的地方。
但是作为一个闭包, handleClick()
将捕获来自词法作用域的变量 countClicked
并且当按钮被点击的时候进行更新。甚至, myText
也被捕获了。
5.2 回调函数(Callbacks)
在回调函数中捕获词法作用域变量非常有用。
一个 setTimeout()
回调函数:
const message = 'Hello, World!';
setTimeout(function callback() {
console.log(message); // 打印 "Hello, World!"
}, 1000);
callback()
函数是一个闭包,因为它捕获了变量 message
。
forEach()
的迭代器函数(iterator function):
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)
当一个函数返回另一个函数直到完全获取参数时,就会发生函数柯里化(Currying)。
举个例子:
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()
就是一个被柯里化的函数,它返回了另一个函数。
柯里化,一个在函数式编程中非常重要的概念,因为闭包,让它成为可能!
executeMultiply(b)
是一个闭包,因为它捕获了来自词法作用域的 a
,当这个闭包被调用的时候,被捕获的变量 a
和参数 b
被用来计算 a * b
。
6. 真实世界中关于闭包的例子
我知道,闭包可能很难掌握。但是一旦你掌握了它,你就会受益终身。
你可以在大脑中进行模拟,通过下面的方式。
想象一下,你有一支神笔。它可以用来画生活中的任何对象。然后这幅画将成为一个窗口,你可以通过这个窗口与真实世界进行交互。
通过这个窗口,你可以移动任何画好的对象。
此外,你可以用这支神笔在任意地方绘画,即使被画的对象不在这里。通过手中的神笔,你可以移动任何对象。
这幅画就是一个闭包,而被画的对象就是一种词法作用域。
JavaScript 就是这么神奇!😊
7. 总结
作用域是一种确定变量的可访问性的规则,它可以是函数作用域或者块级作用域。
词法作用域允许函数作用域从外部作用域静态访问变量。
最后,闭包是从其词法作用域捕获变量的函数。简单来说,闭包会记住从定义它的地方开始的变量,无论它在哪里执行。
闭包在事件处理函数和回调函数中捕获变量,它们用于函数式编程。此外,它还会在各种前端面试中被问起!😂
毫无疑问,每个 JavaScript 开发人员都必须掌握闭包。