深入学习JavaScript之函数作用域与块作用域

  我们将作用域比作气泡,一层嵌套一层,每一个气泡里面都可以放置标识符(函数,变量)的定义,这些气泡在书写阶段就已经确定了。

  但是,究竟是什么生成了一个新的气泡,只有函数能够生成气泡吗?JavaScipt中的其他结构能生成作用域气泡吗?

1.1  函数中的作用域

  对于前面的问题,最常见的答案是JavaScript具有基于函数的作用域,意味着每声明一个函数都会为自身创建一个气泡,其他结构都不会生成气泡,但是这也不完全正确。

  首先需要研究一个例子及其背后的一些内容

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

  按照我们之前的"气泡理论",我们可以这么理解,这段代码有三个气泡

  • 全局气泡------包含了foo函数以及所有的标识符
  • foo()气泡------foo()函数内部所有内容
  • bar()气泡------bar()函数内部所有内容

  bar、a、b、c都在foo()气泡中,这也就意味着,你在foo()外部是无法引用它们的,因为无论是RHS查找还是LHS查找都会从本层气泡出发,一层一层向外查找而不会向内。

如:

bar();
console.log(a,b,c);

会发生ReferenceError错误;因为bar、a、b、c都在foo()函数内,在外部无法调用。

  前面说过作用域就是一套用于引擎在当前作用域和其子作用域下查找标识符的规则。由此可得出函数作用域的规则。

 函数作用域是指在这个函数内部定义的标识符(变量和函数)能在整个函数的范围内使用及复用(事实上在嵌套的子作用域也可以使用)

由此可得出:

       函数作用域规则:外部函数定义的标识符,内部函数可用,内部函数定义的标识符,外部函数不可用

1.2  隐藏内部实现

  在我们定义函数时,我们是怎么做的?先创建函数再往里面添加代码,那么我们如果反过来呢,将代码的一部分拿出来,为它创建一个函数------实际上就是把它隐藏起来了。

  实际上就是为这段代码创建了一个作用域气泡,这段代码里面的函数和变量都不在之前的作用域中了,而是存在于新创建的作用域气泡中。然后根据之前的函数作用域规则。定义的内部函数把“内容代码”隐藏起来了。

  这有什么用呢?

1.2.1   最小特权原则

  在解释用途之前,我们先了解一个概念。最小特权原则(又名最小授权或最小暴露原则):在软件设计中,应该最小限度的暴露必要内容,而将其他部分“隐藏”起来,例如某个功能模块或者API设计。

  我们隐藏某段代码就是基于这个最小特权原则。如果所有的变量和函数都在全局作用域中,可以在内部作用域中访问到它们,但是!!!这样会破坏之前的最小特权原则,会暴露过多的变量和函数,而这些函数和变量应该是私有的,正确的代码是可以阻止这些变量和函数的访问的。

  举个例子

      function doSomething(a){
   b=a+doSomethingElse(a*2);

console.log(b*3);

        }
         function doSomethingElse(a){
            return a-1;
         }
       var b;
   doSomething(2);

    我们注意到,doSomethingElse函数以及b是在全局作用域中的,这不仅没用,而且增添了许多的危险(它们可能会被有意无意的以非预期的方式使用)从而导致超出了doSomething的使用范围。

    我们以最小特权原则来实现对这段代码的隐藏。

  function doSomething(a){
        var b;
      function doSomethingElse(a){
            return a-1;
                 }

   b=a+doSomethingElse(a*2);

console.log(b*3);
        }
     
      
   doSomething(2);

    现在b和doSomethingElse()都被私有化了无法从外部访问,只能被doSomething()控制。最终运行效果也没有受到影响,但是具体内容被私有化了。

1.2.2   规避冲突

  隐藏技术带来的另一个好处是规避同名的标识符之间的冲突,两个标识符可能名字相同但是用途不同,无意间可能会造成命名冲突,这会导致变量的值意外被覆盖。

举个例子:

  

      function  foo(){
        function bar(a){
            i=3;        //修改for中的i值
          console.log(a+i);

            }
      for(var i=0;i<10;i++){
          bar(i*2);         //每次传入的i都是3,陷入无限循环
    }

     }
 foo();

        bar(...)内部的赋值语句  i=3,意外的覆盖了声明在foo(...)内部循环中的i。在这个例子中将会导致无限循环。因为i一直等于3,小于循环结束条件10。

   bar(...)内部赋值操作需要声明本地的变量来使用。采用什么名字都可以,即使是i。var i=3。因为是在bar(...)的气泡中,与foo(...)气泡中的i不起冲突,从而避免了值被覆盖,(这时命名一个不是i的变量比如j也是可以的,但是有时就会出现必须要两个同名的标识符的情况)--------这其实也相当于遮蔽效应(词法分析域里有讲过)。

1.全局命名空间
  
变量的一个典型冲突是在全局命名空间上。当程序加载多了个第三方库时。如果没有妥善隐藏内部的函数以及变量,那么很容易发生冲突。

  这些库通常会在全局作用域中创建一个对象,所有暴露在外面的功能都将成为这个对象的属性。这个对象就代表了库的命名空间,它并不会将标识符暴露在最顶级的词法作用域中。

举个例子

var MyReallyCoolLibrary={

awe:"stuff";

doSomething: function(){.....}

doAnothing:  funciton {.....}

}

2.模块管理

  另一中规避冲突的办法与现代机制中的模块机制非常的接近,就是从众多模块管理器中挑选一个来使用,使用这些工具,任何库无需将标识符添加到全局作用域中,而是依赖管理器的机制,将库的标识符显式的导入另一个特定的作用域中。

  显而易见,这些工具并没有违反词法作用域的规则,它们利用了作用域的规则强制所有标识符都不能注入到共享的作用域中,而是存储在内部,自私,无冲突的作用域中,这样可以规避掉所有的意外冲突。

总结:隐藏机制源于最小特权原则,不让重要的代码(标识符)暴露在外部作用域中,利用函数作用域的访问规则隐藏代码。

     隐藏机制的作用还可以规避冲突,与之有相同功能的是全局命名空间以及模块管理工具。

1.3  函数作用域

  在上面我们了解了,在任意代码片段中添加包装函数,可以将内部的函数和变量隐藏起来,外部作用域无法访问包装函数的内部内容。

例如:

var a=2;
  function foo(){        //污染作用域

  var a=3;

 console.log(a);    //a=3

}
    foo();            //显式调用foo()
console.log(a);          //a=2

  虽然这种方法可以隐藏代码,提高安全性,但是它并不理想,会导致一些其他的问题,①我们将代码存放在foo()中,它会污染所在的作用域(在这里面是全局作用域)。②必须要对foo()进行显式调用。

   这时候我们就会思考,如果函数不需要创建函数名(至少不会污染所在的作用域)并且能够自动运行!!!这简直就是太棒了!!!

  正巧JavaScript中就存在一种机制,实现了这个功能。

例:

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

  这与之前的差别就在,function  foo(){...}被一个括号包了起来,以(funciotn......开始而不是funciton.....。这有什么区别呢?、

  区别就在于,当以(funtion....开头时,函数将会被当做函数表达式而不是函数声明

   

   区分函数声明与函数表达式的方法只需要看function的位置

    以(function开头-------函数表达式

    以funciton开头--------函数声明

函数声明与函数表达式最重要的区别在于它们的名称标识符绑定的地方不同。

  让我们来看看之前的那两个例子,

  第一个例子中的foo()将会被绑定在所在的作用域中,可以直接通过foo()来调用它。

  第二个例子中foo()被绑定在函数表达式自身的函数内而不是所在的作用域中,换句话说,便是(funciton foo(){....})中的foo只能被{....}进行访问,外部作用域则不行,foo变量名被隐藏在自身中意味着不会污染所在的作用域。

1.3.1  具名和匿名

  对于函数表达式,我们最熟悉的就是回调函数了

setTimeOut(function(){ 

console.log("hello word");

},1000);

  细心点你会发现在这里面没有函数名----这就是匿名函数表达式,在function().....中没有函数名(名称标识符)

   在JavaScript中;

函数表达式可以没有函数名

函数声明必须要具有函数名

  匿名函数表达式虽好,在一些工具或者库中也推广中简便易于编写的代码。但是它的缺点也不容忽视!!!

  ①匿名表达式在栈追踪中不会显示出有意义的函数名,使得调试困难

  ②没有函数名,当函数在调用自身时只能使用已经过期的arguments.callee引用(不建议使用arguments.callee,这在ES5中早已过时),比如在递归中,另一个函数需要调用自身的例子,是在事件触发后事件监听器需要解绑自身。

  ③匿名函数省略了许多对代码理解具有重要意义的函数名。

  那么这么解决上述的问题呢???答案是,为行内函数表达式指定一个函数名-----这就叫做行内表达式,之前的或许叫行内匿名表达式

setTimeOut(funtion foo(){      //添加foo()作为行内函数表达式的函数名称

console.log("my name is foo");

},1000);

1.3.2 立即执行函数表达式

var a=3;

(funciton foo(){       //以(function开始为函数表达式

      var a=2;

     console.log(a);

})();            //添加了括号,代表立即执行

console.log(a);

  由于函数被一堆括号包着----所以成了函数表达式,在函数表达式后添加()------立即执行函数表达式(IIFE)。这种模式很常见,人们还给它起了个名字IIFE。

  在解决函数声明带给我们的问题时,光靠一个函数表达式还是不够的,还要让它成为立即执行的函数表达式。

  当然了在IIFE中,函数名不是必须的,IIFE最常见的使用是匿名表达式。

  IIFE函数名形式:

var a=3;

(funciton IIFE(){       //以(function开始为函数表达式

      var a=2;

     console.log(a);

})();            //添加了括号,代表立即执行

console.log(a);

  相较于传统的IIFE模式,更多人喜欢另一个改进模式:(function foo(){...}())。第一种形式中函数表达式被包括在()中,然后在后面用另一个()括号来调用。第二种用来调用的括号移进了封装的括号中。这两种功能一致,用哪个看个人喜好

   IIFE还有一个进阶的用法,把它们当做函数调用并传递实参进去。

什么意思呢?我们通过一个例子来说明。

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

  我们通过引入一个全局对象(window)前面提到过,全局变量会自动成为全局对象的属性。我们在IIFE中传入一个全局对象(window),全局变量a为这个对象的属性,这个对象传入了IIFE中命名为"global",我们试着引用全局变量"a",通过global.a。成功了,这是一种内部函数访问全局变量的方式

IIFE还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE函数执行后当做参数传递进去。这种模式在UMD项目中被广泛使用。

var a=3;
(function IIFE(def){
        def(window);
   })(function def(global){     //需要执行的函数放置在(funtion IIEF(){...})()中的后面括号里。
          var a=2;   
          console.log(a);       //2
            console.log(global);     //3
          })

  函数表达式def定义在片段的第二段代码中,然后当做参数被传递到IIFE函数中。最后def被调用传入全局变量window。

总结:隐藏函数能够尽量使代码中重要部分不暴露出来,但是问题也很大,解决的方式是通过立即执行的函数表达式(IIFE)。

    funciton foo(){.....}在一个括号中------------------函数表达式

    函数表达式后面添加一个括号-----------------------立即执行

    两者相加----------------立即执行的函数表达式

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

   函数声明-----------------function foo(){....}不在()中,绑定的作用域在函数当前所在的作用域。

   函数表达式---------------function foo(){...}在()中,绑定的作用域在函数表达式内自身函数作用域

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

  函数表达式分为具名(函数有名称)和匿名(函数无名称),一般用在行内的叫行内表达式,具名函数表达式要比匿名函数表达式优点多。

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

立即执行函数表达式(IIFE)有两种格式:

第二部分在括号外的:(function foo(){...})();

第二部分在括号内的:(function foo(){...}());

立即执行函数表达式的作用:

  ①解决隐藏内部函数的缺点

  ②传递外部对象作为参数获得外部变量,外部对象在第二个()中

  ③倒置代码的运行顺序,将需要运行的函数放置在第二部分,作为参数传递给IIFE

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

1.4  块作用域

  我们学习了词法作用域,函数作用域。在其他比较理想化的编程语言中,还存在这一个块作用域机制。但是在JavaScript中我们就没有关于块作用机制,但是在JavaScript中存在着类似于块作用域的方法。

  for(var i=0;i<10;i++)

{.....}

我们在for的头部直接定义了i,从代码运行效果,我们会认为i只在for()中有效,但这是不对的,在for(...)中定义的i是在for(...)的外部函数中有效的

 (function IIFE(){
         for(var i=0;i<10;i++);
         console.log(i);      //10
     })()

  我们创建了一个for让i的值循环成10,然后输出i,在for()中定义的i变成了外部变量!!!我们通常只是想在for()中使用i,而不想在外部也使用,这就是块作用域解决的问题了--------------块作用域能让变量只在局部有用,

块作用域是之前最小授权原则的扩展工具------将代码在函数中的隐藏信息细化成函数块代码中的隐藏信息。

1.4.1   with

  with主要用于简写对象的属性引用,我们之前在深入理解JavaScript词法作用域中提到过,with(...)与eval(...)一样能够欺骗词法域在with中声明的变量会被绑定到with创建的作用域中

1.4.2   try/catch

    在ES3中定义的try/catch中也属于创建块作用域的一种语法,try/catch是JavaScript中错误的捕获及处理方式,在tyr中捕获到错误(存放可能发生错误的语句)由catch中的函数来处理它,不过遗憾的是,只有在catch(err){}中创建的err才属于try/catch作用域中存在的参数。

 try{
        undefined()
    }catch (err){
        var a=1;
        console.log(err);     //err
        console.log(a);       //1
    }
    console.log(err);      //ReferenceError: err is not foud
    console.log(a);         //1

  正如上代码所示:在try/catch中定义的err,在外部函数中引用发生错误,try/catch中定义的a在外部函数中引用正确。

  

1.4.3   let

    在JavaScript  ES6中新增了一个声明变量的关键字:let。let能够将声明的变量绑定到{....}块作用域中。换句话说let隐式的将变量绑定到了所在的块作用中,

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

      在上面我们在if这个块中用let创建了一个变量bar=2;在if块中输出结果正确,在if外输出错误。这是因为"let bar=2"将bar绑定在了If这一个块内,在块外对"bar"进行输出,自然会报错误。

  

  用let将变量添加到一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯地移动这些块或者将其包含在其他的块中,就会导致代码变得混乱

    比较好的解决方法是为块作用域显式地创建块,使得变量的附属关系变得清晰。通常来讲,显式的代码要优于隐式的代码或者一些不清晰的代码。显示的块作用域风格非常容易编写,并且和其他语言的块作用域原理一致

  if(true){
   {let bar=2;        //添加{  }将需要显示的块构建出来,代码简但明了,易于复用
    console.log(bar);     //2
    }
  console.log(bar);     //ReferenceError
}

    只要声明正确,我们可以在声明的任何部位用{...}来为let创建一个用于绑定的块!!!

   关于let还有一个很有趣的地方,let所声明的变量在块作用域内是没有提升的(提升是指声明被视为存在于其出现的作用域的整个范围内。)在let的块作用域内,在声明的代码(let)被运行之前,声明并不"存在"。

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

1.4.3.1垃圾收集

  另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制有关。而有关于内部原理和闭包的问题,我们现在还暂时学不到就不给予探讨了。

考虑以下代码:

  function process(data){
   .....   //在这里面对数据进行处理
   }

   var someReallyBigData={....};   //存储数据对象

  process(someReallyBigData);    //处理数据

   var btn=document.getElementByid("my_button");   //得到按键点击对象

//添加按钮监听器,对按钮行为进行监控,并处理

   btn.addEvenListener("click",function click(evt){
  console.log("button clicked");
   },/*capturingPhase=*/false);

   click函数的点击回调并不需要someReallyBigData变量。理论上这就意味着当process(...)执行了以后,,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是由于click函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能保留这个数据结构(取决于具体实现)。

  块作用域就可以解决这个问题,实现占据内存的数据结构的释放,让引擎清除这个数据结构。

   怎么实现呢??

  function process(data){
   .....   //在这里面对数据进行处理
   }
{    //添加了这一行
   let someReallyBigData={....};   //存储数据对象

  process(someReallyBigData);    //处理数据
}   //以及这一行
   var btn=document.getElementByid("my_button");   //得到按键点击对象

//添加按钮监听器,对按钮行为进行监控,并处理

   btn.addEvenListener("click",function click(evt){
  console.log("button clicked");
   },/*capturingPhase=*/false);

  将要实现的数据结构用let声明、处理,并绑定在一个显式的块级作用域中。

1.4.3.2  let循环

  继续我们之前提到的例子,for循环内的变量如何绑定在for这一个块内的?答案同样是通过let关键字给变量声明。

for(let i=0;i<3;i++)
{
  console.log(i);    //0、1、2
}
  console.log(i);     //ReferencEerror

  for循环中的let不仅将i绑定在for()块作用域中,还将i绑定到for()的每一次循环迭代中(也就是在每一次循环中都要声明一次变量,每一次声明的变量是上一次的值)

我们通过另一个代码来看看for()中的迭代绑定

{
   let j;
  
   for(j=0;j<3;j++)
  
    {
  
   let i=j;

console.log(i);   //0、1、2

      }     


}

   如上所示,我们在for中定义了一个let i只存在与for(...)中的变量  "i",来追踪块作用域中的  "j"  的变化,可以看到每次  "j"  的变化都在前一个  "j"  值的基础上+1。证明每次迭代使用的都是同一个  "j"

  1.4.3.3  使用let重构var

   在我们对代码进行重构时,经常容易犯的一个错误是忽略了  "var"  构建的作用域是函数作用域或者全局作用域,而  "let"  构建的作用域是当前新创建的作用域(不是当前函数作用域,也不是全局作用域)

例如:

 (function IIFE() {
            var bar=10;
           if(true){
               var baz=3;
               if(bar>baz){
                   console.log(bar);   //10
               }
           }
        })()

重构后:

(function IIFE() {
            var bar=10;
           if(true){
               var baz=3;
           }
            if(bar>baz){
                console.log(bar);
            }
        })()

使用 let 替换掉 var

(function IIFE() {
            var bar=10;
           if(true){
               let baz=3;
           }
            if(bar>baz){
                console.log(bar);
            }
        })()

  此处发生错误!!!因为之前在第一个  if()  中声明的是var i是全局变量中的i,当我们用 "let"  在  "if(...)"  中声明了一个块作用域变量  "i"

1.4.4  const

  除了let外,ES6中还引进了conts,同样可以用来创建块作用域,不过与let不同的是,它的值是固定的(常量)。之后任何对它进行更改的操作都会导致错误。

举个例子

 (function IIFE(){
            const b=1;         // b等于常量
            console.log(b);      //1
            b=1;           //TypeEroor
        })();
        console.log(b);      //ReferenceError

  如上我们可以看到在IIFE(...)我们创建了一个  conts  类型的常量  b=1;当尝试对  b  进行修改时,提示TypeError错误

  

总结:函数是JavaScript中最常见的作用域单元,在函数作用域,只能从内部访问外部的函数,外部函数不能访问内部函数,我们根据最小授权原则将重要代码标识符放进内部函数中,达到了隐藏代码的效果。

  比内部函数作用域更为标准化的是块作用域,它是存在于一个代码块的作用域,范围为:全局作用域>外部函数作用域>内部函数作用域>块作用域。

  实现块作用域的方法有:

  ①with(){...}:它将{...}内的变量创建在一个新的作用域中(不是函数作用域)

  ②try/catch:ES3开始在catch中的参数,也就是catch(...){...}中(...)的参数绑定在块作用域中

  ③let:ES6开始  let  声明变量(与var差不多),它可以将变量绑定到一个块作用域中,

if(true){let a=2;.....}-------let将变量声明并绑定在if(...)这一个块中。

  作用

  • 创建块作用域内可用的变量

  • 垃圾收集

  • let循环

​​​​​​ ​④conts:创建块作用域内的常量,常量不可修改,在块作用域内无效。

猜你喜欢

转载自blog.csdn.net/qq_41889956/article/details/83061472