【YDKJS笔记】二、作用域与闭包

概述

作用域实际上可以视为一组规则,定义了如何在某些位置存储变量以及如何找到这些变量,即引擎如何通过标识符名称在当前的作用域,或者在包含它的任意嵌套作用域中来查询一个变量。
这些作用域规则在编译器中设置。
尽管JavaScript一般被划分到“动态”或者“解释型”语言的范畴,但其实它是一个编译型语言。JavaScript以一种精巧的方式实施许多与传统的语言编译器相同的步骤:(1) 分词/词法分析:将一串字符打断成对语言来说有意义的片段,成为token(记号);(2) 解析:将一个token的流(数组转换成一个嵌套元素的树),综合的表示程序的语法结构,称为“抽象语法树”(AST——Abstract Syntax Tree);(3) 代码生成:将抽象语法树转换为可执行代码。
可以说,任何JavaScript代码段在它执行之前(通常是刚好在它执行之前)都必须被编译。

  • 编译器术语
    LHS(Left-hand Side):赋值的目标;
    RHS(Right-hand Side):赋值的源;
    区分这两个术语是重要的,因为在变量还没有被声明时,这两种类型的查询的行为不同。
    如果引擎进行RHS查询,在嵌套的作用域的任何一个地方都找不到一个值,就会抛出一个ReferenceError
    如果引擎进行的是一个LHS查询,但到达了全局作用域都没有找到它,而且如果程序没有运行在“Strict模式”下,那么将会在全局作用域中创建一个同名的新变量,并把它交还给引擎。若是在“Strict模式”下将会抛出一个ReferenceError
    如果一个RHS查询的变量被找到了,但是操作者试着去做一些这个值不可能做到的事情,比如让一个非函数值作为函数运行,那么引擎就会抛出TypeError错误。
    注意:ReferenceError是关于作用域解析失败的,而TypeError暗示作用域解析成功,但是对这个结果的操作非法/不可能。
  • 嵌套的作用域
    就像一个代码块或函数被嵌套在另一个代码块或函数中一样,作用域被嵌套在其他的作用域中。所以当在直接作用域中找不到一个变量时,引擎就会咨询下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(即全局作用域)。
    遍历嵌套作用域的规则十分简单:引擎从当前执行的作用域开始查找变量,如果没有找到,就向上走一级继续查找,如此类推。如果到了最外层的全局作用域,那么停止查找无论是否找到变量。

词法作用域

在上述内容中,“作用域”被定义为一组规则,主宰着引擎如何通过标识符在当前作用域,或者在包含它的任意嵌套作用域中来查询一个变量。
作用域的工作方式有两种常见的模型:词法作用域(在绝大部分编程语言中被使用)和动态作用域(一些语言,如Bash脚本、Perl的一些模式等等)。

  1. 词法分析时
    词法分析处理是检查一串源代码字符,并给token赋予语法含义作为某种有状态解析的输出。词法作用域是在词法分析时被定义的作用域,也就是基于程序员在写程序时,变量和作用域的块在何处被编写时决定的,因此在词法分析器处理代码时是(基本)不变的。注意有一些方法可以骗过词法作用域,从而在词法分析器处理后改变它,但是这些方法应当尽量避免。公认的最佳实践是将词法作用域看作是仅仅依靠词法的,因此完全是编写时决定的。
    这些嵌套的作用域都是严格嵌套的,也就是没有哪个函数可以同时(部分的)存在于另外两个外部的作用域中,就像没有函数可以部分地存在于它的两个父函数中一样。
  2. 查询
    引擎在查找一个标识符时,就是从当前最小/最内部的作用域开始,不断地向外冒泡的过程。一旦找到第一个匹配,作用域查询就停止了。相同的标识符可以在嵌套作用域的多个层中指定,这称为“遮蔽(shadowing)”,内部的标识符“遮蔽”了外部的标识符。
    注意:全局变量也自动是全局对象(在浏览器中为window,等等)的属性,所以不直接通过全局变量的词法名称,而通过将它作为全局对象的一个属性引用来间接地引用是可能的。这种技术给出访问全局变量的方法,没有它全局变量将因为被遮蔽而不可访问。然而,被遮蔽的非全局变量是无法访问的。
    不管函数从哪里被调用,也不管它是如何被调用的,它的词法作用域是由这个函数被声明的位置唯一定义的。
    词法作用域查询仅仅在处理头等标识符时实施,如foo.bar.baz,词法作用域查询仅仅在查找foo标识符时实施,但一旦定位这个变量,对象属性访问规则将会分别接管barbaz属性的解析。
  3. 欺骗词法作用域
    欺骗词法作用域也就是在运行时“修改”词法作用域。JavaScript有两种这样的机制,这两种机制都常常被人诟病,欺骗词法作用域会导致更低下的性能

    • eval
      JavaScript中的eval(..)函数接收一个字符串作为参数值,并把这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上,也就是用编程的方法在已经编写好的代码内部生成代码,而且这个生成代码是可运行的,就好像编写时已经在那里了一样。
      eval(..)欺骗并假装这个编写时(即词法),代码一直就在那里。
      举例如下代码:

      function foo(str, a) {
          eval(str); // 作弊
          console.log(a, b);
      }
      var b = 2;
      foo("var b = 3;", 1); // 1 3

      上述代码在foo(..)内部创建了变量b,它遮蔽了声明在外部(全局)作用域中的b
      默认情况下,如果eval(..)执行的代码字符串包含一个或多个声明(变量或函数),这个动作就会修改这个eval所在的词法作用域。技术上讲,eval(..)可以通过这种技巧被“间接”调用,而使得它在全局作用域的上下文中执行,以此改变全局作用域。
      注意:当eval(..)被用于一个操作它自己的词法作用域的strict模式程序时,在eval(..)内部做出的声明不会实际上修改包围它的作用域。

      function foo(str) {
          "use strict";
          eval(str);
          console.log(a); // RefferenceError: a is not defined
      }
      foo("var a = 2");

    在以前的JavaScript中,setTimeout(..)setInterval(..)可以为它们各自的第一个参数值接收一个字符串,其内容将会被eval为一个动态生成的函数代码,这种行为已经被废弃。
    应当尽量减少动态生成代码,因为这会导致性能的倒退。

    • with
      with关键字现在已经被废弃,此处只是介绍它如何与词法作用域互动并影响词法作用域。
      with的常见方式是作为一种缩写来引用一个对象的多个属性,而不必每次都重复对象引用本身。
      如下举例所示:

      var obj = {
          a: 1, 
          b: 2,
          c: 3
      }; 
      // 重复“obj”显得“繁冗”
      obj.a = 2;
      obj.b = 3;
      obj.c = 4;
      
      // 简单的缩写如下
      with(obj) {
          a = 3;
          b = 4;
          c = 5;
      }

      实际上,这里除了简单的对象属性访问的便捷缩写,还发生了一些其他事情。考虑如下代码:

      function foo(obj) {
          with(obj) {
              a = 2;
          }
      }
      var o1 = {
          a: 3
      };
      var o2 = {
          b: 3
      };
      foo(o1);
      console.log(o1.a); // 2
      foo(o2);
      console.log(o2.a); // undefined
      console.log(a); // 2 -- 全局作用域被泄露

      with块内部,制造的变量a,看似普通词法引用,实际上是一个LHS引用,并将值2赋给它。当传入o2时,由于没有a属性,没有这样的属性被创建,所以o2.a还是undefined
      但是这导致了一个副作用:赋值a = 2创建了一个全局变量a
      with语句接收一个对象,这个对象有0个或者多个属性,并将这个对象视为一个完全隔离的词法作用域,因此这个对象的属性被视为在这个“作用域”中词法定义的标识符。
      注意:尽管一个with块将一个对象视为一个词法作用域,但是在with块内部的一个普通的var声明将不会归于这个with块的作用域,而是归于包含它的函数作用域。with语句从传递过来的对象中凭空制造了一个全新的词法作用域。
      当传入o2时,“作用域”o2中没有a这样的标识符,foo(..)作用域中也没有,甚至全局作用域也没有找到,所以当a = 2被执行时,其结果就是自动全局变量创建(非strict模式下)。
      with在运行时将一个对象和它的属性转换成为一个带有“标识符”的“作用域”。

    注意eval(..)with都受到Strict模式的影响。with不允许使用,eval(..)保有核心功能,但各种间接形式的或者不安全的eval(..)是不允许的。

  4. 性能
    JavaScript在编译初期进行许多性能优化的工作,其中的一些优化原理都归结为实质上在词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以少花些力气来解析标识符。
    如果引擎在代码中发现一个eval(..)with出现,那么实质上就不得不假定自己知道的所有标识符的位置是无效的,因为它不可能在词法分析时就知道eval会传入什么值来修改词法作用域,或者是向with传递的对象有什么内容来创建一个新的将被查询的词法作用域。
    也就是,如果出现欺骗词法作用域,那么有许多的优化都会变得没有意义,那么引擎就会简单的不做任何优化,所以代码肯定趋于运行更慢。

函数与块级作用域

创建作用域的方法有什么?

  1. 函数中的作用域
    常见的回答是JavaScript拥有基于函数的作用域。也就是声明的每一个函数都为自己创建了一个作用域,而且没有其他的结构能够创建它们自己的作用域,但是实际上这种说法不完全正确。
    函数作用域的思路就是:所有变量都属于函数,而且贯穿整个函数始终都可以使用和重用(而且甚至可以在嵌套的作用域中访问)。这种设计方式可以很好的利用JavaScript的“动态”性质——变量可以根据需要接受不同类型的值。

    • 隐藏于普通作用域
      拿出所编写的代码的任意一部分,在它周围包装一个函数声明,这就在实质上“隐藏”了这部分代码。这意味着现在在这段代码中的任何声明都将绑在这个新的包装函数的作用域上,而不是前一个包含它们的作用域。
      这种基于作用域的隐藏是一种有用的技术,这主要是由一种称为“最低权限原则”(principle of least privilege)的软件设计原则引起的,也称为“最低授权”或“最少曝光”。这个原则规定,在软件设计中,比如一个模块/对象的API,你应当只暴露所需要的最低限度的东西,而“隐藏”其他的一切。
      如果所有的变量和函数都在全局作用域中,它们将对所有嵌套的作用域都是可访问的,但这会违背“最少……”原则,暴露了很多本应当保持为私有的变量和函数。
      例如:
      function doSomething(a) {
          b = a + doSomethingElse(a*2);
          console.log(b*3);
      }
      function doSomethingElse(a) {
          return a-1;
      }
      var b;
      doSomething(2); // 15

    在这段代码中,变量b和函数doSomethingElse(..)可能是doSomething(..)如何工作的私有细节,此时外围的作用域都可以“访问”变量b和函数doSomethingElse(..),这是不必要而且危险的。一个更恰当的设计使将这些私有细节隐藏在doSomething的作用域内部,比如:

    function doSomething(a) {
        function doSomethingElse(a) {
            return a-1;
        }
        var b; 
        b = a+doSomethingElse(a*2);
        console.log(b*3);
    }
    doSomething(2); // 15
    
    • 避免冲突
      将变量和函数“隐藏”在一个作用域内部的另一个好处就是避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。
      例如:

      function foo() {
          function bar(a){
              i = 3; // 在外围的for循环的作用域中改变`i`
              console.log(a+i);
          }
          for(var i=0; i<10; i++) {
              bar(i*2); // 无限循环
          }
      }
      foo();

      bar(..)内部的赋值i = 3以外的覆盖了在foo(..)的for循环中声明的i。假如将i = 3改为var i = 3;将修复这个问题(并将为i创建一个前面提到的遮蔽变量声明)。

      • 全局“名称空间”
        变量冲突很可能发生的一个特别强有力的例子就是在全局作用域中,当多个库被加载到一起时,如果它们没有适当地隐藏它们内部/私有函数的变量,那么它们十分容易互相冲突。
        这样的库通常会在全局作用域中使用一个足够独特的名称来创建一个单独的变量声明,它经常是一个对象,然后这个对象被用作这个库的一个名称空间,所有要明确暴露出来的功能都被作为属性挂在这个对象(名称空间)上,而不是将它们自身作为顶层词法作用域的标识符。
        例如:

        var MyReallyCoolLibrary = {
            awesome: "stuff";
            doSomething: function() {
                // ...
            }
            doAnotherThing: function() {
        
            }
        };
      • 模块管理(详细可参考**作用域闭包*8部分)
        另一种回避冲突的选择是通过任意一种依赖管理器,使用更加现代的“模块”方式。使用这这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。
  2. 函数作为作用域
    虽然可以将一段代码包装成一个函数来实现“隐藏”变量和函数声明,但是这种方法并不一定理想。他引入了几个问题,首先是程序员不得不声明一个命名函数,如foo(),这意味着这个标识符名称foo本身就“污染”了外围作用域。另外,程序员不得不通过名称foo()来明确地调用这个函数,从而运行被包装的代码。
    如果这个函数能够不需要名称(或者这个名称不污染外围作用域),而且如果这个函数能够自动地被执行就更理想了。
    JavaScript为此提供了一个解决方法:

            ```
            var a = 2;
            (function foo(){
                var a = 3;
                console.log(a); // 3
            })();
            console.log(a);
            ```
    

    此处函数foo被视为一个函数表达式。
    注意:区别声明与表达式的最简单的方法是,这个语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个出现的,那么它就是一个函数声明。否则,就是一个函数表达式。
    此处可以观察到一个函数声明和一个函数表达式之间的关键不同是它的名称作为一个标识符被绑定在何处。
    上述代码中,名称foo没有被绑定在外围作用域中,而是被绑定在它自己的函数内部。换言之,(function foo(){...})作为一个表达式意味着标识符foo仅能在..代表的作用域中被找到。将名称foo隐藏在它自己内部意味着它不会污染外围作用域。

    • 匿名与命名
      函数表达式作为回调参数是常见的“匿名表达式”,因为function()...上没有名称标识符。函数表达式可以是匿名的,但是函数声明不能省略名称,那将会是不合法的JS程序。
      匿名函数表达式可以快速地键入,而且许多库和工具往往鼓励使用这种代码惯用风格。然而它们有几个缺点需要考虑:
      - 在栈轨迹上匿名函数没有名称可以表示,这可能会使得调试更加困难;
      - 在没有名称的情况下,若这个函数需要为了递归等目的引用自身,那么就需要使用被废弃的arguments.callee引用。另一个需要自引用的例子是,当一个事件处理器函数在被触发后想要把自己解除绑定。
      - 匿名函数省略的名称经常对提供更易读/易懂的代码很有帮助,一个描述性的名称可以帮助代码自解释。
      内联函数表达式很强大且很有用:给你的函数表达式提供一个名称就可以有效地解决上述缺陷,而且并不带来什么坏处,最佳的方法总是命名你的函数表达式。
    • 立即调用函数表达式
      形如(function foo(){...})()第一个()使得函数foo变成一个表达式,末尾添加的另一个()执行这个函数。
      这个模式十分常见,被称之为IIFE,表示“立即被调用的函数表达式”(Immediately Invoked Function Expression)。当然IIFE不一定需要一个名称——IIFE的最常见形式是使用一个匿名函数表达式。
      传统的IIFE有一种稍稍变化的形式如(function(){..}()),在这种方法中,由于调用的()被移动到用于包装的()内侧。这纯粹是一个偏好风格的选择。
      IIFE的另一种常见的变种是,利用其函数调用的事实来传入参数。例如:

      var a = 2;
      (function IIFR(global) {
          var a = 3;
          console.log(a); // 3
          console.log(global.a); // 2
      })(window);
      console.log(a); // 2

      传入window对象引用,但是参数被命名为global,这样我们对于全局和非全局引用就有了一个清晰的文体上的划分。
      IIFE的另一种应用解决了一个小问题:默认的undefined标识符的值也许会被不正确地覆盖掉,而导致意外的结果,通过将参数命名为undefined,同时不为它传递任何参数值,我们就可以保证一个代码块中的undefined标识符确实是一个未定义的值。

      undefined = true; // 给其他的代码埋雷233
      (function IIFE(undefined) {
          var a; 
          if(a === undefined) {
              console.log("Undefined is safe here");
          }
      })();

      IIFE还有另一中变种,它将事情的顺序颠倒,要被执行的函数在调用和传递给它的参数之后给出。这种模式用于UMD(Universal Module Definition —— 同一模块定义)项目,这样虽然有点繁冗,但是更加干净易懂。

      var a = 2;
      (function IIFE(def){
          def(window);
      })(function def(global) {
          var a = 3;
          console.log(a); // 3
          console.log(global.a); // 2
      });

      def函数表达式在代码段的后半部分被定义,然后作为参数被传递给代码段前半部分定义的IIFE函数。最后参数def(函数)被调用,并将window作为global参数传入。

  3. 块作为作用域
    虽然函数作为作用域单位是最常见的、也是最为广泛传播的设计方式,但是仍然有其他的作用域单位,而且使用这些作用域单位可以导致更好、对于维护来说更加干净的代码。
    块作用域尽可能靠近地、尽可能局部地在变量将被使用的位置声明它,尽管有时候意图使用块作用域,但是表面上来看,JavaScript并没有块作用域的能力,如在for循环头内部中声明的var i=0;或者是在一个if语句内部的变量声明var bar=1;,实际上这些变量都属于最小范围内的函数作用域。
    但是通过一些特殊的结构可以实现块作用域功能。

    • with
      尽管提倡尽量不要使用with,但是它确实是一种形式的块作用域的例子。它从对象中创建的作用域仅存在于这个with语句的生命周期中,而不再其外围作用域。
    • try/catch
      JavaScript在ES3中明确指出try/catchcatch子句中声明的变量是属于catch块作用域的。
      例如:

      try{
      undefined(); // 用非法操作强制产生一个异常!  
      } catch(err) {
      console.log(err); // 好用!
      }
      console.log(err); // ReferenceError: `err` not found

      注意:虽然这种行为已经明确规定,而且几乎对于所有标准的JS环境的都成立,但是如果你在同一个作用域中有两个及以上catch子句,而它们又各自用相同的标识符名称声明了它们表示错误的变量时,许多linter(检查机器代码)依然会报警,虽然实际上这并不是重定义。为了避免这些不必要的警告,应当将catch变量命名为err1err2,等等。

    • let
      ES6引入了一个新的关键字let,作为另一种声明变量的方式伴随着var
      let关键字将变量声明附着在它所在的任何块(通常是一个{..})的作用域中,也就是let关键字为它的变量声明隐含地劫持了任意块的作用域。

      var foo = true;
      if(foo) {
          let bar = foo*2;
          bar = something(bar);
          console.log(bar);
      }
      console.log(bar); // ReferenceError

      使用let将一个变量附着在现存的块中有些隐晦,为了块作用域创建明确的块可以部分解决这些问题,使变量附着在何处更加明显。如下所示:

      var foo = true;
      if(foo) {
          { // <-- 明确的块
              let bar = foo*2;
              bar = something(bar);
              console.log(bar);    
          }
      }
      console.log(bar); // ReferenceError

      程序员可以通过简单地引入一个{..}来为let创建一个任意的可以绑定的块。在以后的重构中将整个块四处移动可能会更加容易,而且不会影响外围的if语句的位置和语义。
      使用let做出的声明将不会在它们所出现的整个块的作用域中提升。所以直到声明语句出现为止,声明将不会“存在”于块中。

      console.log(bar); // ReferenceError!
      let bar = 2;
    • 垃圾回收
      块作用域的另一个有用之处是关于闭包和释放内存的垃圾回收。这里将简单地在这里展示,闭包的机制在之后详细描述。

      function process(data) {
          // 做些有趣的事情
      }
      var someReallyBigData = {..};
      process(someReallyBigData);
      var btn = document.getElementById("my_button");
      btn.addEventListener("click", function click(evt) {
          console.log("button clicked");
      }, /*capturingPhase=*/false);

      点击事件的处理器回调函数click根本不需要someReallyBigData变量。这意味着从理论上讲,在process(..)运行之后,这个消耗巨大内存的数据结构可以被作为垃圾回收。然而,JS引擎很可能(虽然这要看具体实现)将会仍然将这个结构保持一段时间,因为click函数在整个作用域上拥有一个闭包。
      块作用域可以解决这个问题,使引擎清楚地知道它不必再保持someReallyBigData了:

      // 运行过后,任何定义在这个块中的东西都可以消失了
      {
          let someReallyBigData = {..};
          process(someReallyBigData);
      }

      声明可以将变量绑定在本地的明确的块是一种强大的工具,善于使用可以提高性能。

    • let循环
      let的作用在for中得到了极大的体现。在闭包中很有意义,将在后面讨论。

      for (let i=0; i<10; i++){
          console.log(i);
      }
      console.log(i); // ReferenceError

      在for循环头部的let不仅将i绑定在for循环体上,而且实际上,它会对每一次循环的迭代重新绑定i,确保它是上一次循环迭代末尾的值。这种每次迭代重新绑定的行为也等同于以下过程:

      {
          let j;
          for(j=0; j<10; j++){
              let i = j; // 每次迭代都重新绑定
              console.log(i);
          }
      }
    • const
      const也是ES6引入,它也创建一个块作用域变量,但是它的值是固定的(常量),试图改变都会导致错误。

      var foo = true;
      if(foo) {
          var a = 2;
          const b = 3; // 包含于if作用域中
      
          a = 3; // OK!
          b = 4; // Error!
      }
      console.log(a); // 3
      console.log(b); // ReferenceError!

提升

首先回顾一下编译器,引擎实际上将会在它解释执行JavaScript代码之前编译它,编译过程的一部分就是找到所有的声明并将其关联在合适的作用域上。所以在代码被执行之前,所有的声明,变量和函数,都会首先被处理。
对于一个赋值语句var a = 3;,JavaScript将其视为两个语句:var a;a = 3;。第一个语句,声明是在编译阶段被处理的,第二个语句赋值是在执行阶段处理的,仍然留在原处。
变量和函数声明从它们在代码流中的位置“移动”到了代码的顶端,这就是“提升”。
此处需要注意的是以作用域为单位的,也就是声明、变量和函数会被提升到其所在作用域的顶端。
函数声明会被提升,但是函数表达式不会,如下:

foo(); // 不是ReferenceError,而是TypeError!
var foo = function bar(){
    // ...
};

变量标识符foo被提升并附着在全局作用域上,所以foo()不会作为一个ReferenceError而失败。但foo还没有值,所以foo()就是试图调用一个undefined值,所以是TypeError,即非法操作。
同时要注意,对于bar尽管命名,但是在外围作用域依然不可用。

  1. 函数优先
    函数声明和变量声明都会被提升,但是有一个细节值的注意,函数会首先被提升(在有多个重复声明的代码中也适用),然后才是变量。考虑如下代码:

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

    这个代码段被引擎解释执行为:

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

    注意var foo是一个重复被无视的声明,尽管出现在了function foo()...声明之前,但是函数声明是在普通变量之前被提升的。
    虽然多个/重复的var声明实际上被忽略,但是后续的函数声明的确会覆盖前一个。考虑如下:

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

    注意在块内出现的函数声明一般会被提升至外围的作用域,这种行为很容易出现误解,而且未来的JavaScript版本可能会改变,所以最好不要做这样的事情。

作用域闭包

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。考虑如下代码:

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

函数bar()对于foo()内的作用域拥有词法作用域访问权,在上述代码中,在执行foo()之后,其返回的值,即内部的bar()函数,被赋给一个称为baz的变量,当调用baz()时,实际上就是调用内部函数bar(),只是通过一个不同的标识符引用。
也就是bar()在其被声明的词法作用域之外被执行了,但是在这之前,foo()被执行之后,一般来说会期望foo()的整个内部作用域都将消失(由于引擎启用了垃圾回收器),但是闭包会阻止foo()内部作用域的消失,因为内部作用域实际上仍然在使用。这有赖于bar()被声明的位置,bar()拥有一个词法作用域闭包覆盖着foo()的内部作用域,闭包为了bar()在以后的任意时刻都可以引用这个作用域而保持它的存在。bar()依然拥有对foo()内部作用域的引用,这个引用称为闭包
总之无论使用什么方法将内部函数传送到其词法作用域之外,它都会维护一个指向它最开始被声明时的作用域的引用,这就是闭包。

  1. 循环+闭包
    最常用来展示闭包的是for循环,考虑如下代码:

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

    此时运行结果是“6”会被打印5次,一秒一个。首先回调函数必然是在for循环结束后被执行,也就是及时每次迭代是setTimeout(..., 0),所有的这些回调函数也都仍然是严格的在循环之后运行。
    显然这段代码并不如我们语义上暗示的那样运行,即打印1, 2, 3, 4, 5。这是因为虽然这5个回调函数在每次循环迭代中分离地定义,由于作用域的工作方式,它们都闭包在一个共享的全局作用域上,也就是事实上这5个函数指向相同的i的引用。
    所以说这个问题的解决方法就是为每次循环迭代都准备一个新的被闭包的作用域,比如说,用IIFE通过声明并立即执行一个函数来创建作用域:

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

    每次迭代内部使用的IIFE为每次迭代创建了新的作用域,在每次迭代时闭包一个新的作用域,这些作用域中的每一个都拥有一个持有正确的迭代值的变量给我们访问。
    除了使用IIFE在每一次迭代中创建新的作用域之外,我们也可以通过let创建一个块作用域,也就是在for循环头部,用let i=1;来代替var i=1;表示变量i是为每一次迭代声明一次,它将在每次后续的迭代中被上一次迭代末尾的值初始化。

  2. 模块
    考虑以下代码,模块模式利用了闭包实现:

    function CoolModule(){
        var something = "cool";
        var another = [1, 2, 3];
    
        function doSomething(){
            console.log(something);
        }
    
        function doAnother(){
            console.log(another.join("!"));
        }
    
        return {
            doSomething: doSomething,
            doAnother: doAnother
        };
    }
    
    var foo = CoolModule();
    foo.doSomething(); // cool
    foo.doAnother(); // 1 ! 2 ! 3

    在如上代码中,somthinganother是私有数据变量,doSomething()doAnother()都拥有覆盖在foo()内部作用域上的词法作用域。在JavaScript中这种模式称为模块。实现模块模式的最常见方法经常被称为“揭示模块”。首先CoolModule()只是一个函数,必须被调用才能成为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。另外,CoolModule()函数返回一个对象,通过对象字面量语法标记。这个我们返回的对象拥有指向我们内部函数的引用。但是没有指向我们内部数据变量的引用,可以认为是隐藏和私有的。这个返回值对象实质上是一个我们模块的公有API
    注意:从模块中返回一个实际对象(字面量)不是必须的。我们可以仅仅直接返回一个内部函数,jQuery就是一个很好地例子。jQuery$标识符是jQuery“模块”的公有API,但是它们本身都只是一个函数(这个函数本身可以有属性,因为所有函数都是对象)。
    行使模块模式有两个“必要条件”:一是必须有一个外部的外围函数,而且它必须至少被调用一次(每次创建一个新的模块实例)。二是外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问/修改这个私有状态。
    上述代码中CoolModule()是一个独立的模块创建器,可以被调用多次,每次创建一个新的模块示例,使用IIFE就可以得到一个“单例”模式。

    var foo = (function CoolModule(){...})();
    foo.doSomething(); // cool
    foo.doAnother(); // 1 ! 2 ! 3

    模块只是函数,所以可以接收参数,同时也可以为公有API返回的对象命名:

    var foo(function CoolModule(id){
        function change(){
            // 修改公有API
            publicAPI.identify = identify2;
        }
        function identify1(){
            console.log(id);
        }
        function identify2(){
            console.log(id.toUpperCase());
        }
        var publicAPI = {
            change: change,
            identify: identify1
        };
    
        return publicAPI;
    })("foo module");
    foo.identify(); // foo module
    foo.change();
    foo.identify(); // FOO MODULE

    通过在实例模块内部持有一个1指向公有API对象的内部引用,就可以从内部修改模块,包括删除和添加方法,属性以及改变它们的值。

  3. 现代的模块
    各种模块依赖加载器/消息机制实质上都是将这种模块定义包装进一个友好的API。考虑如下代码:

    var MyModules = (function Manager(){
        var modules = {};
        function define(name, deps, impl){
            for(var i=0; i<deps.length; i++){
                deps[i] = modules[deps[i]];
            }
            modules[name] = impl.apply(impl, deps);
        }
    
        function get(name){
            return modules[name];
        }
    
        return {
            define: define,
            get: get
        };
    })();

    modules[name] = impl.apply(impl, deps)为一个模块调用了它的定义的包装函数(传入所有依赖),并将返回值,也就是模块的API,存储到一个用名称追踪的内部模块列表。考虑在如下代码定义一个模块:

    MyModules.define("bar", [], function(){
        function hello(who){
            return "Let me introduce: "+who;
        }
        return {
            hello: hello
        };
    });
    
    MyModules.define("foo", ["bar"], function(bar){
        var hungry = "hippo";
    
        function awesome(){
            console.log(bar.hello(hungry).toUpperCase());
        }
    
        return {
            awesome: awesome
        };
    });
    
    var bar = MyModules.get("bar");
    var foo = MyModules.get("foo");
    
    console.log(
        bar.hello("hippo");
        // Let me introduce: hippo
    );
    
    foo.awesome(); // LET ME INTRODUCE: HIPPO

    如上述代码列出了模块模式的两个性质:调用一个函数定义包装器,并将它的返回值作为这个模块的API存储下来。

  4. 未来的模块
    ES6为模块增加了头等的语法支持。当通过模块系统加载时,ES6将一个文件视为一个独立的模块。每个模块可以导入其他的模块或者特定的API成员,也可以导出它们自己的公有API成员。
    注意:基于函数的模块不是一个可以被静态识别的模式(编译器可以知道的东西),所以它们的语义直到运行时才会被考虑,也就是可以在运行期间修改模块的API。
    相比之下,ES6模块API是静态的(这些API不会在运行时改变)。因为编译器知道它,它可以在(文件加载和)编译期间检查一个指向被导入模块的成员的引用是否实际存在。如果API应用不存在,编译器会在编译时抛出一个“早期”错误,而不是等待传统的动态运行时解决方案。
    ES6模块没有“内联”格式,它们必须被定义在一个分离的文件中(每个模块一个)。浏览器/引擎拥有一个默认的“模块加载器”(可被覆盖),它在模块被导入时同步地加载模块文件。考虑代码如下:

    // ============== bar.js ==============
    function hello(who){
        return "Let me introduce: " + who;
    }
    export hello;
    
    // ============== foo.js ==============
    // 仅仅导入“bar”模块中的`hello()`
    import hello from "bar"
    var hungry = "hippo";
    function awesome() {
        console.log(
            hello(hungry).toUpperCase();
        );
    }
    export awesome;
    
    // ============== 加载/导入模块 ==============
    // 导入`foo`和`bar`整个模块
    module foo from "foo";
    module bar from "bar";
    
    console.log(
        bar.hello("rhino")
    ); // Let me introduce: rhino
    foo.awesome(); // LET ME INTRODUCE: HIPPO

    注意上述代码分属三个文件。import在当前的作用域中导入了一个模块的API的一个或多个成员,每个都绑定到一个变量。module将整个模块地API导入到一个被绑定的变量(这个例子中是foobar)。export为当前模块的公有API导出一个标识符(变量,函数)。在一个模块的定义中,这些操作符可以根据需要使用任意多次。
    在模块文件内部的内容被视为像是包围在一个作用域闭包中,就像早先看到的使用函数闭包的模块那样。

动态作用域

词法作用域是在代码编写时被定义的(假定不进行欺骗),而动态作用域就是相对而言的,其作用域在运行时被确定,而不是编写时静态确定的。考虑如下代码:

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

动态作用域本身不关心函数和作用域是在哪里和如何被声明的,而是关心它们从何处被调用的。换言之,作用域链条基于调用栈的,而不是代码中作用域的嵌套。所以假如JavaScript有动态作用域,那么foo()应该沿着调用栈向上查找得到的a3。但是要清楚JavaScript实际上没有动态作用域,但是this机制有些像动态作用域。
关键的差异在于:词法作用域是编写时的,而动态作用域(和this)是运行时的。词法作用域关心的是函数在何处被声明,但是动态作用域关心的是函数从何处被调用。

词法this

ES6为函数声明增加了一种特殊的语法形式,称为“箭头函数”。考虑如下代码:

var foo = a => {
    console.log(a);
}
foo(2); // 2

“大箭头”是function关键字的缩写。但是实际上其功能并不止于此。简言之,这段代码有一个问题:

var obj = {
    id: "awesome";
    cool: function coolFn(){
        console.log(this.id);
    }
};
var id = "not awesome";
obj.cool(); // awesome
setTimeout(obj.cool, 100); // not awesome

这个问题就是在cool()函数上丢失this绑定,一个常用的解决方案就是var self = this;。这个“解决方案”免除了理解和正确使用this绑定的整个问题,而是退回到词法作用域上。self变成了一个可以通过词法作用域和闭包解析的标识符,而且一直不关心this绑定发生了什么。
但是这种重复的情景是不受程序员欢迎的,所以ES6提出的解决方案,就是箭头函数,引入了一种称为“词法this”的行为。

var obj = {
    count: 0,
    cool: function coolFn(){
        if(this.count < 1) {
            setTimeout(() => {
                this.count++;
                console.log("awesome?");
            }, 100);
        }
    }
}
obj.cool(); // awesome?

当箭头函数遇到它们的this绑定时,它们的行为与一般的函数根本不同,它们摒弃了this绑定的所有一般规则,而是将它们的立即外围词法作用域作为this的值,无论它是什么。
所以在这个代码段中,箭头函数不会以不可预知的方式丢掉this绑定,它只是“继承”cool()函数的this绑定。
但是箭头函数实际上混淆了“this绑定”规则与“词法作用域”规则。
所以更恰当的解决方式是正确地使用并接受this机制,使用bind()

var obj = {
    count: 0, 
    cool: function coolFn(){
        if(this.count<1){
            setTimeout(function timer(){
                this.count++; //`this`因为`bind(..)`所以安全
                console.log("more awesome");
            }.bind(this), 100);
        }
    }
};
obj.cool(); // more awesome

猜你喜欢

转载自blog.csdn.net/Jingle_cjy/article/details/78121194
今日推荐