学习链接

MDN:闭包

深入浅出JavaScript闭包

JavaScript深入之闭包(👍👍👍)

现代 JavaScript 教程:变量作用域,闭包(👍👍👍)

网道教程:闭包

说说你对闭包的理解?闭包使用场景

鉴定一下网络热门面试题:如何理解闭包的概念?(👍👍👍)

  • 闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的
  • 以及可能的关于 [[Environment]] 属性和词法环境原理的技术细节

闭包

闭包定义

闭包 closure 是 1964 年提出来的一个计算机编程的概念,按照当时论文《表达式的机器执行》的表述,它的定义包含控制和环境两个部分,在 JavaScript 中,以函数能够访问其定义时的环境中变量的方式得以实现。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这就产生了闭包。

在 JavaScript 中,所有函数都是天生闭包的(只有一个例外 new Function,见 补充)。

也就是说:JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的词法作用域,所以它们都可以访问外部变量

本质

在本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁,

也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。

每当创建一个函数,闭包就会在函数创建的同时被创建出来。

两个角度

或许从两个角度来看待闭包,就会少很多分歧和误解:

  1. 理论角度:所有的函数都是闭包。
    因为它们都在创建的时候就将上层上下文的数据保存起来了,都可以访问外部的变量。
  2. 实践角度:以下函数才算是闭包:
    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    2. 在代码中引用了外部函数变量

细节概括

看这里!

在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。

词法环境对象由两部分组成:

  1. 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  2. 外部词法环境的引用,与外部代码相关联。

一个“变量”只是 环境记录 这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。

  • 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
  • 操作变量实际上是操作该对象的属性。

变量声明

当脚本开始运行,词法环境预先填充了所有声明的变量

  • 最初,它们处于“未初始化(Uninitialized)”状态。
  • 这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。(暂时性死区

函数声明

与变量不同,函数声明的初始化会被立即完成。

创建了一个词法环境时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。

所有的函数在“诞生”时都会记住创建它们的词法环境。

所有函数都有名为 **[[Environment]]** 的隐藏属性,该属性保存了对创建该函数的词法环境的引用

这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关[[Environment]] 引用在函数创建时被设置永久保存

函数调用

在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数,并且其外部词法环境引用获取于 函数的 **[[Environment]]** 属性

访问修改变量

当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境

在变量所在的词法环境中更新变量,这也就意味着多次调用同一内部函数,使用的也是同一个外部函数的变量。

细节展开

变量

矩形表示环境记录(变量存储),箭头表示外部引用。

闭包 - 图1

此时的词法环境只有一个,全局词法环境。全局词法环境没有外部引用,所以箭头指向了 null

  1. 当脚本开始运行,词法环境预先填充了所有声明的变量。
    • 最初,它们处于“未初始化(Uninitialized)”状态。
    • 这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。(暂时性死区
  2. 然后 let phrase 定义出现了。它尚未被赋值,值为 undefined。此时就可以使用变量了。
  3. phrase 被赋予了一个值。
  4. phrase 的值被修改。
  • 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
  • 操作变量实际上是操作该对象的属性。

词法环境是一个规范对象

“词法环境”是一个规范对象(specification object):它仅仅是存在于 编程语言规范 中的“理论上”存在的,用于描述事物如何运作的对象。我们无法在代码中获取该对象并直接对其进行操作。

函数声明

一个函数其实也是一个值,就像变量一样。

不同之处在于函数声明的初始化会被立即完成。

当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。

这就是为什么我们可以在(函数声明)的定义之前调用函数声明。

闭包 - 图2

正常来说,这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...

内部和外部的词法环境

在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。

闭包 - 图3

在这个函数调用期间,总共有两个词法环境:内部一个(用于函数调用)和外部一个(全局):

  • 内部词法环境与 say 的当前执行相对应。它具有一个单独的属性:name,函数的参数。
  • 外部词法环境是全局词法环境。它具有 phrase 变量和函数本身。

内部词法环境引用了 outer

当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

  • 对于 name 变量,当 say 中的 alert 试图访问 name 时,会立即在内部词法环境中找到它。
  • 当它试图访问 phrase 时,然而内部没有 phrase,所以它顺着对外部词法环境的引用找到了它。

返回函数

尚未运行内部函数

闭包 - 图4

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。

在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。此时尚未运行它,仅创建了它。

闭包 - 图5

所有的函数在“诞生”时都会记住创建它们的词法环境。

所有函数都有名为 **[[Environment]]** 的隐藏属性,该属性保存了对创建该函数的词法环境的引用

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关[[Environment]] 引用在函数创建时被设置永久保存

开始运行内部函数

调用 **counter()** 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 **counter.[[Environment]]**

闭包 - 图6

counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 **makeCounter()** 的词法环境,并且在哪里找到就在哪里修改

在变量所在的词法环境中更新变量。

执行后:

闭包 - 图7

应用场景

任何闭包的使用场景都离不开这两点:

  • 创建私有变量
  • 延长变量的生命周期

一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的

  1. 可以读取函数内部的变量
  2. 可以使变量的值长期保存在内存中,生命周期比较长。因此不能滥用闭包,否则会造成网页的性能问题。

使用闭包模拟私有方法:数据内部私有,暴露方法或函数供外面使用。

柯里化函数:把一个多参数函数转化成一个嵌套的一元函数,便于重用。

缺点

性能

如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响

内存泄漏

栈内存提供一个执行环境,即作用域,包括全局作用域和私有作用域。

  • 全局作用域:只有当页面关闭的时候全局作用域才会销毁
  • 私有的作用域:只有函数执行才会产生

一般情况下,函数执行会形成一个新的私有的作用域,当私有作用域中的代码执行完成后,我们当前作用域都会主动的进行释放和销毁。

但当遇到函数执行返回了一个引用数据类型的值,并且在函数的外面被一个其他的东西给接收了,这种情况下一般形成的私有作用域都不会销毁。

  1. function fn(){
  2. let num = 100;
  3. return function(){
  4. return num * 2;
  5. }
  6. }
  7. const f = fn(); // fn执行形成的这个私有的作用域就不能再销毁了

也就是像上面这段代码,fn函数内部的私有作用域会被一直占用的,发生了内存泄漏。

所谓内存泄漏,指的是任何对象在我们不再拥有或需要它之后仍然存在

  • 闭包不能滥用,否则会导致内存泄露,影响网页的性能。
  • 闭包使用完后,要立即释放资源,将引用变量指向 **null**

补充

现代 JavaScript 教程:”new Function” 语法

通常,闭包是指使用一个特殊的属性 [[Environment]] 来记录函数自身的创建时的环境的函数。它具体指向了函数创建时的词法环境。

但是如果我们使用 **new Function** 创建一个函数,那么该函数的 **[[Environment]]** 并不指向当前的词法环境,而是指向全局环境

因此,此类函数无法访问外部(outer)变量,只能访问全局变量

  1. function getFunc() {
  2. let value = "test";
  3. let func = new Function('alert(value)');
  4. return func;
  5. }
  6. getFunc()(); // error: value is not defined

可正常访问全局变量

  1. let value = "test";
  2. let func = new Function('alert(value)');
  3. func(); // value

语法:

  1. let func = new Function ([arg1, arg2, ...argN], functionBody);

由于历史原因,参数也可以按逗号分隔符的形式给出。

以下三种声明的含义相同:

  1. new Function('a', 'b', 'return a + b'); // 基础语法
  2. new Function('a,b', 'return a + b'); // 逗号分隔
  3. new Function('a , b', 'return a + b'); // 逗号和空格分隔

new Function 创建的函数,它的 [[Environment]] 指向全局词法环境,而不是函数所在的外部词法环境。

因此,我们不能在 new Function 中直接使用外部变量。不过这样是好事,这有助于降低我们代码出错的可能。并且,从代码架构上讲,显式地使用参数传值是一种更好的方法,并且避免了与使用压缩程序而产生冲突的问题