函数作用域
每一个作用域都可以作为容器,其中包含了标识符(变量、函数)的定义。这些作用域互相嵌套并且整齐地排列成蜂窝型(没有交集),排列的结构在写代码时定义。
【前提知识】:
【疑问】:究竟是什么生成了一个作用域?只有函数会生成新的作用域吗?JavaScript 中的其他结构能生成作用域吗?
函数中的作用域
属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
【优点】:这种设计方案非常有用,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。
【注意】:如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意想不到的问题。
隐藏内部实现
【前言】:对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际上就是把这些代码“隐藏”起来。
根据前言可以推断出,开发者可以用函数将一些代码片段包装起来。实际结果就是在代码片段周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的作用域中。那么这些变量和函数外部就无法访问(除非内部主动暴露出来),从而实现了“代码隐藏”。
【问】:为什么“隐藏”变量和函数是一个有用的技术?
【答】:有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小特权原则,因为可能会暴露过多的变量或函数,而这些变量或函数本应该是私有的。正确的做法是要阻止对这些变量或函数进行访问。
【示例】:
function doSomething(a) {
b = a + doSomethingElse(a * 2);
console.log(b * 3);
}
function doSomethingElse(a) {
return a - 1;
}
var b;
doSomething(2); // 15
// 好的做法
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse(a);
console.log(b * 3);
}
doSomething(2); // 15
【解释】:之前的代码,变量 b 和函数 doSomethingElse() 放置在全局作用域中,可能被有意或无意地以非预期的方式使用。合理的设计会将这些私有的具体内容隐藏在 doSomething() 内部。
规避冲突
“隐藏”作用域中的变量和函数所带来的另一个好处:是可以避免同名标识符之间的冲突,两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致变量的值被意外覆盖。
【示例】:
function foo() {
function bar(a) {
i = 3;
console.log(a + i);
}
for(var i = 0; i < 10; i++) {
bar(i * 2);
}
}
foo();
【说明】:bar() 内部的赋值表达式 i = 3 意外地覆盖了声明在 foo() 内部 for 循环中的 i,因此在这个例子中将会导致无限循环。
【解决】:
- bar() 内部的赋值操作声明一个本地变量来使用,采用任何名字都可以,var i = 3; 也可以满足这个需求(“遮蔽变量”)。
- 将 for 循环中声明的 i 变量“隐藏”起来。
function foo() {
function bar(a) {
i = 3;
console.log(a + i);
}
(function() {
for(var i = 0; i < 10; i++) {
bar(i * 2);
}
})()
}
foo();
1. 全局命名空间
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
【示例】:
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
2. 模块管理
使用模块管理器工具。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样就可以有效规避所有的意外冲突。
函数作用域
通过在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
var a = 2;
function foo() { // 添加这一行
var a = 3;
console.log(a); // 3
} // 以及这一行
foo(); // 以及这一行
console.log(a); // 2
【额外的问题】:
- 必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域。
- 必须显式地通过函数名 foo() 调用这个函数才能运行其中的代码。
【解决的办法】:使用 IIFE。
匿名和具名
函数表达式和函数声明的区别规则:
- 当 js 解析器看到 function 是这一行代码中第一个词,function 被认为是声明。
- 当 function 作为语句的一部分出现的,都会是表达式。
【区别】:函数声明会将其函数名绑定在函数所在的作用域中。而函数表达式的函数名被绑定在函数表达式自身的函数中而不是所在的作用域中。换句话说,(function() {..}) 作为函数表达式,意味着函数只能在 .. 所代表的位置中被访问,外部作用域则不行。
【注意】:函数表达式可以匿名,而函数声明不可以是匿名,这在 JavaScript 的语法中是非法的。
【优点】:
- 书写简单快捷。
- 很多库和工具也倾向鼓励使用这种风格的代码。
【缺点】:
- 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数引用自身的例子是在事件触发后事件监听器需要解绑自身。
- 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
【建议】:使用行内函数表达式。
setTimeout(function timeoutHandler() {
console.log("I waited 1 second!");
}, 1000);
【解释】:匿名和具名之间的区别并不会对函数有任何影响。给函数表达式指定一个函数名可以有效地解决匿名函数表达式存在的缺点。因此给函数表达式命名是一个最佳实践。
立即执行函数表达式
在函数的外围包裹一对()括号,将其转换为函数表达式。通过在末尾加上另外一个()可以立即执行这个函数。也就是说第一对()将函数转变为函数表达式,第二对()执行该函数。
var a = 2;
(function foo() {
var a = 3;
console.log(a);
})();
console.log(a);
这种模式被称为 IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression)。函数名对 IIFE 不是必须的,最常见的用法是使用匿名函数表达式。虽然使用具名函数的 IIFE 不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值得推广的实践。
【形式】:功能上并无区别。
// 方式一
(function() {
// ...
})()
// 方式二
(function() {
// ...
}())
【进阶用法】:将 IIFE 当作函数调用并传递参数进去。
var a = 2;
(function IIFE(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window);
console.log(a); // 2
【其他用法】:
- 解决 undefined 标识符的默认值被错误覆盖导致的异常(不常见)。
(function IIFE(undefined) {
var a;
if(a === undefined) {
console.log("Undefined is safe here!");
}
})();
- 倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 被执行之后当作参数传递进去。
var a = 2;
(function IIFE(def) {
def(window);
})(function def(global) {
var a = 3;
console.log(a);
console.log(global.a);
});
小结
- 函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。