详解JavaScript作用域

最后更新于:2022-04-01 11:55:16

### 作用域 在深入学习JavaScript作用域之前,首先要了解一下,究竟什么是作用域。几乎所有的编程语言都有作用域的概念,简单的说,作用域就是变量与函数的可访问范围,即作用域控制着变量与函数的可见性和生命周期。 我们先了解一下JavaScript的工作原理,引擎,编译器,作用域三者是如何协同工作来完成javascript代码的执行的。 **引擎**:从头到尾负责整个JavaScript程序的编译及执行过程。 **编译器**:负责词法分析及代码生成 **作用域**:负责收集并维护由所有声明的变量组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些变量的访问权限。 我们看下最简单的var index = 10;了解一下引擎、编译器和作用域是如何协同工作的。     JS会将其看成是两个声明,第一个是定义声明:编译器在编译阶段执行。第二个是赋值声明:由引擎在运行时执行。 因此可以分解为: ~~~ var index; index = 10; ~~~ 首先遇到var index,"编译器"会询问"作用域":当前的作用域中是否有index,如果是,那么"编译器"会忽略这个声明,继续进行编译;如果否,那么它会要求“作用域”在当前的作用域声明一个新的变量,并命名为index. 然后,"引擎"处理index = 10时,首先会询问"作用域":当前的作用域中是否存在一个index的变量,如果是,那么引擎就会使用这个变量,如果否,那么"引擎"会继续查找该变量。如果"引擎"最终找到了index变量,那么就将10赋值给它,否则"引擎"就会抛出 一个异常(作用域链)。 总结一下变量赋值操作过程,即:首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时,引擎会在作用域中查找该变量,如果能够找到就对它赋值,否则就抛出异常。 此处需要注意的是:编译阶段是在当前的作用域中声明变量,而引擎查找时,是在整个作用域中查找该变量。 在上面的变量声明的执行时,我们提到了引擎在作用域中查找变量的问题,我们将此分为两种方式,一种为LHS查询,一种为RHS查询,在上面的例子中,引擎在执行index = 10时,进行的是LHS查询。 那么究竟何为LHS查询和RHS查询呢,简单的,当变量出现在左侧时,执行的是LHS查询(L可以认为是Left)。除了LHS查询,剩下的就是RHS查询。作进一步说明,LHS查询是企图找到变量的容器本身,即去寻找赋值操作的目标,而RHS查询是找赋值操作的源头,即获取变量的值。  举例说明: ~~~ index = 10; console.log(index); ~~~ 在执行index = 10时,引擎会进行LHS查询,去寻找index变量的容器,目的是找到赋值操作的目标。而在执行console.log(index)时,引擎会进行RHS查询,目的是去找到index的值。我们之所以进行执行的区分,是因为如果index变量没有声明的情况下,这两种查询方式的结果是完全不同的。 作用域嵌套引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找,直至到最外层的全局作用域链,不管最终是否找到了变量,查找过程到到此结束。 如下:在执行index = 15时,引擎首先在当前作用域fun函数中查找index变量,没有找到,那么继续向上查找,在par函数中也没有找到,那么继续向上一级查找,最后在全局作用域中找到了该index. ~~~ <script> var index = 10; function par(){ function fun(){ index = 15; } fun(); } par(); </script> ~~~ 当引擎进行RHS查询时,如果查询到作用域链的顶层(全局作用域)依旧未找到index变量,那么引擎就会抛出一个ReferenceError异常。 当引擎进行LHS查询,在全局作用域中也未能找到目标变量(本例中的index),在非严格模式下,会在全局作用域中创建一个该名称的变量。而在严格模式下,会同RHS查询一样,抛出一个ReferenceError异常。 作用域查找会在找到第一个匹配的标识符(变量)时停止,在多层嵌套作用域中可以定义同名的标识符,这也称之为"遮蔽效应",如上面的代码中,如果fun函数中定义了index变量,那么在fun中对index的赋值操作不会影响到全局变量中的index.因为作用域查找始终是从运行时所处的最内部的作用域开始,逐级向上查找,直到找到匹配的标识符为止。 词法作用域是由写代码时将变量和函数写在哪里决定的,而不是由其调用的位置决定,JS提供了两种机制修改词法作用域,即:width和eval,鉴于这两种机制都会导致性能的降低,在此不多作介绍,尽量避免使用即可。 初学者或多或少都会遇到一个问题:命名冲突。 命名冲突会导致变量的值被意外覆盖。而这并非是我们想看到的。那么如何规避冲突呢? 1.全局命名空间(类似于jQuery的实现) 如:我们在全局作用域重视声明了一个名字足够独特的变量,通常是一个变量,如下面的carousel_yve,这个对象被称为库的命名空间,所有需要暴露给外界的功能都会称为这个对象的属性(如:index、defaults、init),避免将自己的标识符暴露在顶级的词法作用域中。 ~~~ <script type = "text/javascript"> var carousel_yve = { index: 0, defaults: { width: "1200px", height: "500px"}, init: function(){ console.log(this.defaults); } } carousel_yve.init(); //Object {width: "1200px", height: "500px"} console.log(carousel_yve.index); //10 </script> ~~~ 2.模块模式 模块模式分为两种,一种是每次调用都会创建一个新的模块实例,另一种是单例模式,即只会创建一个实例。 如: ~~~ <script type = "text/javascript"> function carousel_yve(){ var index = 0; var defaults = {width: "1200px", height: "500px"}; function init(){ console.log(defaults); } function doSomething(){ console.log(index); } return { init: init, doSomething: doSomething } } var example = carousel_yve(); example.init(); //Object {width: "1200px", height: "500px"} example.doSomething(); //0 </script> ~~~ carousel_yve是一个函数,通过对它的调用来创建一个模块实例。每次调用都会生成一个实例。 我们再来看一下单例模式:     ~~~ <script type = "text/javascript"> var example = (function carousel_yve(){ var index = 0; var defaults = {width: "1200px", height: "500px"}; function init(){ console.log(defaults); } function doSomething(){ console.log(index); } return { init: init, doSomething: doSomething } })(); example.init(); //Object {width: "1200px", height: "500px"} example.doSomething(); //0 </script> ~~~ 我们将先前的模块函数换成了IIFE,即:立即调用。 模块模式需要具备两个条件: 1.必须有外部的封闭函数,该函数必须至少被调用一次。 2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。 鉴于本文的目的是研究javaScript的作用域问题,因此对于模块模式不再做更多的扩展说明。 关于IIFE,有时我们想对外隐藏时,也可以简单的使用此方式。通过这样的方式,避免污染所在的作用域。    ~~~ <script type = "text/javascript"> (function carousel_yve(){ var index = 10; var defaults = {width: "1200px", height: "500px;"}; var obj = document.getElementById("btn"); obj.addEventListener("click", function(){/*code*/}, false); //code…… })(); </script> ~~~ 包装函数的声明以(function开始,而不是以function开始,看起来区别很小,但是实际上却完全不一样,因为(function开始会当做函数表达式,而function开头是作为标准的函数声明。而函数声明和函数表达式最重要的区别在于它们的名称标识符被绑定在何处。上面的代码中carousel_yve被绑定在自身的函数中,而不是所在的作用域中,其只能在自身函数的内部被访问。 此外,对于匿名函数和具名函数还要做一点说明。 JavaScript中,函数表达式允许匿名,但是函数声明不允许省略函数名,即不允许匿名。尽管匿名函数使用起来简单快捷,但是匿名函数的几个缺点需要考虑: 1.匿名函数在栈追踪中不会显示出有意义的函数名,使得调试困难。 2.如果没有函数名,当函数需要调用自身时,只能使用过期的arguments.callee引用,比如在递归中。 3.匿名函数省略了对于代码可读性/可理解性很重要的函数名,一个描述性的名称可以防止代码不言自明。(代码即注释) 匿名或具名并不会影响函数的功能,因此始终给函数表达式命名是值得推崇的。如我们经常使用的setTimeout、setInterval中。给回调函数命一个形象的名字将使代码的可读性更强。   ### 提升 我们前面说过JavaScript运行代码,分为两步,第一步:编译,第二步:执行。 在编译阶段,我们首先会提升变量声明。举例说明:     ~~~ <script type = "text/javascript"> console.log(a); var a = 2; </script> ~~~ 这儿是会抛出reference error呢还是Undefined呢?结果是Undefined,原因是因为,作用域会进行提升操作,上面这段代码实际上的处理流程是下面这样子的。 ~~~ var a; console.log(a); a = 2; ~~~ 在执行console.log(a)时,a已经被声明,仅仅是未赋值,因此结果是undefined,而非是抛出reference error. 想想,如果是下面这个样子的呢。结果又是什么?     ~~~ <script type = "text/javascript"> console.log(a); a = 2; </script> ~~~ 这个地方输出的又是什么呢?结果是reference error,想一想原因是什么。其实很好理解,这段代码中,有对a的赋值,但是并没有去声明a,尽管在执行赋值时,引擎查在作用域中找不到a,会在全局的作用域中创建一个a,但是因为a没有声明,所以在编译时,不会被提升,在执行console.log(a)时,进行的是RHS查询,在顶级作用域中查找不到a,抛出reference error的异常。 正因为这些差别,无论是全局作用域中,还是局部作用域中,希望大家都是使用var 去定义变量,而不是省略var,还口口声声说javascript是弱语言,有没有var都一样。很明显,有很多区别,在函数作用域里不使用var很有可能会无意中改变了全局变量的值。 关于提升,还有一点需要说明的是:**变量声明和函数声明都会被提升,但是函数优先 **举例说明** ~~~ <script type = "text/javascript"> example(); var example = function(){ console.log(10); }; function example(){ console.log(20); } </script> ~~~ 此处的输出结果是20,而不是10。原因就是因为函数优先,上面的代码,实际的顺序为:     ~~~ function example(){ console.log(20); } example(); example = function(){ console.log(10); }; ~~~ 函数声明首先被提升,即function example()会被提升到第一步,第二步是var example,但是因为作用域中已经有了example声明,属于重复声明,被忽略。因此引擎正在理解的代码如上所示,这就是为什么输出的是20,而并非是10. 值得注意的是: var的重复声明会被忽略,但是函数的重复声明会覆盖,如下:     ~~~ <script type = "text/javascript"> example(); var example = function(){ console.log(10); }; function example(){ console.log(20); }; function example(){ console.log(30) } </script> ~~~ 输出的结果是30,而不是20,因此请记住这个结论: 函数声明和变量声明都会被提升,但是首先是函数被提升,然后才是变量,重复的var声明会被忽略,但是重复的函数声明会覆盖。 另外一个需要注意的地方是:尽可能避免再块内部声明函数,至于为何这样说,我们来看一个例子: ~~~ <script type = "text/javascript"> var a = true; if(a == true){ function example(){ console.log(10); }; }else{ function example(){ console.log(20); }} example(); ~~~ 你的本意是想当a为true的时候,输出10,而a为false时,输出20;很遗憾的是并非是你想的那样,对于这个例子,火狐的输出结果是10,而谷歌是20。显然,引擎对其的处理有所不同。对此,建议您不要这样使用。 最后再简单说下闭包的问题,关于闭包,之前已经写过一篇博文介绍过,这里再说明一次。 闭包是指有权访问另一个函数作用域中的变量的函数。创建闭包的常见方式,就是在一个函数内部创建另一个函数。 说一个最典型的问题,下面这段代码是今天一个初学JS的朋友问我的,相信很多初学者都有遇到过这个问题。    ~~~ <html lang="en"> <head> <meta charset="UTF-8" /> <title>Document</title> <style> #ullist li { display: block;width: 40px; height: 40px; border:1px solid #ccc; text-align: center; line-height: 40px; cursor: pointer; float: left; margin:10px;} </style> </head> <body> <ul id="ullist"> <li id="li1">1</li> <li id="li2">2</li> <li id="li3">3</li> <li id="li4">4</li> <li id="li5">5</li> </ul> </body> <script> window.onload=function(){ var ullist=document.getElementById("ullist"); var listE=document.getElementsByTagName("li"); for (var i=0; i<listE.length; i++){ listE[i].onclick = function(i){ alert(listE[i].innerHTML); }; }; } </script> </html> ~~~ 很显然,他的目的是点击每一个li时,弹出对应的内容,但是结果却并非如此,并且控制台中还会报错。这是为什么呢?事实上,当你点击时,i的值已经变成了listE.length;而listE[listE.length]是不存在的。JS中for并非是一个块级作用域,因此i其实是定义在外部的一个变量,for中的函数共用同一个i. 我们将JS的代码改一改,就可以得到我们想要的结果,如下: ~~~ <script type = "text/javascript"> window.onload=function(){ var ullist=document.getElementById("ullist"); var listE=document.getElementsByTagName("li"); for (var i=0; i<listE.length; i++){ listE[i].onclick = (function(i){ return function(){ alert(listE[i].innerHTML); } })(i); }; }; </script> ~~~ 除了这个方法以外,还可以使用ES6中的let声明for中的i,但是这需要支持ES6的浏览器。 更多关于闭包的内容可以查看本人先前的博客《JS闭包与变量》。 此篇博文花费时间较长,如果能为您更一步理解JS作用域提供了一点点的帮助,也是值得的。          
';