前端面试必会 | 一文读懂 JavaScript 中的闭包

本文翻译自 https://blog.bitsrc.io/a-beginners-guide-to-closures-in-javascript-97d372284dda,作者 Sukhjinder Arora,内容有部分修改,标题有修改。

闭包是每个 JavaScript 程序员都应该知道并且掌握的基础概念。然而,这个概念使很多 JavaScript 新手感到困惑。

对闭包有适当的了解将有助于您编写更好,更有效和干净的代码。反过来,它将帮助您成为更好的 JavaScript 开发人员。

因此,在本文中,我将尝试解释闭包的原理以及它们在 JavaScript 中的实际工作方式。

闭包是什么

下图就是闭包:

前端面试必会 | 一文读懂 JavaScript 中的闭包_第1张图片 闭包

闭包是即使外部函数返回之后也可以访问其外部函数作用域的函数。这意味着闭包即使外层函数执行完成了也可以记住并访问外层函数的变量和参数。要注意,在图示中由于没有使用 age 变量,所以打印出的闭包中没有它。

在深入探讨闭包之前,让我们首先了解词法作用域。

词法作用域是什么?

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'

在此代码中,我们正在调用 person 函数,该函数返回内部函数 displayName 并将该内部函数存储在 peter 变量中。当我们调用 peter 函数(实际上是在引用 displayName 函数)时,名称 Peter 被打印到控制台上。

但是我们没有在 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 函数内部的 counter 变量。

但是请注意,每次 count 执行的时候 counter 的值并不会重置为 0

这是因为,每次调用 count(),将会为该函数创建一个新的作用域,但是这里只给 getCounter 创建了一个作用域,又因为 counter 变量是定义在 getCounter 函数作用域内部的,所以它将在每次 count 调用的时候自增而不是被重置。

闭包是如何工作的

到目前为止,我们已经讨论了什么是闭包及其实际示例。现在,让我们了解闭包在 JavaScript 中如何真正起作用。

要真正了解闭包在 JavaScript 中是如何工作的,我们必须了解 JavaScript 中两个最重要的概念,即 1)执行上下文和 2)词法环境

执行上下文

执行上下文是一个抽象的环境,JavaScript 代码在其中执行。当全局代码执行时,它将在全局执行上下文中执行,而函数代码将在函数执行上下文中执行。

每次只能有一个执行上下文处于运行状态,因为 JavaScript 是单线程语言,它由执行栈或者调用栈来管理。

执行堆栈是具有 LIFO(后进先出)结构的堆栈,只能从堆栈顶部添加或删除项目。

当前正在运行的执行上下文将始终位于堆栈的顶部,并且当当前正在运行的函数完成时,其执行上下文将从堆栈中弹出,并且指针将指向堆栈中位于其下方的执行上下文。

让我们看一下代码片段,以更好地理解执行上下文和堆栈:

前端面试必会 | 一文读懂 JavaScript 中的闭包_第2张图片 执行上下文示例

当执行此代码时,JavaScript 引擎会创建一个全局执行上下文来执行该全局代码,并且当它遇到 first() 函数调用时,它将为该函数创建一个新的执行上下文并将其推入执行堆栈的顶部。

因此,以上代码的执行堆栈如下所示:

前端面试必会 | 一文读懂 JavaScript 中的闭包_第3张图片 执行栈

first() 函数结束,它的执行堆栈被从栈中删除,并且指针达到到其下方的执行上下文,即,全局执行上下文。因此,将执行全局范围内的剩余代码。

词法环境

每当 JavaScript 引擎创建执行上下文来执行该函数或全局代码时,它就会创建一个新的词法环境来存储在该函数执行期间该函数中定义的变量。

词法环境是一个存储标识符和变量映射关系的数据结构。标识符指的是变量或者函数的名字,变量指的是实际的对象,包括函数或者原始值。

词法环境包括三个部分:(1) 环境记录;(2)outer 外部环境的指向;(3)this

  1. 环境记录是存储变量和函数声明的地方;

  2. outer,外部环境的指向表示它可以访问外层(父层)的词法环境。它是理解闭包原理的关键。

词法环境的结构如下:

lexicalEnvironment = {
  EnvironmentRecord: {
     : ,
     : 
  }
  outer: 
  ThisBinding: 
}

现在分析下面的代码。

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 : 
  }
  outer: null
  ThisBinding: 
}

此处将外部词法环境设置为 null 是因为全局作用域没有外部词法环境。

当引擎为 first() 函数创建执行上下文时,它还会创建一个词法环境来存储在函数执行期间在该函数中定义的变量。因此,该函数的词法环境如下所示:

functionLexicalEnvironment = {
  EnvironmentRecord: {
      b    : 25,
  }
  outer: 
  ThisBinding: 
}

函数的外部词法环境被设置为全局词法环境,因为函数在源代码中被全局作用域所包围。

注意,函数完成后,其执行上下文将从堆栈中删除,但是它的词法环境可能会也可能不会从内存中删除,具体取决于该词法环境是否被其它词法环境引用(取决于是否存在闭包)。

详细的闭包示例

现在我们了解了执行上下文和词法环境,让我们回到闭包。

示例1

看下面的代码:

function person() {
  let name = 'Peter';
  
  return function displayName() {
    console.log(name);
  };
}
let peter = person();
peter(); // prints 'Peter'

person 函数执行时,JavaScript 引擎会为该函数创建新的执行上下文和词法环境。该函数执行完毕之后,它会返回 displayName 函数并把它赋值给 peter 变量。

因此其词法环境如下所示:

personLexicalEnvironment = {
  EnvironmentRecord: {
    name : 'Peter',
    displayName: 
  }
  outer: 
  ThisBinding: 
}

由于 displayName 函数中没有变量,因此其环境记录将为空。在执行此函数期间,JavaScript 引擎将尝试在此函数的词法环境中查找变量 name

由于 displayName 函数的词法环境中没有变量,因此它将查看外部词法环境,即 person 函数仍在内存中的词法环境。JavaScript 引擎找到该变量并将 name 打印到控制台。

示例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 函数的词法环境如下所示:

getCounterLexicalEnvironment = {
  EnvironmentRecord: {
    counter: 0,
     : 
  }
  outer: 
}

该函数返回一个匿名函数并将其赋值给 count 变量。

count 执行时,其词法环境将如下所示:

countLexicalEnvironment = {
  EnvironmentRecord: {
  }
  outer: 
}

count 函数执行时时,JavaScript 引擎将在此函数的词法环境中查找 counter。同样,由于其环境记录为空,因此引擎将查找函数的外部词法环境。

引擎找到该变量,将其打印到控制台,并将 getCounter 函数词法环境中的变量加1。

因此,首次调用 count 函数后 getCounter 函数的词法环境如下所示:

getCounterLexicalEnvironment = {
  EnvironmentRecord: {
    counter: 1,
     : 
  }
  outer: 
}

在每次调用 count 函数时,JavaScript 引擎都会为该函数创建一个新的词法环境,递增 counter 变量并更新 getCounter 函数的词法环境以反映更改。

结论

通过上面的解释,相信你已经完全掌握了闭包。闭包是每个 JavaScript 开发人员都应该理解的 JavaScript 基本概念。熟悉这些概念将帮助您成为一个更有效,更好的 JavaScript 开发人员。

最后

往期精彩:

  • 前端面试必会 | 一文读懂 JavaScript 中的作用域和作用域链

  • 前端面试必会 | 一文读懂 JavaScript 中的执行上下文

  • InterpObserver 和懒加载

  • 初探浏览器渲染原理

  • CSS 盒模型、布局和包含块

  • 详细解读 CSS 选择器优先级

关注公众号可以看更多哦。

感谢阅读,欢迎关注我的公众号 云影 sky,带你解读前端技术,掌握最本质的技能。关注公众号可以拉你进讨论群,有任何问题都会回复。

前端面试必会 | 一文读懂 JavaScript 中的闭包_第4张图片 公众号 前端面试必会 | 一文读懂 JavaScript 中的闭包_第5张图片 交流群

你可能感兴趣的:(前端面试必会 | 一文读懂 JavaScript 中的闭包)