闭包是 JavaScript 中一个基本的概念, 每个 JavaScript 开发者都应该知道和理解的. 然而, 很多新手 JavaScript 开发者对这个概念还是很困惑的.
正确理解闭包可以帮助你写出更好, 更高效, 简洁的代码. 同时, 这将会帮助你成为更好的 JavaScript 开发者.
因此, 在这篇文章中, 我将会尝试解析闭包内部原理以及它在 JavaScript 中是如何工作的.
好, 废话少说, 让我们开始吧.
什么是闭包
用一句话来说就是, 闭包是一个可以访问它外部函数作用域的一个函数, 即使这个外部函数已经返回了. 这意味着即使在函数执行完之后, 闭包也可以记住及访问其外部函数的变量和参数.
在我们深入学习闭包之前, 首先, 我们先理解下词法作用域(lexical scope).
什么是词法作用域
JavaScript 中的词法作用域 (或者静态作用域) 是指在源代码物理位置中变量, 函数以及对象的可访问性. 举个例子:
- let a = 'global';
- function outer() {
- let b = 'outer';
- function inner() {
- let c = 'inner'
- console.log(c); // prints 'inner'
- console.log(b); // prints 'outer'
- console.log(a); // prints 'global'
- }
- console.log(a); // prints 'global'
- console.log(b); // prints 'outer'
- inner();
- }
- outer();
- console.log(a); // prints 'global'
这里的 inner 函数可以访问自己作用域下定义的变量和 outer 函数的作用域以及全局作用域. 而 outer 函数可以访问自己作用域下定义的变量已经全局作用域. 所以, 上面代码的一个作用域链是这样的:
- Global {
- outer {
- inner
- }
- }
注意到, inner 函数被 outer 函数的词法作用域所包围, 而 outer 函数又被全局作用域所包围. 这就是 inner 函数可以访问 outer 函数以及全局作用域定义的变量的原因.
闭包的实际例子
在深入闭包是如何工作之前, 我们先来看下闭包一些实际的例子.
- // 例子 1
- function person() {
- let name = 'Peter';
- return function displayName() {
- console.log(name);
- };
- }
- let peter = person();
- peter(); // prints 'Peter'
在这段代码中, 我们调用了返回内部函数 displayName 的 person 函数, 并将该函数存储在 perter 变量中. 当我们调用 perter 函数时(实际上是引用 displayName 函数), 名字 "Perter" 会打印到控制台. 但是在 displayName 函数中并没有定义任何名为 name 到变量, 所以即使该函数返回了, 该函数也可以用某种方式访问其外部函数 person 的变量. 所以 displayName 函数实际上是一个闭包.
- // 例子 2
- function getCounter() {
- let counter = 0;
- return function() {
- return counter++;
- }
- }
- let count = getCounter();
- console.log(count()); // 0
- console.log(count()); // 1
- console.log(count()); // 2
同样地, 我们通过调用 getCounter 函数返回一个匿名内部函数, 并且保存到 count 变量中. 由于 count 函数现在是一个闭包, 可以在即使在 getCounter 函数返回后访问 getCounter 函数的变量 couneter. 但是请注意, counter 的值在每次 count 函数调用时都不会像通常那样重置为 0. 这是因为, 在每次调用 count()的时候, 都会创建新的函数作用域, 但是只为 getCounter 函数创建一个作用域, 因为变量 counter 定义在 getCounter 函数作用域内, 所以每次调用 count 函数时数值会增加而不是重置为 0.
闭包工作原理
到目前为止, 我们已经讨论了什么是闭包以及一些实际的例子. 下面我们来了解下闭包在 JavaScript 中的工作原理. 要真正理解闭包在 JavaScript 中的工作原理, 首先, 我们必须要理解 JavaScript 中的两个重要的概念: 1)执行上下文 2)词法环境.
执行上下文(Execution Context)
执行上下文是一个抽象的环境, 其中的 JavaScript 代码会被计算求值和执行. 当全局代码执行时, 它在全局执行上下文中执行, 函数代码在函数执行上下文中执行.
当前只能有一个正在运行执行环境(因为 JavaScript 是单线程语言), 它由被称为执行堆栈或调用堆栈的堆栈数据结构管理.
执行堆栈是一个具有 LIFO(后进先出)结构的堆栈, 其中只能在堆栈顶部进行添加或删除选项.
当前正在运行的执行上下文始终位于堆栈的顶部, 当正在执行的函数执行完成后, 其执行上下文将从堆栈中弹出移除, 然后控制到达堆栈中它下面的执行上下文.
下面我们看一个代码片段更好地理解执行上下文和堆栈.
当以上代码执行时, JavaScript 引擎会创建一个全局执行上下文来执行全局代码, 然后当执行到调用 first()函数时, 它会为该函数创建一个新的执行上下文并且将其推送到执行堆栈的顶部. 所以, 上面代码的执行堆栈就如下图那样:
当 first()函数执行完后, 它的执行堆栈就会从堆栈中移除. 然后, 控制到达下一个执行上下文, 就是全局执行上下文了. 因此, 将会执行全局作用域下剩余的代码.
词法环境(Lexical Envirionment)
每次 JavaScript 引擎创建一个执行上下文执行函数或者全局代码时, 它还会创建一个新的词法环境来存储在该函数执行期间在该函数中定义的变量.
词法环境是一个包含标识符 (identifier)- 变量(variable) 映射的数据结构.(这里所说的标识符 (identifier) 指的是变量或者函数的名称, 而变量 (variable) 是实际对象 [包括函数类型对象] 或原始值的引用).
一个词法环境有两个组件:(1)环境数据 (2)对外部环境的引用.
1, 环境数据是指变量和函数声明实际存放的地方.
2, 对外部环境的引用意思是说它可以访问外部 (父级) 的词法环境. 这个组件很重要, 是理解闭包工作原理的关键.
一个词法环境从概念上看起来像这样:
- lexicalEnvironment = {
- environmentRecord: {
- <identifier> : <value>,
- <identifier> : <value>
- }
- outer: <Reference to the parent lexical environment> // 父级词法环境引用
- }
现在我们来重新看下之前上面的代码片段:
- let a = 'Hello World!';
- function first() {
- let b = 25;
- console.log('Inside first function');
- }
- first();
- console.log('Inside global execution context');
当 JavaScript 引擎创建一个全局执行上下文来执行全局代码时, 它还创建了一个新的词法环境来存储在全局作用域定义的变量和函数. 因此, 全局作用域的词法环境将如下所示:
- globalLexicalEnvironment = {
- environmentRecord: {
- a : 'Hello World!',
- first : <reference to function object>
- }
- outer: null
- }
这里的外部词法环境设置为 null, 因为全局作用域没有外部词法环境. 当引擎为 first()函数创建执行上下文时, 它还会创建一个词法环境来存储在执行函数期间在该函数中定义的变量. 所以函数的词汇环境看起来像这样:
- functionLexicalEnvironment = {
- environmentRecord: {
- b : 25,
- }
- outer: <globalLexicalEnvironment>
- }
函数的外部词法环境设置为全局词法环境, 因为该函数被源代码中的全局作用域所包围.
详细的闭包示例
现在我们理解了执行上下文和词法环境了, 下面我们回到闭包.
例子一
我们先看下这个代码块
- function person() {
- let name = 'Peter';
- return function displayName() {
- console.log(name);
- };
- }
- let peter = person();
- peter(); // prints 'Peter'
当 person 函数执行, JavaScript 引擎会给这个函数创建一个新的执行上下文和词法环境. 当该函数执行完成后, 将返回 displayName 函数并且分配给到 perter 变量. 所以它的词法环境看起来像这样:
- personLexicalEnvironment = {
- environmentRecord: {
- name : 'Peter',
- displayName: <displayName function reference>
- }
- outer: <globalLexicalEnvironment>
- }
当 person 函数执行完成后, 它的执行上下文就会从堆栈里移除. 但它的词法环境仍然在内存里, 是因为它的词法环境被它内部的 displayName 函数的词法环境引用. 所以变量在内存中仍然可用.
当 peter 函数执行(其实是引用 displayName 函数),JavaScript 引擎会为该函数创建新的执行上下文和词法环境. 所以它的词法环境看起来像这样:
- displayNameLexicalEnvironment = {
- environmentRecord: {
- }
- outer: <personLexicalEnvironment>
- }
因为 displayName 函数没有声明变量, 所以它的环境数据是空的. 该函数在执行期间, JavaScript 引擎将尝试在该函数的词法环境中寻找变量 name. 因为 displayName 函数的词法环境没有任何变量, 所以引擎会到外层的词法环境寻找, 这就是还在内存中的 person 函数的词法环境. JavaScript 引擎找到了这个变量 name 然后打印到控制台.
例子二
- function getCounter() {
- let counter = 0;
- return function() {
- return counter++;
- }
- }
- let count = getCounter();
- console.log(count()); // 0
- console.log(count()); // 1
- console.log(count()); // 2
同样地, getCounter 函数的词法环境是这样的:
- getCounterLexicalEnvironment = {
- environmentRecord: {
- counter: 0,
- <anonymous function> : <reference to function>
- }
- outer: <globalLexicalEnvironment>
- }
这个函数返回一个匿名函数并且把它分配到变量 count. 当这个 count 函数执行, 它的词法环境看起来是这样的:
- countLexicalEnvironment = {
- environmentRecord: {
- }
- outer: <getCountLexicalEnvironment>
- }
当 count 函数被调用, JavaScript 引擎会尝试在该函数词法环境查找变量 counter. 同样地, 因为它的环境数据是空的, 所以引擎将到该函数外层词法环境查找. 因此, 在第一次调用 count 函数之后 getCounter 函数的词法环境是这样的:
- getCounterLexicalEnvironment = {
- environmentRecord: {
- counter: 1,
- <anonymous function> : <reference to function>
- }
- outer: <globalLexicalEnvironment>
- }
在每次调用 count 函数, JavaScript 引擎都会为 count 函数创建一个新的词法环境, 递增 count 变量并且更新 getCounter 函数的词法环境以表示做了变更.
结语
所以我们学习了什么是闭包和闭包的原理. 闭包是 JavaScript 的基本概念, 每个 JavaScript 开发者都应该理解的. 熟悉这些概念将有助于你成为一个更高效, 更好的 JavaScript 开发者. 如果你觉得这文章对你有帮助, 请点个赞! (完)
后记
以上译文仅用于学习交流, 水平有限, 难免有错误之处, 敬请指正.
原文
来源: https://juejin.im/post/5bfe076651882516da0e2300