作用域与闭包的一二事

闭包是JavaScript语言的一大特点,可以说再开发的过程中,它无处不在,即使有时候我们都没有发现它的存在。接触JavaScript以来,虽然说是一直有对闭包了解,但是实际应用起来或者分析一些代码的时候却有些吃力。现在回过头总结,才又进一步的揭开了闭包的一层面纱。而在讲闭包之前,我们要先明白JavaScript中又一个重要的知识,那就是JavaScript的内存机制以及作用域,这两个是我们有效理解闭包原理的前提吧。前面我们已经讲了JavaScript的内存机制,那么在这里先理解什么是作用域和作用域链。

作用域与作用域链

之前我们已经了解了JavaScript内存以及垃圾回收的机制了,在讲作用域之前,其实还有一个知识点,那就是JavaScript的执行环境产生的变量对象。

变量对象

执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。而每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但是解析器在处理数据时会在后台处理它们。

JavaScript的运行环境大概有三种:

  1. 全局环境:全局环境是最外围的一个执行环境。在web浏览器中,全局执行环境被认为是window对象,因此所有的全局变量和函数都会作为window对象的属性和方法创建的;
  2. 局部环境:通常是函数内部创建的执行环境。
  3. eval

变量对象的创建过程:

  1. 创建arguments对象。检查当前执行环境中的参数,建立该对象下的属性与属性值。
  2. 检查当前执行环境中的函数声明,即function关键字的函数声明,在变量对象中以函数名建立一个属性,其值是指向该函数所在内存地址的引用。若函数名已经存在,则改属性将会被新的引用说覆盖。
  3. 检查当前执行环境中的变量声明,在变量对象以变量名建立一个属性,并初始化为undefined。
变量对象

我们经常说到的变量提升,函数优先提升就是这样一个过程,在每个执行环境中的变量对象被创建的时候都会经历这几个步骤。
而当某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的变量和函数定义也随之销毁,全局执行环境直到应用程序退出比如浏览器关闭时才会销毁。

作用域

了解了变量对象之后,我们在这里给作用域一个定义,作用域就是用来管理JavaScript引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称(这里指的是变量名和函数名)进行变量查找的一套规则。说的简单点就是作用域就是变量与函数的可访问范围,它控制着变量与函数的可见性与生命周期。

JavaScript代码的整个执行过程分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器执行完成,将代码编译成可执行代码,并确立作用域的规则。执行阶段由引擎完成,主要是执行可执行代码,执行上下文在这个阶段创建。

执行过程

我们举一个简单粗暴的🌰解释一下引擎、编译器和作用域是如何协同工作的就明白了:

1
var a = 10;

JavaScript执行上面代码的时候会将其看成是两个声明,一个是编译阶段的定义声明,一个是引擎执行阶段的赋值声明,所以我们可以分解成:

1
2
var a;
a=10;
  1. 编译阶段:“编译器"会询问"作用域”:当前的作用域中是否已经有变量a,如果有,那么"编译器"会忽略这个声明,继续进行编译;如果没有,那么它会要求“作用域”在当前的作用域声明一个新的变量,并命名为a;
  2. 执行阶段:“引擎"处理a = 10时,首先会询问"作用域”:当前的作用域中是否存在一个a的变量,如果存在,那么引擎就会使用这个变量,如果否,那么"引擎"会继续查找该变量。如果"引擎"最终找到了a变量,那么就将10赋值给它,否则"引擎"就会抛出一个异常。
  3. 总结一下变量赋值操作过程,即:首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时,引擎会在作用域中查找该变量,如果能够找到就对它赋值,否则就抛出异常。

编译阶段是在当前的作用域中声明变量,而引擎查找时,是在整个作用域中查找该变量。

作用域链

上面我们讲了,引擎在查找变量的时候,是在整个作用域中查找的,在当前作用域中找不到它会继续往上一层作用域中查找,直至到顶层作用域即全局作用域。而这一个过程中就产生了我们所说的作用域链,作用域链是在执行上下文创建的时候产生的。

执行过程

我们说作用域是JavaScript编译执行的一套规则,那么作用域链就是这个规则的一种具体体现。

作用域链是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端始终是当前执行的代码所在环境的变量对象。而前面我们已经讲了变量对象的创建过程。作用域链的下一个变量对象来自包含环境即外部环境,这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

闭包

定义

理解了作用域与作用域链之后,我们再来看看闭包的一些定义:

MDN 对闭包的定义:

闭包是指那些能够访问独立(自由)变量的函数(变量在本地使用,但定义在一个封闭的作用域中)。换句话说,这些函数可以「记忆」它被创建时候的环境。

《JavaScript 权威指南(第6版)》对闭包的定义:

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为闭包。

《JavaScript 高级程序设计(第3版)》对闭包的定义:

闭包是指有权访问另一个函数作用域中的变量的函数。

最后是阮一峰老师对闭包的解释:

由于在 Javascript 语言中,只有函数内部的子函数才能读取局部变量,因此可以把闭包简单理解成定义在一个函数内部的函数。

从以上定义我们可以总结,我们可以简单理解就是一个函数被一个外部函数所包含,根据上面我们讲到的作用域链只是我们可以知道,当前被包含的函数内部(即当前的执行环境)是有权访问它外部函数(外部的执行环境)的变量对象的,那么这个函数就是我们所说的闭包了。原来对闭包的理解就是这么的通畅,哈哈😆。

闭包的用途

我们都知道,当函数执行完毕的时候,局部的活动对象就会退出执行栈,随后就会被销毁,但是闭包则不是酱紫的。废话不多说,我们再来看一个🌰(图画的有点丑,将就着看吧哈哈)

1
2
3
4
5
6
7
8
9
10
function fun(){
var name = "Junga"
function fun1(){
var greet = 'Hello '+ name;
console.log(greet);
}
return fun1;
}
var result = fun();
result();
执行过程

上图展示的是调用函数fun()的过程中产生的作用域链之间的关系。作用域链本质上就是一个指向变量对象的指针列表,它只引用但不实际包含变量对象。上面代码很明显,fun1()就是一个闭包,因为它的作用域链包含了外部函数fun()的活动对象,那为什么闭包在运行完之后不会被销毁呢?下面解释一下:

在函数fun()中,返回了内部函数fun1(),它的作用域链被初始化为包含fun()函数的活动对象和全局变量对象。因此,fun1()就可以访问fun()中定义的所有变量。接下来最重要的一点就是,fun()函数在执行完毕之后其活动对象也不会被销毁,因为闭包fun1()的作用域链仍然在引用这个活动对象。换句话说,当fun()函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中,直到闭包fun1()被销毁后,fun()的活动对象才会被销毁。例如当上面的result被赋值为null时,此时闭包fun1()就被解除了引用,随后被垃圾收集器回收,最后释放其内存。

因此,可以总结出闭包有两大用途:1、可以读取函数内部的变量;2、可以让函数内部的变量始终保存在内存中。
注意:由于闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存,过度使用闭包可能会导致内存泄漏。