对于前面提出的问题,最常见的答案是JavaScript具有基于函数的作用域,意味着每声明一个函数都会为其自身创建一个气泡,而其他结构都不会创建作用域气泡。但事实上这并不完全正确,下面我们来看一下。大家还可以关注我的微信公众号,蜗牛全栈。
首先需要研究一下函数作用域机器背后的一些内容。
考虑下面的代码:
function foo(a){
var b = 2;
function bar(){
}
var c = 3;
}
在这个代码片段中,foo的作用域气泡中包含了标识符a、b、c和bar【其中a在函数foo的参数内】。无论标识符声明出现在作用域中的何处,这个标识符所代表的变量或函数都将附属于所处作用域的气泡。我们将在后续的文章讨论具体的原理。
Bar拥有自己的作用域气泡。全局作用域也有自己的作用域气泡,它只包含了一个标识符:foo。
由于标识符a、b、c和bar都附属于foo的作用域气泡,因此无法从foo的外部对他们进行访问。也就是说,这些标识符全都无法从全局作用域中进行访问,因此下面的代码会导致ReferenceError错误:
bar(); // 失败
console.log(a, b, c); // 三个全都失败
【调用bar的时候,因为在全局作用域中也没有找到,所以出现的错误是ReferenceError,,而不是TypeError】
但是,这些标识符(a、b、c、foo和bar)在foo内部都是可以被访问的,同样在bar内部也可以被访问(假设bar内部没有同名的标识符声明)【如果有同名的标识符声明,会出现之前说的遮蔽效应】
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(实际上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用JavaScript变量可以根据需要改变值类型的“动态”特性。
但与此同时,如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意想不到的问题。
隐藏内部的实现
对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想可以带来一些启示:从所写的代码中挑选任意的一个片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来了。
实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或者函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域来“隐藏”他们。【就相当于在原来的集体圈一个自己的小集体出来,只能一部分和外界沟通,至于怎么沟通,由这个小集体内部自己决定】
为什么“隐藏”变量和函数是一个有用的技术?
有很多原因促成了这种基于作用域的隐藏方法。他们大都是从最小特权中引申出来的,也叫最小授权或最小暴露原则。【这个应该是防止暴露的太多,会出现作用域的问题,就像之前提到的with关键字用法实例】这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的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的“访问权限”不仅没有必要,而且可能是“危险”的,因为他们可能被有意或无意地以非预期的方式使用,从而导致超出了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
现在,b和doSomethingElse都无法从外部被访问,而只能被doSomething控制。功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会以此进行实现。【这个在项目重构上,也会有一席之地】
避免冲突
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,【这个小编想到了同名的变量和函数】,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量值被意外覆盖。
例如:
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,永远满足小于10这个条件。
Bar内部的赋值操作需要声明一个本地变量来使用,采用任何名字都可以,var i=3;就可以满足这个需求(同时会为i声明一个前面提到过的“遮蔽变量”)。另外一种方法是采用一个完全不同的标识符名称,比如var j=3;。但是软件设计在某种情况下可能自然而然地要求使用同样的标识符名称,因此在这种情况下使用作用域来“隐藏”内部声明是唯一的最佳选择。【一定程度上也为重构提供了更多要注意的事项和方案】
全局命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果他们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象,这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
例如:
var MyReallyCoolLibrary = {
awesome: ’stuff’,
doSomething: function(){
},
doAnotherThing: function(){
}
}
模块管理
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地引入到另外一个特定的作用域中。
显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只能利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。【就相当于每个模块都在自己的小盒子里,大家互不干扰】
因此,只要你愿意,即使不适用任何依赖管理工具也可以实现相同的功效。