ShiningDan的博客

作用域和闭包

在本文中,记录着我对于作用域和闭包的一些总结,其中很多的部分是对于 《You Don’t Know JS》中提到的知识点的梳理和精华内容的提取。当然,最推荐大家亲自来阅读这本书,体会其中的奥秘。

理解什么是作用域

JS 代码的运行过程

尽管 JavaScript 一般被划分到 动态 或者 解释型 语言的范畴,但是其实它是一个编译型语言。不过得益于 JIT( Just-in-time) 的工作原理,使得 JavaScript 在编译的时候能够以很快的速度运行,具体 JIT 的解释,我们可以参考这篇文章:WebAssembly 系列(二)JavaScript Just-in-time (JIT) 工作原理

在理解了 JavaScript 代码在运行前需要编译之后,我们可以开始对 JavaScript 代码的运行机制中 作用域 的产生和特点来进行分析了:

为了更简单地理解 JavaScript 的编译和运行机制,我们使用一个简单的例子:var a = 2; 来进行演示:

在理解代码运行的时候,我们会接触下面三个概念:

  1. 引擎:负责从始至终的编译和执行 JavaScript 程序。
  2. 编译器:负责语法分析和代码生成。
  3. 作用域:收集并维护一张所有被声明的标识符(变量)的列表,并且确定当前代码对这些标识符的访问权限。

下面我们来看这三个部分在运行 var a = 2; 这段代码的时候是怎么配合的:

当你看到程序 var a = 2; 时,引擎 看到两个不同的语句,一个是 编译器 将在编译期间处理,一个是 引擎 将在执行期间处理的。

编译器 将会这样处理:

  1. 遇到 var a编译器作用域 去查看作用域集合,变量 a 是否已经存在。如果存在,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去声明一个称为 a 的新变量。
  2. 然后 编译器引擎 生成稍后要执行的代码,来处理赋值 a = 2引擎 运行时首先让 作用域 去查看在当前的作用域集合中是否存在 a 的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看嵌套作用域
  3. 如果 引擎 最终找到一个变量,它就将值 2 赋予它。如果没有,引擎 就报出一个错误。
1
2
3
4
5
6
7
注意:在严格模式和宽松模式中,对于找不到变量的处理有所不同

在严格模式中,在给变量赋值的时候,禁止自动或者隐式创建全局变量,而是返回一个 ReferenceErrot 异常。

再注意:ReferenceError 和 TypeError 的区别:

ReferenceError 是关于 作用域 解析失败,而 TypeError 表示着 作用域 解析成功了,但是对这个结果进行的动作是非法的。

总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量 a(如果先前没有在当前作用域中声明过),第二,当执行时,如果找到 a 的话,引擎作用域 中查询这个变量并给它赋值。

所以,我们认为,作用域是根据变量名称查找变量的一套规则

作用域嵌套

一个代码块或函数被嵌套在另一个代码块或函数中 时,就发生了作用域被嵌套。如果在直接作用域中找不到一个变量的话,引擎 就会咨询外层作用域,如此直到找到这个变量或者到达最外层作用域(也就是全局作用域)。

词法作用域

概念:词法作用域是在词法分析时被定义的作用域。就是在写程序时,变量和作用域的块儿写在哪决定的,并且 大多数 时候词法分析器处理代码之后,变量的作用域是不变的。

变量的查找

作用域查找会在找到第一个匹配的标识符时停止。所以在多层嵌套的时候,定义相同名字的标识符可以产生 遮蔽

改变词法作用域

我们在上面提到了,大多数 时候词法分析器处理代码之后,变量的作用域是不变的。当然,这里说的是大多数情况,因为我们可以使用代码在运行时 修改 词法作用域。

1
2
3
4
5
注意:欺骗词法作用域会导致性能下降

JavaScript 引擎 在编译阶段期行许多性能优化工作。其中的一些优化原理都归结为实质上在进行词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以快速找到标识符。

如果 eval(..) 或 with 出现,那么几乎所有的优化都会变得没有意义,所以它就会简单地根本不做任何优化。

eval

我们可以看一个例子:

1
eval("var a = 2;");

引擎在运行这段代码的时候,会将 var a = 2; 这段代码本来就在那里一样来处理,导致当前作用域中又创建了一个变量 a,修改了已经存在的词法作用域。

1
2
3
4
5
6
7
8
function foo(str, a) {
eval( str ); // 作弊!
console.log( a, b );
}

var b = 2;

foo( "var b = 3;", 1 ); // 1 3

可以看到,当前词法作用域中创建了一个新的变量 b遮蔽 了外层的 b

1
注意:eval(...) 通常被用在执行动态创建的代码,比如,编程联系的时候,用户添加的一部分功能的实现 :)
1
再注意,在严格模式下,eval(...) 在运行的时候有自己的词法作用域,所以在其中的声明不会修改所在的作用域。

with

1
注意:with 语句在

为了显示 with 的作用,我们可以看一下以下的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function foo(obj) {
with (obj) {
a = 2;
}
}

var o1 = {
a: 3
};

var o2 = {
b: 3
};

foo( o1 );
console.log( o1.a ); // 2

foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作用域被泄漏了!

with 语句的作用是:将一个对象视为一个词法作用域。

with 内部正常的 var 声明不会在这个块的作用域(对象)中声明变量,而是在 with 所处的函数函数作用域中添加对象,如果是 a = 2,则隐式创建一个全局的对象,太可怕了!

提升

我们听说过 JS 中的变量声明提升和函数声明提升,从而导致代码产生了偏离本意的结果。那么,为什么会产生这种结果呢?这是由于 编译器的编译过程和引擎的执行过程是两个阶段而导致的。

考虑一个代码段:

1
2
3
console.log( a );

var a = 2;

当我们看到 var a = 2; 时,我们可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a;a = 2;。第一个语句是在 编译 阶段被处理的。第二个语句是在 执行 阶段运行的。

所以我们的代码是按照以下的流程处理的:

1
2
3
4
var a;

console.log( a );
a = 2;

这个过程就像变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端,这就产生了“提升”这个名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注意:每个作用域都会进行提升操作,作用域内声明的变量会被提升到该作用域的顶端

function foo() {
console.log( a ); // undefined

var a = 2;
}

实际上是:

function foo() {
var a;

console.log( a ); // undefined

a = 2;
}

函数表达式不会提升,只有函数声明才会被提升

函数会首先被提升,然后才是变量。

函数作用域和块作用域

什么是函数作用域和块作用域,我就不再赘述了,这里我想要再提及的,是 ES6 中 let 关键字。

let 关键字相比于 var 关键字,有一些额外的功能,比如:

  1. 不存在变量提升
  2. 暂时性死区
  3. 不允许重复声明

我们这里谈论的是,let 关键字在作用域方面的影响,通过以下的例子展现:

let 关键字将变量绑定在所在的任意作用域中。

所以我们可以看以下的例子:

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

可以表示成为:

1
2
3
4
5
6
7
if (foo) {
{ // <-- 明确的块儿
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
}

let 循环

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

i 绑定在了 for 循环的作用域中,而且将 i 绑定在了循环的每一个迭代中。

1
2
3
4
5
6
7
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每次迭代都重新绑定
console.log( i );
}
}

作用域闭包

闭包的定义:一个函数即使被传递在定义自己的词法作用域之外,也保持着定义自己的词法作用域的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;

function bar() {
console.log( a );
}

return bar;
}

var baz = foo();

baz();

在这个例子中,bar 被传递到了定义自己的词法作用域之外执行,但是它依然可以找到定义自己的词法作用域中的变量。

循环和闭包

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

虽然所有这5个函数在各个循环迭代中分别定义的,但是它们 都封闭在一个共享的全局作用域上,事实上只有一个 i

所以,我们有以下几种方法来解决这个问题:

模拟块级作用域

IIFE 会通过声明并立即执行一个函数来创建一个作用域

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

创建块级作用域

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
11
{
let i;
for (i=1; i<=5; i++) {
{
let j = i;
setTimeout( function timer(){
console.log( j );
}, i*1000 );
}
}
}

模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function CoolModule(id) {
function identify() {
console.log( id );
}

return {
identify: identify
};
}

var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );

foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

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

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

严格与非严格模式相关

这里记录的是《作用域与闭包》中严格模式和非严格模式中的一些区别,详细的区别点在文章中都可以找到解释:

  1. 在严格模式中,在给变量赋值的时候 a = 2; ,禁止自动或者隐式创建全局变量,而是返回一个 ReferenceErrot 异常。在非严格模式下,如果在赋值的时候没有找到该变量,会隐式创建一个全局变量
  2. 函数必须先声明再使用(不能隐式创建一个全局变量)
  3. 严格模式下,eval(...) 在运行的时候有自己的词法作用域,所以在其中的声明不会修改所在的作用域。在非严格模式下,eval(...) 函数创建的变量可能会影响到所在的作用域
  4. 严格模式下 不能 使用 with 语句。

参考

You-Dont-Know-JS