作用域和闭包

本文着重于对 JavaScript 中的作用域和闭包机制进行剖析和说明,闭包本质上也是作用域的一种类型,因为在 JavaScript 里非常重要,所以我们把它在标题里单独列出来。

 

概述

作用域(Scope),即有效范围,决定了标识符(包括变量、常量、函数名等)在程序中可以被使用的区域。

作用域存在着嵌套关系,对某段代码来说,可能存在多个同名的标识符,其作用域都覆盖了这段代码,这时,被适用的将是作用域范围最小(即离代码最近)的那个标识符。

作用域有两大类别:

  • 静态作用域
    指标识符的作用范围是由标识符的声明位置和程序的结构决定的,也就是说某段代码所参照的标识符是在哪定义的,通过对程序代码进行词法解析即可确定,标识符的作用域是静态不变的。
  • 动态作用域
    指标识符的作用范围是由程序运行时函数的调用情况决定的,也就是说某段代码所参照的标识符是在哪定义的,需在程序运行时根据执行栈才能确定,标识符的作用域可能会是动态变化的。

以下面的代码为例,我们来简单的看一下两者的区别和各自的特点。

var i = 1,
    f1 = function() {
        i = i+1;
        console.log(i);
    },
    f2 = function() {
        var i = 2;
        f1();
        f1();
    };
    f3 = function() {
        f1();
        f1();
    };
f2();
f3();

静态作用域模式下,f1() 代码里参照的 i 始终是全局变量 i ,其输出如下:

  • f2()
    • 第一次调用的 f1()
      将全局变量 i 由1变为 2 , 并输出2
    • 第二次调用的 f1()
      将全局变量 i 由2变为 3 ,并输出 3
  • f3()
    • 第一次调用的 f1()
      将全局变量 i 由3变为 4,并输出 4
    • 第二次调用的 f1()
      将全局变量 i 由4变为 5,并输出 5

而动态作用域模式下,f1() 代码里参照的 i 取决于函数的调用(执行栈):

  1. 被 f2() 调用的时候
    f2() 里也有同名的 i 变量,由于排在全局变量 i 的前面,这时 f1() 操作的是 f2() 里的局部变量 i。
  2. 被 f3() 调用的时候
    f3() 里没有局部变量 i,因此直接操作的是全局变量 i。

其输出如下:

  • f2()
    • 第一次调用的 f1()
      将 f2() 的局部变量 i 由2变为 3,并输出 3
    • 第二次调用的 f1()
      将 f2() 的局部变量 i 由3变为 4,并输出 4
  • f3()
    • 第一次调用的 f1()
      将全局变量 i 由 1 变为 2,并输出 2
    • 第二次调用的 f1()
      将全局变量 i 由 2 变为 3,并输出 3

采用动态作用域模式的语言很少,大部分语言采用的都是静态作用域模式,JavaScript 采用的也是静态作用域模式,因此这里我们只针对静态作用域来进行展开。

作用域的类型

不限于JavaScript,这里将各个语言有所实现的静态作用域作一个总的类型分类

  1. 全局作用域 (global scope)
    全局作用域里的变量称为全局变量,所有程序都能访问。
    大部分语言都支持全局作用域,既有象 Basic 一样的只有全局作用域的语言,也存在象 Python 这样不让程序简单的就能修改全局变量的语言。
    JavaScript 支持全局作用域
  2. 文件作用域 (file scope)
    文件作用域与全局作用域类似,但变量只能是同一个源文件模块里的程序才能访问。
    支持文件作用域的语言比较少,有C/C++等。
    JavaScript 不存在文件作用域
  3. 函数作用域 (function scope)
    函数作用域是一个局部作用域,变量在函数内声明,只在函数内部可见。大部分语言都支持函数作用域。
    JavaScript 支持函数作用域
  4. 代码块作用域 (block scope)
    代码块作用域也是一个局部作用域,变量在代码块 ({}) 内声明,只在代码块内部可见。
    支持代码块作用域的有 C/C++、C#、Java。
    JavaScript 从 ES6 开始支持代码块作用域
  5. 静态局部作用域 (static local scope)
    静态局部作用域也是函数内部的局部作用域,其特殊性是即使函数执行结束后变量也不会被释放,每次函数代码的执行访问的都是同一个变量。
    支持静态局部作用域的语言比较少,基本上都是一些历史比较悠久的语言,如 C/C++、Fortran 等。
    JavaScript 不存在静态局部作用域
  6. 闭包作用域(closure scope)
    闭包是一种让函数的代码能够访问函数声明(函数对象被创建)时的作用域内(上下文环境)的变量机制。闭包在函数式语言中非常普遍。
    JavaScript 支持闭包作用域

全局作用域

在 JavaScript 中,全局作用域是最外围的一个执行上下文,可以在代码的任何地方访问到。在浏览器中,我们的全局作用域就是 window。因此在浏览器中,所有的全局变量和函数都是作为 window 对象的属性和方法创建的。

局部作用域

局部作用域和全局作用域正好相反,局部作用域一般只在某个特定的代码片段内可访问到,JavaScript 中的局部作用域分为函数作用域和代码块作用域两类,其中代码块作用域在 ECMAScript6 之前不被支持。

函数作用域

函数作用域的变量不管声明在那个位置,在整个函数内部都可访问。

函数内用var声明的变量都具有函数作用域。

function hi() {
  for (var i = 0; i < 10; i++) {
    var value = "hi " + i ;
  }
  console.log(i); // 输出:10
  console.log(value);//输出 : hi 9
}
hi();

如上例所示,变量 i 和 value 虽然是声明在循环语句块里,但因为是函数作用域,所以即使出了循环语句块,后面的代码仍可访问。

代码块作用域

代码块作用域的变量只在声明变量的代码块内部可见。

ECMAScript6 之后,函数内用 let 声明的变量和 const 声明的常量都属于代码块作用域。

function hi() {
  for (let i = 0; i < 10; i++) {
    let value = "hi " + i ;
  }
  try {
   console.log(i);  // Uncaught ReferenceError: i is not defined
  } catch (e){
    console.log(e.toString()); 
  } 
  try {
    console.log(value);// Uncaught ReferenceError: value is not defined
  } catch (e){
    console.log(e.toString()); 
  } 
}
hi();

闭包作用域

闭包并没有一个明确标准化的定义,一个常见的定义是把闭包当成一个由函数和其创建时的执行上下文组合而成的实体。 这个定义本身没有问题,但把闭包理解成函数执行时的作用域环境好像更接近闭包的本质,因此知典对 JavaScript 中的闭包重新做了一个定义:

  1. 闭包是将函数定义时的局部作用域环境保存起来后生成的一个实体。
  2. 闭包实现了一个作用域,函数始终是运行在它们被定义的闭包作用域里,而不是它们被调用的作用域里。
  3. 闭包可以嵌套,全局作用域→闭包(0..n)作用域→函数作用域→代码块(0..n)作用域就整个的形成了一个代码执行时的作用域链。

以下面的代码为例:

  var x = 1;
  function Counter() {
     var n = 0;
     var f = function () {
         n = n + x;
         return n;
     };
     return f;
   }

函数Counter()在内部定义了本地变量 n,并返回一个函数对象 f。

函数对象 f 创建时的局部作用域环境(包含变量 n)被保存起来,成为被返回的函数对象内部关联的闭包。

调用Counter(),获得返回的函数对象:

var a = Counter();

Counter()执行后的环境状态如下:

  ┌────────────────┐
    全局环境             ┌────────────────────────────────┐
      x => 1         ←─  Counter()执行时的局部环境(a的闭包)     ←─ a();
      a => function           n => 0                          ←─ a();
                        └────────────────────────────────┘     
  └────────────────┘   

连续4次调用保存在变量a里的返回函数:

console.log("a()=" + a());  //输出: a()=1
console.log("a()=" + a());  //输出: a()=2
console.log("a()=" + a());  //输出: a()=3
console.log("a()=" + a());  //输出: a()=4

如上例所示,a() 每次执行时访问的都是同一个闭包环境里的同一个变量 n。

下面的代码,我们调用两次Counter():

  var a = Counter();
  var b = Counter();

Counter()两次执行后的环境状态如下:

┌──────────────────┐       ┌───────────────────────────────────────┐
   全局环境           ←─    Counter()第1次执行时的局部环境(a的闭包)     ←─ a();
      x => 1                      n => 0                             ←─ a();
      a => function        └───────────────────────────────────────┘
      b => function        ┌───────────────────────────────────────┐
                     ←─     Counter()第2次执行时的局部环境(b的闭包)     ←─ b();
                                  n => 0                             ←─ b();
                           └───────────────────────────────────────┘                          
└──────────────────┘                          

然后执行以下代码:

  
  console.log("a()=" + a()); // 输出:a()=1
  console.log("a()=" + a()); // 输出:a()=2
  console.log("b()=" + b()); // 输出:b()=1
  console.log("a()=" + a()); // 输出:a()=3
  console.log("b()=" + b()); // 输出:b()=2

如上例所示,Counter() 每次执行所返回的函数对象 f,都是一个不同的函数对象,关联着不同的闭包环境。

作用域链

JavaScript 中的作用域链有两种: 一种是函数创建时保存在函数对象属性中的、静态存在的作用域链,还有一种是程序执行时,与执行上下文相关联的、动态存在的作用域链,下面对这两种作用域链分别进行说明。

函数的作用域链

如闭包说明中的截图所示,函数对象有一个内部属性 [[Scope]],包含了函数被创建后的作用域对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数对象执行时的代码访问。

闭包说明的示例代码中所创建的函数对象 a 和 b,各自的作用域链如下图所示:

函数的作用域链自函数对象创建起直至函数对象被释放(不再被访问)为止,即使函数代码不在执行状态始终一直存在,因此我们说函数的作用域链是一个静态的作用域链。

执行上下文的作用域链

(这里以函数的执行为例进行说明,与函数的执行相比,全局代码执行时的作用域链更为简单,没有函数作用域和闭包作用域。)

执行函数时会创建一个称为“执行上下文(execution context)”的内部对象,执行上下文定义了函数执行时的环境。

每个执行上下文都有自己的作用域链,当执行上下文被创建时,它的作用域链初始化为当前运行函数的 [[Scope]] 属性所包含的作用域对象,这些作用域对象的引用被复制到执行上下文的作用域链中。

另外,执行上下文会为函数创建一个函数作用域对象,JavaScript 里称之为活动对象(activation object),该对象包含了函数的所有局部变量、命名参数、参数集合以及 this,然后此对象会被推入作用域链的前端。新的作用域链如下图所示:

有些语句可以延长作用域链,即在作用域链的前端临时增加一个新的代码块作用域对象,该作用域对象会在代码执行后被移除。

ES6 之后支持代码块作用域,如果代码块里存在 let 定义的变量,即会出现作用域延长的现象。ES5 之前也有两种特殊情况下会发生这种现象:

  • try-catch 语句中的 catch 块
  • with 语句

函数执行结束后,函数的执行上下文对象被释放,其关联的作用域链也会一起被释放。因此我们说执行上下文的作用域链是一个动态的作用域链。

以下面的代码为例:

  var x = 1;
  function Counter() {
     var n = 0;
     var f = function () {
         console.log("start");
         for (let i = 1;i<3;i++) {
            debugger;
            console.log(i);
         }
         n = n + x;
         return n;
         var lvar;
     };
     return f;
 }
 var a = Counter();
 a();

其执行过程中的作用域链变化如下:

  1. 进入函数
    作用域链的顶端是 local (函数作用域)
  2. 进入代码块
    一个新的 block (块作用域)被压至作用域链的顶端
  3. 执行代码块内的代码
  4. 退出代码块
    block (块作用域)被弹出, 作用域链顶端恢复为 local (函数作用域)