本篇内容
  • 作用域介绍
  • 词法作用域
  • 函数作用域和块作用域

重温《你不知道的JS》—2-作用域提升和闭包

1 作用域提升

1
2
3
4
5
6
7
8
// 情况1
a = 2;
var a;
console.log(a);

// 情况2
console.log(b);
var b = 3;

包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

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

每个作用域都会进行提升操作。

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

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

1.1 函数优先

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

如:

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

作用域闭包

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

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 —— 朋友,这就是闭包的效果。

函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作
一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。在 foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实
际上只是通过不同的标识符引用调用了内部的函数 bar()。
bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方
执行。
在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃
圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很
自然地会考虑对其进行回收。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此
没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。
bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一
直存活,以供 bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包!
}
1
2
3
4
5
6
function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );

将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..) 作用域
的闭包,因此还保有对变量 message 的引用。
wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..)
作用域的闭包。
深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个
参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是
内部的 timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包。

玩笑开完了,本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一
级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、
Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使
用了回调函数,实际上就是在使用闭包!

*IIFE

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

虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中
的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而
外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发
现的。
尽管 IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建
可以被封闭起来的闭包的工具。因此 IIFE 的确同闭包息息相关,即使本身并不会真的使用
闭包。

循环和闭包

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

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

由于很多开发者对闭包的概念认识得并不是很清楚,因此当循环内部包含函数定义时,代码格式检查器经常发出警告。我们在这里介绍如何才能正确地使用闭包并发挥它的威力,但是代码格式检查器并没有那么灵敏,它会假设你并不真正了解自己在做什么,所以无论如何都会发出警告。

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是
根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,
但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
这样说的话,当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的
机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,
那它同这段代码是完全等价的。

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


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 );
}

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的
作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

let

仔细思考我们对前面的解决方案的分析。我们使用 IIFE 在每次迭代时都创建一个新的作用
域。换句话说,每次迭代我们都需要一个块作用域。第 3 章介绍了 let 声明,可以用来劫
持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码
就可以正常运行了:

1
2
3
4
5
6
for (var i = 1; i <= 5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}

for 循环头部的 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( " ! " ) );
}
}

这里并没有明显的闭包,只有两个私有数据变量 something
和 another,以及 doSomething() 和 doAnother() 两个内部函数,它们的词法作用域(而这
就是闭包)也就是 foo() 的内部作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。

我们仔细研究一下这些代码。
首先,CoolModule() 只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行
外部函数,内部作用域和闭包都无法被创建。
其次,CoolModule() 返回一个用对象字面量语法 { key: value, … } 来表示的对象。这
个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐
藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共 API。
这个对象类型的返回值最终被赋值给外部的变量 foo,然后就可以通过它来访问 API 中的
属性方法,比如 foo.doSomething()。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery 就是一个很好的例子。jQuery 和 $ 标识符就是 jQuery 模块的公共 API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)。

doSomething() 和 doAnother() 函数具有涵盖模块实例内部作用域的闭包(通过调用
CoolModule() 实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作
用域外部时,我们已经创造了可以观察和实践闭包的条件。
如果要更简单的描述,模块模式需要具备两个必要条件。

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

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用
所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。
上一个示例代码中有一个叫作 CoolModule() 的独立的模块创建器,可以被调用任意多次,
每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的
改进来实现单例模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
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
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
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

“foo” 和 “bar” 模块都是通过一个返回公共 API 的函数来定义的。”foo” 甚至接受 “bar” 的
示例作为依赖参数,并能相应地使用它。
为我们自己着想,应该多花一点时间来研究这些示例代码并完全理解闭包的作用吧。最重
要的是要理解模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个
特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。
换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

未来的模块机制

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

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的 API(参考前面关于公共 API 的讨论)。相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。由于编辑器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否真实存在。如果 API 引用并不存在,编译器会在运行时抛出一个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

ES6 的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览
器或引擎有一个默认的“模块加载器”(可以被重载,但这远超出了我们的讨论范围)可
以在导入模块时异步地加载模块文件。
考虑以下代码:

1
2
3
4
5
// bar.js
function hello(who) {
return "Let me introduce: " + who;
}
export hello;
1
2
3
4
5
6
7
8
9
10
// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(
hello( hungry ).toUpperCase()
);
}
export awesome;
1
2
3
4
5
6
7
8
// 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

需要用前面两个代码片段中的内容分别创建文件 foo.js 和 bar.js。然后如第三个代码片段中展示的那样,bar.js 中的程序会加载或导入这两个模块并使用它们。

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量
上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在
我们的例子里是 foo 和 bar)。export 会将当前模块的一个标识符(变量、函数)导出为公
共 API。这些操作可以在模块定义中根据需要使用任意多次。
模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭
包模块一样。


(完)