(一)函数作用域
1. 函数中的作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。
2. 隐藏内部实现
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容,还可以避免同名标识符之间的冲突。
规避冲突的方法:
(1) 全局命名空间
一些常使用的第三方库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
var MyReallyCoolLibrary = {
awesome: "stuff",
doSomething: function() {
// ...
},
doAnotherThing: function() {
// ...
}
};
(2) 模块管理
使用模块管理器工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
3. 函数作用域
(1) 函数声明与函数表达式
区分函数声明和表达式最简单的方法是看
function
关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果function
是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
简单的说,不以function
开头的函数语句就是函数表达式定义。
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
// 函数声明
function foo() {}
// 函数表达式
(function bar() {})
// 函数表达式
x = function hello() {}
if (x) {
// 函数表达式
function world() {}
}
// 函数声明
function a() {
// 函数声明
function b() {}
if (0) {
//函数表达式
function c() {}
}
}
(2) 匿名和具名
函数表达式可以是匿名的,而函数声明则不可以省略函数名。
- 匿名函数
setTimeout( function() { // 匿名函数表达式
console.log("I waited 1 second!");
}, 1000 );
//匿名函数表达式书写起来简单快捷,但也有几个缺点需要考虑
//1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
//2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee 引用,比如在递归中。
// 另一个函数需要引用自身的例子,是在事件触发后事件监听器要解绑自身。
//3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
- 具名函数
行内函数表达式非常强大且有用 —— 匿名和具名之间的区别并不会对这点有任何影响。始终给函数表达式命名是一个最佳实践。
setTimeout( function timeoutHandler() {
//给函数表达式指定一个函数名可以有效解决匿名函数表达式带来的问题
console.log( "I waited 1 second!" );
}, 1000 );
(3) 立即执行函数表达式
IIFE
,代表立即执行函数表达式(Immediately Invoked Function Expression
)。
- 函数名对
IIFE
当然不是必须的,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
以上代码中,将window
对象的引用传递进去,但将参数命名为global
;当然也可以从外部作用域传递任何你需要的东西,并将变量命名为任何你觉得合适的名字
-
IIFE
还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在IIFE
执行之后当作参数传递进去。这种模式在UMD
项目中被广泛使用。
var a = 2;
(function IIFE( def ) {
def( window );
})(function def( global ) {
var a = 3;
console.log( a ); // 3
console.log( global.a ); // 2
});
函数表达式def
定义在片段的第二部分,然后当作参数(这个参数也叫作def
)被传递进IIFE
函数定义的第一部分中。最后,参数def
(也就是传递进去的函数)被调用,并window
传入当作global
参数的值。
(二)块作用域
变量的声明应该距离使用的地方越近越好,并最大限度地本地化。
1. with
用with
从对象中创建出的作用域仅在with
声明中而非外部作用域中有效。有关with关键字的用法可查看《【你不知道的JavaScript】(一)作用域与词法作用域》
2. try/catch
try/catch
的catch
分句会创建一个块作用域,其中声明的变量仅在catch
内部有效。
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found
3. let
ES6
引入了新的let
关键字,提供了除var
以外的另一种变量声明方式;let
关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }
内部)。
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError
(1) 垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。
(2) let
循环
for (var j=0; j<10; j++) {
console.log( j );
}
console.log( j ); // 10
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError
// for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,
// 事实上它将其重新绑定到了循环的每一个迭代中,
// 确保使用上一个循环迭代结束时的值重新进行赋值。
4. const
除了
let
以外,ES6
还引入了const
,同样可以用来创建块作用域变量,但其值是固定(常量)。之后任何试图修改值的操作都会引起错误。
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在if 中的块作用域常量
a = 3; // 正常!
b = 4; // 错误!
}
console.log( a ); // 3
console.log( b ); // ReferenceError!
函数作用域和块作用域的行为是一样的,可以总结为:任何声明在某个作用域内的变量,都将附属于这个作用域。
(三)提升
1. 先有鸡还是先有蛋
正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
a1 = 2;
var a1;
console.log( a1 ); // 2
//↑上面代码会发生以下处理
var a1; // 编译
a = 21; // 执行
console.log( a1 );
console.log( a ); // undefined
var a = 2;
//↑上面代码会发生以下处理
var a; // 编译
console.log( a );
a = 2; // 留在原地等待执行
也就是说,先有蛋(声明)后有鸡(赋值);只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
2. 注意事项
(1) 每个作用域都会进行提升操作;函数内部的声明会被提升到该函数中的最上方,而不是整个程序的最上方;
foo();
function foo() {
console.log( a ); // undefined
var a = 2;
}
//↑上面代码跟下面等同
function foo() {
var a;
console.log( a ); // undefined
a = 2;
}
foo();
(2) 函数声明会被提升,但是函数表达式却不会被提升。
foo(); // 不是ReferenceError, 而是TypeError!
var foo = function bar() {
// ...
};
// ↑上面代码中的变量标识符`foo()`被提升并分配给所在作用域(在这里是全局作用域)
// 因此`foo()` 不会导致`ReferenceError`
// 但是`foo`此时并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)。
// `foo()`由于对`undefined`值进行函数调用而导致非法操作,因此抛出`TypeError` 异常。
即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用。
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};
这个代码片段经过提升后,实际上会被理解为以下形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
(3) 函数声明和变量声明都会被提升。函数优先 —— 函数会首先被提升,然后才是变量。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
↑以上代码片段会被引擎理解为如下形式:
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};
var foo
尽管出现在 function foo()...
的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。尽管重复的 var
声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
小总结
- 我们习惯将
var a = 2;
看作一个声明,而JavaScript
引擎将var a
和a = 2
当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。 - 无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。
- 形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。
- 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升,会留在原地等待执行。