你不知道的JS总结-作用域和闭包(二)

关于你不知道的JS总结-作用域和闭包的第二部分。

第 4 章 提升

先有鸡还是先有蛋

关于JS的代码执行顺序,直觉上是由上到下一行一行执行的。实际上并不完全正确,考虑下下面的代码

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

你认为console.log( a );声明会输出什么呢?很多人会认为是undefined,其实真实的输出结果是2。而

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

这段代码输出结果又是ReferenceError异常。

按照第一章的编译器顺序,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

所以看到的var a = 2;其实是var a;a = 2;两个声明。代码的编译过程是如下:

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

// 加上顺序,流程如下:
var a;
console.log(a);
a = 2;

从上面的例子可以看出,是现有声明,再有赋值。这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面。这个过程就叫作提升。

只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码执行的顺序,会造成非常严重的破坏。

关于函数的声明

首先看一段代码:

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

foo 函数的声明(这个例子还包括实际函数的隐含值)被提升了,因此第一行中的调用可以正常执行。

正确的执行顺序:

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

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

下面看一段函数表达式的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
};

// 经过提升
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
3
4
5
6
7
function foo() {
console.log( 1 );
}
foo(); // 1
foo = function() {
console.log( 2 );
};

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

而且,后面的函数声明会覆盖前面的声明。

1
2
3
4
5
6
7
8
9
10
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"); }
}

总结

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var aa = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。

要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!

第 5 章 作用域闭包

启示

闭包无处不在,你所需要的只是理解它

实质问题

闭包的定义:

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

下面这段代码的定义:

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

上面的代码中,基于词法作用域,函数bar()可以访问外部作用域中的变量a(利用RHS引用调用)。这里的词法作用域的利用,是闭包的一部分,也是最重要的一部分。

从纯学术的角度说,函数 bar() 具有一个涵盖 foo() 作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域))。也可以认为 bar() 被封闭在了 foo() 的作用域中。为什么呢?原因简单明了,因为 bar() 嵌套在 foo() 内部。

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() 依然持有对该作用域的引用,而这个引用就叫作闭包。

因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量 a。

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

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

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
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模式,因为函数(示例代码中的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行。这是是创建了一个闭包但是并不能调用。

循环和闭包

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

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

我们预期的效果是:分别输出数字 1~5,每秒一次,每次一个。

得到的结果是,每秒一次的频率输出五次 6。

首先我们看一下6是怎么来的。这个例子中的终止条件是i<=5。所以首次成立时的i的值是6,因此,输出显示的是循环结束时 i 的最终值。

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

缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。

我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

首先试下IIFE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
} // 这样的写法不行,为什么呢?因为每个IIFE中i的值并没有定义,也就是说这个作用于是空的

// 改进
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}

重返块作用域

我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。第 3 章介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:

1
2
3
4
5
6
7
8
9
10
11
12
for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}
// for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

模块

还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块

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

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

小结

闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的词法环境中书写代码的。

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

如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有用的事!

附录 A 动态作用域

JS中作用域就是词法作用域,(事实上大部分语言都是基于词法作用域的)。

而JS中,this的机制,类似于动态作用域。

词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用eval() 或 with)。而动态作用域,是一个运行时被被动确定状态的形式。

例子:

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

词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。

而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

因此,如果 JavaScript 具有动态作用域,理论上,上面代码中的 foo() 在执行时将会输出 3。

为什么会这样?因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地方查找 a,而不是在嵌套的词法作用域链中向上查找。由于 foo() 是在 bar() 中调用的,引擎会检查 bar() 的作用域,并在其中找到值为 3 的变量 a

附录 B 块作用域的替代方案

第 3 章深入研究了块作用域。至少从 ES3 发布以来,JavaScript 中就有了块作用域,而with 和 catch 分句就是块作用域的两个小例子。

但随着 ES6 中引入了 let,我们的代码终于有了创建完整、不受约束的块作用域的能力。块作用域在功能上和代码风格上都拥有很多激动人心的新特性。

但如果我们想在 ES6 之前的环境中使用块作用域呢?

1
2
3
4
5
{
let a = 2;
console.log( a ); // 2
}
console.log( a ); // ReferenceError

这段代码在 ES6 环境中可以正常工作。但是在 ES6 之前的环境中如何才能实现这个效果?答案是使用 catch。

1
2
3
4
try{throw 2;}catch(a){
console.log( a ); // 2
}
console.log( a ); // ReferenceError

了解原理剩下就交给ES6转换器吧。

附录 C this 词法

ES6 中有一个主题用非常重要的方式将 this同词法作用域联系起来了,我们会简单地讨论一下。

ES6 添加了一个特殊的语法形式用于函数声明,叫作箭头函数。

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

这里称作“胖箭头”的写法通常被当作单调乏味且冗长(挖苦)的 function 关键字的简写。

看这段代码:

1
2
3
4
5
6
7
8
9
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
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(); // 酷吧?

var self = this 这种解决方案圆满解决了理解和正确使用 this 绑定的问题,并且没有把问题过于复杂化,它使用的是我们非常熟悉的工具:词法作用域。

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

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

简单来说,箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。

因此,这个代码片段中的箭头函数并非是以某种不可预测的方式同所属的 this 进行了解绑定,而只是“继承”了 cool() 函数的 this 绑定(因此调用它并不会出错)。

箭头函数将程序员们经常犯的一个错误给标准化了,也就是混淆了 this 绑定规则和词法作用域规则。

换句话说:为什么要自找麻烦使用 this 风格的代码模式呢?把它和词法作用域结合在一起非常让人头疼。在代码中使用两种风格其中的一种是非常自然的事情,但是不要将两种风格混在一起使用。

另一个导致箭头函数不够理想的原因是它们是匿名而非具名的。具名函数比
匿名函数更可取的原因参见第 3 章。

在我看来,解决这个“问题”的另一个更合适的办法是正确使用和包含 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( function timer(){
this.count++; // this 是安全的
// 因为 bind(..)
console.log( "more awesome" );
}.bind( this ), 100 ); // look, bind()!
}
}
};
obj.cool(); // 更酷了。

无论你是喜欢箭头函数中 this 词法的新行为模式,还是喜欢更靠得住的 bind(),都需要注意箭头函数不仅仅意味着可以少写代码。

它们之间有意为之的不同行为需要我们理解和掌握,才能正确地使用它们。

现在我们已经完全理解了词法作用域(还有闭包),理解 this 词法是小菜一碟!