JavaScript中的作用域

1. LHS 与 RHS

LHS 即找到变量容器本身,并对其进行赋值,RHS 即得到某某的值。

1
2
3
4
5
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2)

以上代码中的LHS查询有3处:c = ..a = 2(隐式变量分配)、b = ..

RHS查询有4处:foo(2..= aa .... b

LHS 和 RHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域,最后抵达全局作用域,无论找到或者没找到都将停止。

2. 关于作用域判断相关的异常

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。

3. 词法作用域

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问。

1
window.a

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

4. 函数作用域和块作用域

在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。

例如:

1
2
3
4
5
6
7
var a = 2;
function foo() { // <-- 添加这一行
var a = 3;
console.log( a ); // 3
} // <-- 以及这一行
foo(); // <-- 以及这一行
console.log( a ); // 2

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先,必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其中的代码。

匿名函数表达式

1
2
3
setTimeout( function() {
console.log("I waited 1 second!");
}, 1000 );

这叫作匿名函数表达式,因为 function().. 没有名称标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。

匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是它也有几个缺点需要考虑。

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:

1
2
3
setTimeout( function timeoutHandler() { // <-- 快看,我有名字了! 
console.log( "I waited 1 second!" );
}, 1000 );

立即执行函数表达式

1
2
3
4
5
var a = 2;
(function foo() { var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

5. 块作用域

除 JavaScript 外的很多编程语言都支持块作用域,因此其他语言的开发者对于相关的思维方式会很熟悉,但是对于主要使用 JavaScript 的开发者来说,这个概念会很陌生。

1
2
3
for (var i=0; i<10; i++) {
console.log( i );
}

我们在 for 循环的头部直接定义了变量 i,通常是因为只想在 for 循环内部的上下文中使用 i,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。

let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。

1
2
3
4
5
6
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar ); console.log( bar );
}
console.log( bar ); // ReferenceError

6. 提升

考虑以下代码:

1
2
3
a = 2;
var a;
console.log( a );

你认为 console.log(..) 声明会输出什么呢?

很多开发者会认为是 undefined,因为 var a 声明在 a = 2 之后,他们自然而然地认为变量被重新赋值了,因此会被赋予默认值 undefined。但是,真正的输出结果是 2

考虑另外一段代码:

1
2
console.log( a );
var a = 2;

鉴于上一个代码片段所表现出来的某种非自上而下的行为特点,你可能会认为这个代码片 段也会有同样的行为而输出 2。还有人可能会认为,由于变量 a 在使用前没有先进行声明,因此会抛出 ReferenceError 异常。

不幸的是两种猜测都是不对的。输出来的会是 undefined

编译器是如何工作的

当你看到 var a = 2; 时,可能会认为这是一个声明。但 JavaScript 实际上会将其看成两个 声明:var a;a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。

我们的第一个代码片段会以如下形式进行处理:

1
2
var a;
a = 2; console.log( a );

其中第一部分是编译,而第二部分是执行。

类似地,我们的第二个代码片段实际是按照以下流程处理的:

1
2
3
var a; 
console.log( a );
a = 2;

因此,打个比方,这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动到”了最上面。这个过程就叫作提升。

函数声明会被提升,但是函数表达式却不会被提升。

1
2
3
4
foo(); // 不是 ReferenceError, 而是 TypeError!
var foo = function bar() {
// ...
};

这段程序中的变量标识符 foo() 被提升并分配给所在作用域(在这里是全局作用域),因此 foo() 不会导致 ReferenceError。但是 foo 此时并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)。foo() 由于对 undefined值进行函数调用而导致非法操作, 因此抛出 TypeError 异常。

同时也要记住,即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:

1
2
3
4
5
foo(); // TypeError
bar(); //ReferenceError
var foo = function bar() {
// ...
};

这个代码片段经过提升后,实际上会被理解为以下形式:

1
2
3
4
5
6
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self... // ...
}

函数优先

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。

考虑以下代码:

1
2
3
4
5
6
7
8
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};

会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:

1
2
3
4
5
6
7
function foo() { 
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};

注意,var foo 尽管出现在 function foo()... 的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。

尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

1
2
3
4
5
6
7
8
9
10
11
12
13
foo(); // 3

function foo() {
console.log(1);
}

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

function foo() {
console.log(3);
}

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代 码暗示的那样可以被条件判断所控制:

1
2
3
4
5
6
7
8
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}

7. 作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

类似于如下代码:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2
1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 2;
function baz() {
console.log(a); // 2
}
bar(baz);
}

function bar(fn) {
fn();
}
1
2
3
4
5
6
7
8
9
10
11
12
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将 baz 分配给全局变量 }
function bar() {
fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

循环和闭包

要说明闭包,for 循环是最常见的例子。

1
2
3
4
5
for (var i=1; i<=5; i++) { 
setTimeout( function timer() {
console.log(i);
}, i*1000 );
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次 6。

延迟函数的回调会在循环结束时才执行。事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个 6 出来。

正确操作:

1
2
3
4
5
6
7
for (var i=1; i<=5; i++) { 
(function(j) {
setTimeout( function timer() {
console.log(j);
}, j*1000 );
})(i);
}

或者使用let

1
2
3
4
5
for (let i=1; i<=5; i++) { 
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

模块

1
2
3
4
5
6
7
8
9
10
function foo() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
}

正如在这段代码中所看到的,这里并没有明显的闭包,只有两个私有数据变量 somethinganother,以及 doSomething()doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是 foo() 的内部作用域。

接下来考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

这个模式在 JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。

模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

上一个示例代码中有一个叫作 CoolModule() 的独立的模块创建器,可以被调用任意多次, 每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的 改进来实现单例模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];

function doSomething() {
console.log( something );
}
function doAnother() {
console.log( another.join( " ! " ) );
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

模块模式另一个简单但强大的变化用法是,命名将要作为公共 API 返回的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var foo = (function CoolModule(id) { 
function change() {
// 修改公共 API
publicAPI.identify = identify2;
}

function identify1() {
console.log( id );
}

function identify2() {
console.log( id.toUpperCase() );
}

var publicAPI = {
change: change,
identify: identify1
};

return publicAPI;
})( "foo module" );

foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。

现代的模块机制

大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var MyModules = (function Manager() {
var modules = {};

function define(name, deps, impl) {
for (var i=0; i<deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply( impl, deps );
}

function get(name) {
return modules[name];
}

return {
define: define,
get: get
};
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管 理的模块列表中。

下面展示了如何使用它来定义模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
MyModules.define( "bar", [], function() { 
function hello(who) {
return "Let me introduce: " + who;
}

return {
hello: hello
};
} );

MyModules.define( "foo", ["bar"], function(bar) {
var hungry = "hippo";

function awesome() {
console.log( bar.hello( hungry ).toUpperCase() );
}

return {
awesome: awesome
};
} );

var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );

console.log(
bar.hello( "hippo" )
); // Let me introduce: hippo

foo.awesome(); // LET ME INTRODUCE: HIPPO

未来的模块机制

ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立 的模块来处理。每个模块都可以导入其他模块或特定的 API 成员,同样也可以导出自己的 API 成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
bar.js
function hello(who) {
return "Let me introduce: " + who;
}

export hello;

foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";

var hungry = "hippo";

function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}

export awesome;

baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo";
module bar from "bar";

console.log(
bar.hello( "rhino" )
); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

动态作用域和this

词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

考虑以下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
id: "awesome",
cool: function coolFn() {
console.log( this.id );
}
};

var id = "not awesome"

obj.cool(); // 酷

setTimeout( obj.cool, 100 ); // 不酷

问题在于 cool() 函数丢失了同 this 之间的绑定。解决这个问题有好几种办法,但最长用 的就是 var self = this;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var obj = {
count: 0,
cool: function coolFn() {
var self = this;

if (self.count < 1) {
setTimeout( function timer(){
self.count++;
console.log( "awesome?" );
}, 100 );
}
}
};

obj.cool(); // 酷吧?

ES6 中的箭头函数引入了一个叫作 this 词法的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = { 
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( () => { // 箭头函数是什么鬼东西?
this.count++;
console.log( "awesome?" );
}, 100 );
}
}
};

obj.cool(); // 很酷吧 ?

解决这个“问题”的另一个更合适的办法是正确使用和包含 this 机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var obj = { 
count: 0,
cool: function coolFn() {
if (this.count < 1) {
setTimeout( function timer(){
this.count++; // this 是安全的
// 因为 bind(..)
console.log( "more awesome" );
}.bind( this ), 100 ); // look, bind()!
}
}
};

obj.cool(); // 更酷了。