JavaScript 闭包

ㅤㅤㅤ
ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ(多数人都拥有自己不了解的能力和机会,都有可能做到未曾梦想的事情。——戴尔·卡耐基
ㅤㅤㅤ
ㅤㅤㅤ
ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ在这里插入图片描述

闭包

闭包就是能够读取其他函数内部变量的函数。例如在javascript中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁 - 百度百科

闭包的特性

  • 函数嵌套具有相互引用关系的函数
  • 函数外部可以引用函数内部的参数和变量
  • 被引用的参数和变量无法被垃圾回收

闭包的优缺点

  • 优点
  1. 将变量永久保存在内存中
  2. 避免全局变量的污染
  3. 在早期的es版本中用来操作私有成员
  • 缺点
  1. 常驻内存,大量使用会消耗内存
  2. 使用不当会造成内存泄露

但如果要真正的理解闭包,我们还需要理解JavaScript的

  • 执行上下文(预编译)
  • 作用域
  • 作用域链
  • 词法作用域

ECMA官方解释

JavaScript执行上下文

预编译又称为预处理,是做些代码文本的替换工作。是整个编译过程的最先做的工作

  • 为什么需要预编译?
  • “执行上下文”的概念提供了一种推理方法,用于推理在创建和执行全局环境或调用函数时发生的情况。它是局部变量(在全局环境下为全局变量),参数(对于函数)等的概念性容器。可以编写JavaScript引擎,而实际上不包含任何称为“执行上下文”的对象。它实现了与规范定义的执行上下文行为一致的语言
  • 通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。与传统的编译语言不同,JavaScript的编译过程不是发生在构建之前,它的大部分编译发生在代码执行前的几微秒,一般把这个过程称为预编译。

函数执行前的预编译过程
一般分为以下四步:

  • 创建活动对象(Active object,下面简称AO)
  • 查找函数形参和函数内变量声明,作为AO的属性名,不赋值
  • 形参与实参值统一
  • 查找函数声明,函数名作为AO的属性名,值为函数引用
function foo(a){
  var b = 2;
  function bar(){}
  console.log(a + b);
}
foo(1)

以上面代码片段为例:

  1. 当代码执行到 foo(1) 的时候,会为函数foo创建一个AO
AO{}
  1. 将形参和变量声明放入AO
AO{
 a: undefined,
 b: undefined
}
  1. 形参实参值统一
AO{
 a: 1,
 b: undefined
}
  1. 将函数声明放到AO并赋值
AO{
 a: 1,
 b: undefined,
 bar: function bar(){}
}

但这其中还设计到this指向和arguments对象等,在此就先不进行介绍

  • 全局代码块执行前的预编译

一般分为以下三步:

  • 创建全局活动对象(Global object,以下简称GO)
  • 查找变量声明,作为GO的属性名,不赋值
  • 查找函数声明,函数名作为GO的属性名,值为函数引用
var a = 1;
function foo(b){
  console.log(b)
}
foo(2)

以上面代码片段为例:

  1. 首先创建一个全局活动对象GO
GO{}
  1. 将变量声明放入GO
GO {
 a: undefined
}

3.将函数声明放入GO并赋值

GO{
 a: undefined,
 foo: function foo(){...}
}

我们可以看到和函数的预编译过程几乎一样,只是少了函数参数参与的环节。

其实和函数预编译还有点不一样的地方就是第一步创建GO。实际预编译过程中不会创建GO,而是直接使用全局对象(如果在浏览器中就是window对象)。

补充两点:

  1. 函数声明和函数表达式
//函数声明
function foo{}
//函数表达式
var foo = function(){}

在预编译过程中,函数表达式会被视为变量声明,即会在AO/GO中放入一个foo属性,但是不会为其赋值。
果正
2. 暗示全局变量

JavaScript在非严格模式下可以不加var直接声明变量,比如:


a = 1;
function foo(){
 b = 2;
}
foo();

这段代码运行完之后会在全局作用域创建两个变量a和b

不推荐使用,并且严格模式下会报错

  • Demo:
    全局作用域:
console.log(a);//undefined
console.log(foo);//function foo(){}
var a = 1;
function foo(){}
函数作用域:

function foo(a){
  console.log(a);//1
  console.log(b);//undefined
  console.log(bar);//function bar(){}
  console.log(baz);//undefined

  var b = 1;
  function bar(){}
  var baz = function(){}
}
foo(1);
  • 总结

所谓预编译,就是为代码接下来的执行创建一个执行上下文,这样代存储和访问变量,是任何一种编程语言最基本的功能之一,变量存在哪里?程序需要时如何找到它?这些问题需要一套良好的规则来规范,这套规则,就成为作用域码才能顺利执行下去。这个执行上下文又称为作用域,负责变量(标识符)的读写

JavaScript作用域

作用域(scope)是标识符(变量)在程序中的可见性范围。作用域规则是按照具体规则维护标识符的可见性,以确定当前执行的代码对这些标识符的访问权限。作用域(scope)是在具体的作用域规则之下确定的

在 JavaScript 中有两种作用域类型:

  • 局部作用域
  • 全局作用域

JavaScript 拥有函数作用域:每个函数创建一个新的作用域。

  • 作用域决定了这些变量的可访问性(可见性)。
  • 函数内部定义的变量从函数外部是不可访问的(不可见的)

我们由一段示例代码开始:

1  var global = 1;
2  function foo(a){
3   var b = 2;
4   console.log(a + b);
5  }
6  foo(1);

按照我们之前了解的内容,上面这段代码会生成两个作用域。首先在全部代码执行之前会生成全局作用域:

GO{
 global: undefined,
 foo: function foo(){...}
}

在生成全局作用域之后,js引擎从上至下一行行的执行代码。执行到第一行, 需要为变量global赋值,引擎会到GO中找到global变量,然后为其赋值, 这时的GO:

GO{
 global: 1,
 foo: function foo(){...}
}

然后代码继续执行,第2-5行是个函数声明,所以直接跳到第6行,这一行是一个对foo函数调用,引擎会在GO中查找到foo,然后对其进行函数执行。在执行前,会进行预编译生成foo函数的作用域AO:

AO{
 a: 1,
 b: undefined
}

然后执行函数内的第一行(第3行),从AO中找到b并为其赋值:

AO{
 a: 1,
 b: 2
}

继续执行 console.log(a + b), 这里需要访问变量a和b的值,引擎会到函数作用域中查找到a和b的值,分别是1和2,最终输出结果: 3 。

通过上面的介绍,我们简单了解了作用域存取变量的过程。那么思考一下,如果foo函数的最后一行改为console.log(a + b + global), global的值会到哪里去获取?

我们将刚才的代码执行一下:

在这里插入图片描述

执行结果为4, 说明global的值为1。

在上面的代码中,我们一共就有两个作用域AO和GO,AO中存储着a和b的值分别是1和2,GO中存储着global的值为1。最后的执行结果表明global的值是从GO中获取的。

在代码执行的过程中,通常需要同时顾及几个作用域。当一个块或者函数嵌套在另一个块或者函数中时,就发生了作用域嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套作用域中继续查找,直到找到该变量,或抵达最外层作用域(也就是全局作用域)为止。

这样逐级嵌套的作用域就是作用域链

接下来,我们来看一个示例:

var a = 1;
function foo(){
  var b = 2;
  function bar(){
   var c = 3;
   console.log(c + b + a);
  }
  bar();
}
foo();

上面代码片段在执行的过程中一共会生成三个作用域:

全部代码执行之前:

GO{
 a: undefined,
 foo: function foo{...}
}

执行foo函数的前一刻

GO{
 a: 1,
 foo: function foo{...}
}
AO1{
 b: undefined,
 bar: function bar(){...}
}

然后执行foo函数:

  • 执行var b =2, 会为foo函数作用域的变量b赋值,
  • 然后执行到bar(),从foo函数的作用域获得bar函数的引用,开始执行bar函数

在执行bar函数之前,需要为bar函数创建作用域:

GO{
 a: 1,
 foo: function foo{...}
}
AO1{
 b: 2,
 bar: function bar(){...}
}
AO2{
 c: undefined
}

接着执行bar函数,执行完第一行,为bar函数作用域里的变量c赋值

GO{
 a: 1,
 foo: function foo{...}
}
AO1{
 b: 2,
 bar: function bar(){...}
}
AO2{
 c: 3
}

接下来开始执行 console.log(c + b + a)

  • 引擎开始获取变量c的值,从bar函数的作用域找到了变量c的值为3
  • 然后开始查询变量b的值,引擎从bar函数自己的作用域没有找到,所以尝试到foo函数的作用域去寻找,获取了变量b的值为2
  • 接下来寻找变量a的值,引擎从bar函数自己的作用域没有找到,所以尝试到foo函数的作用域去寻找,也没有找到,于是到全局作用域去寻找,获取了变量a的值为1
  • 最后输出结果: 6

在这个例子中有三个逐级嵌套的作用域,我们可以将其想象成几个逐级包含的作用域气泡:

在这里插入图片描述

  • 最外层气泡包含着整个全局作用域,其中有两个标识符:foo和a
  • 中间橙色气泡包含着foo函数的作用域,其中有两个标识符:b和bar
  • 最里面红色气泡包含着bar函数的作用域,其中只有一个标识符:c

作用域气泡由其对应的作用域代码块写在哪里决定的,它们是逐级包含的。而访问变量的过程就是逐级查询嵌套作用域的过程

作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,不能反向查找。

作用域查找会在找到第一个匹配的标识符(变量)停止。在多次嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符遮蔽了外部的同名标识符)。

//遮蔽效应
var a = 1;
function foo(){
 var a = 2;
 console.log(a);
}
foo(); //2

JavaScript词法作用域

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将 变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情 况下是这样的)

JavaScript查找是按照代码书写时候的位置来决定的,而不是按照调用时候位置

我们再看一下上一节示例:

var a = 1;
function foo(){
  var b = 2;
  function bar(){
   var c = 3;
   console.log(c + b + a);
  }
  bar();
}
foo();

作用域链:

在这里插入图片描述

bar的作用域完全被foo的作用域包裹,唯一的原因是我们写代码的时候将它定义在foo函数的内部。

我们来把bar函数换个位置来试一下:

var a = 1;
function bar(){
 console.log(a);
}
function foo(){
 var a = 2;
 bar();
}
foo();

你觉得这个代码片段的运行结果是1还是2?

bar函数是在foo函数内部被调用的,如果bar的作用域在foo的内部,按照作用域链查找变量的原则,bar自己的作用域中不存在变量a,所以它会到它的上层作用域去查找。

如果它的上层作用域是foo的作用域,foo作用域里面存在变量a,则会停止查找直接返回a的值,为2。

然而我们可以实际运行一下这段代码,结果为1。

说明bar函数不是从foo函数的作用域查找的变量a,而是从全局作用域中获取的a的值。

这说明什么?

说明bar函数的外层作用域是全局作用域,而不是foo函数的作用域。

因为bar函数定义在全局作用域中而不是foo函数中,而JavaScript中的作用域是词法作用域

上面代码片段的作用域气泡图:

在这里插入图片描述

最外层为全局作用域,而foo和bar的作用域之间没有嵌套关系,所以在执行bar的时候,在bar()的作用域链中是不存在foo()的作用域的,自然也就不会到foo的作用域中查找变量。

  • 总结:

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定,这就是JavaScript的词法作用域
除非你使用with或者eval欺骗它

JavaScript闭包

闭包,指的是词法表示包括不被计算的变量的函数,也就是说,函数可以使用函数之外定义的变量

只要你真的理解了词法作用域和作用域链,闭包对你来说是显而易见的事情。因为闭包是基于词法作用域书写代码时所产生的自然结果

「函数」和「函数内部能访问到的变量」(也叫环境)的总和,就是一个闭包

闭包的创建和使用在你的代码中随处可见,你缺少的只是根据你的意愿来准确的识别它。

先来看一段代码:

var a = 1;
function foo(){
  var a = 2;
  function bar(){
    console.log(a);
  }
  bar();
}
foo();//2

按照我们之前了解的词法作用域的规则,上面代码片段的输出结果为2。

我们稍微改变一下上面这段代码:

var a = 1;
function foo(){
  var a = 2;
  function bar(){
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz();

函数bar()的词法作用域能够访问foo()的内部作用域。现在我们将bar作为foo()执行的返回值赋值给baz,然后再执行baz()。

实际上只是通过不同的标识符引用调用了内部的函数bar(), bar显然可以正常执行,但是它是在自己定义的词法作用域以外的地方执行的。

在foo执行后,通常会期待foo()的整个内部作用域被销毁,因为我们知道引擎有个垃圾回收机制用来释放不再使用的内存空间。由于看上去foo的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的神奇在于它可以阻止这件事情发生。事实上内部作用域仍然存在,并没有被回收。谁在使用这个内部作用域?是bar()本身在使用。

bar()声明位置决定了它拥有foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

bar()依然持有对该作用域的引用,而这个引用就叫做闭包

因此,在baz被实际调用(调用内部函数bar)的时候,它依然可以访问定义时的词法作用域,可以正常的访问foo()作用域内部的变量a。

所以最终输出的结果foo作用域的a,而不是全局作用域的a,即使baz()函数是在全局作用域被执行的。

无论以何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

function foo(){
  var a = 2;
  function baz(){
    console.log(a);
  }
  bar(baz);
}
function bar(fn){
  var a = 5;
  fn();
}
foo();//2

把内部函数baz传递给bar,调用内部函数时(现在叫作fn),它涵盖的foo()内部作用域的闭包就可以观察到了,因此它能够访问a。

  • 回调函数和闭包

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

看下面这段代码:

function foo(a){
 setTimeout(function bar(){
   console.log(a)
 }, 1000);
}
foo(1);

将一个内部函数(bar)传递给setTimeout()。bar具有涵盖foo()作用域的闭包,因此还保有对变量a的引用。 foo()执行1000毫秒后,它的内部作用域并不会消失,bar函数依然保有foo()作用域的闭包。

本质上无论何时何地,如果将函数到处当做参数传递执行,你就会看到闭包在这些函数中的应用。

在定时器,事件监听器,Ajax请求等代码中,只要使用了回调函数,实际上就是在使用闭包。

  • 循环和闭包

下面我们来看一个闭包必讲的经典问题:

for(var i =1; i<=5; i++) {
 setTimeout(function timer(){
   console.log(i);
 }, i*1000)
}// 6,6,6,6,6

我们预期这段代码输出1-5,每秒输出一次。但是实际上这段代码在运行时会输出5次6;

为什么呢?

首先6是哪来的?我们看到这个循环终止的条件是 i >= 5,所以首次条件不成立时i的值是6。因此,输出的结果全是循环结束后i的最终值。

仔细想一想,其实又是合理的。延迟函数会在循环结束后才执行(即使将时间设成0)。因此所有回调函数都会在循环结束后才被执行,所以输出的全是6。

虽然这5个函数是在5次的迭代中分别定义,但是它们都定义在同一个(全局)作用域中,因此它们输出的都是同一个i。

怎么解决这个问题?我们可以给循环过程中的每个函数创建一个单独的闭包作用域,这样,它们就会到各自的闭包作用域中去查找变量。这里我们利用IIFE(立即执行函数)来创建闭包作用域:

for(var i =1; i<=5; i++) {
 (function(){
   var j = i;
   setTimeout(function timer(){
     console.log(j);
   }, i*1000)
  }());
}//1,2,3,4,5

现在它可以正常工作了。

现在每一次循环都有一个IIFE( 立即调用函数表达式)被执行,而IIFE的作用域里都有一个变量j,它的值为当前循环发生时的i。

而且IIFE的作用域在执行后不会被销毁,因为它依然被setTimeout的回调函数持有着。

当全部循环结束后,5个函数依次被执行,当每个函数尝试去查找j的时候,它会在自己词法作用域所涵盖的IIFE的作用域中查找到j的值。所以最终输出结果为1,2,3,4,5。

这个地方关键是要理解, 每一次迭代都会有一个IIFE被执行,IIFE会为每次迭代都生成一个新的作用域, 而延迟函数的回调函数可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量j供我们访问。

改进上面代码:

for(var i =1; i<=5; i++) {
 (function(i){
   setTimeout(function timer(){
     console.log(i);
   }, i*1000)
  }(i));
}//1,2,3,4,5

当然,使用let自带的块级作用域可以更优雅的解决这个问题:

for(var i =1; i<=5; i++) {
 let j = i;
 setTimeout(function timer(){
   console.log(j);
 }, i*1000)
}// 1,2,3,4,5

还可以利用for循环中let的特殊行为改进上面代码:

for(let i =1; i<=5; i++) {
 setTimeout(function timer(){
   console.log(i);
 }, i*1000)
}// 1,2,3,4,5

这不在我们讨论范围之内,就不详细说明了。

模块化
在ES6为模块增加语法支持之前,闭包常被用于实现模块化,封装私有属性和方法,暴露公共接口。

比如下面这段代码:

function module(){
  var privateValue = 1;
   function showPrivate(){
     console.log(privateValue);
   }
  return { showPrivate: showPrivate };
};
var foo = modules();
foo.showPrivate(); //1- 

这个模式在JavaScript中被称为模块,其实就是应用了JavaScript闭包的特性来实现的。

首先,module()只是一个函数,必须要调用它来创建一个模块实例。如果不执行外部函数,内部作用域和闭包都无法被创建。

其次,module()返回了一个对象字面量,这个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐藏且私有状态。可以将这个对象返回值看作模块的公共API。

这个对象返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API的属性方法,比如foo.showPrivate()。

showPrivate()函数具有涵盖模块实例内部作用域的闭包(通过调用module()实现)。

当通过返回一个对象的方式将函数传递到词法作用域外部时,就已经创造了闭包。

模块必要的两个条件:

必须有外部的封闭函数,该函数至少被调用一次,每次调用都会创建一个新的模块实例
封闭函数必须返回至少一个内部函数,这样才能形成闭包,访问或修改私有状态。
我们可以利用IIFE来实现单例模式(模块函数module只会执行一次):

var foo = (function module(){
  var privateValue = 1;
   function showPrivate(){
     console.log(privateValue);
   }
  return { showPrivate: showPrivate };
}());

foo.showPrivate(); //1
  • 总结:

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

一般来说,一个函数在执行开始的时候,会给其中定义的变量划分内存空间保存,以备后面的语句所用,等到函数执行完毕返回了,这些变量就被认为是无用的了.对应的内存空间也就被回收了.下次再执行此函数的时候,所有的变量又回到最初的状态,重新赋值使用.

但是如果这个函数内部又嵌套了另一个函数,而这个函数是有可能在外部被调用到的.并且这个内部函数又使用了外部函数的某些变量的话.这种内存回收机制就会出现问题.如果在外部函数返回后,又直接调用了内部函数,那么内部函数就无法读取到他所需要的外部函数中变量的值了.所以js解释器在遇到函数定义的时候,会自动把函数和他可能使用的变量(包括本地变量和父级和祖先级函数的变量(自由变量))一起保存起来.也就是构建一个闭包,这些变量将不会被内存回收器所回收,只有当内部的函数不可能被调用以后(例如被删除了,或者没有了指针),才会销毁这个闭包,而没有任何一个闭包引用的变量才会被下一次内存回收启动时所回收.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值