原来JS是这样的 – 提升, 作使用域 与 闭包
引子
长久以来一直都没有专门学过 JS ,由于之前有自己啃过 C++ ,又打过一段时间的算法竞赛(写得一手好心大利面条),于是自己折腾自己的网站的时候,一直都把 JS 当 C 写。但写的时候总会遇到少量奇怪的问题,于是打算花点时间看了看《你不知道的JavaScript》。写这篇文章以记录一下一段时间的学习内容,也治疗一下我不爱做笔记和总结的毛病。假如你也是一直按着别的语言的编程习惯来写 JS 而没有专门去理解过它,不妨一起来理解一下 JS 的少量独特之处。
首先来看一段代码:
console.log("Firstly, i = " + i);
你可可以注意到,这段代码一开始就要输出 i 的值,而在输出之前我们似乎并没有写任何公告和定义 i 值的语句,而再之后,我们给 i 赋了一个值,但我们仍然没有使用 var 之类的关键字来做变量公告的工作。在for循环,我们终于公告了 i ,但 for 循环之后,我们仍然在试图用 i 。这些代码看上去都很荒唐,或者许你可可以认为这段代码在第一行的时候就会报 ReferenceError 以提醒我们并没有定义变量 i 并中止执行。但实际真的是这样吗?
让我们看一下这段代码的执行结果吧:
Firstly, i = undefined
这段代码其实非常的谭浩强,但却说明了一个比较显著的 JS 的不同之处,那就是 提升 和 作使用域 规则。
提升(Hoisting)
或者许因为之前的编程语言中所得到的经验,我们可可以会认为,在公告语句之后我们才能用我们刚刚公告过的变量,我们看这段代码:
a = 61;
你可可以认为第一条语句是非法的,但实际上它正常的执行了,但分明我们是在下面才公告了 a ,这就是 提升 的含义了。
实际上,在 JavaScript 解释一个作使用域内的代码时,会把变量和函数的公告在这块作使用域中的任何代码执行之前进行解决。这就像是把函数和变量的公告拿到了这个作使用域的最上面了一样。这个过程就叫做 提升 。
于是我们再来看下一段代码:
console.log(a); // undefined
停!等等!不是会提升么,不应该是 log 一个 61 出来么?但实际上答案就是未定义。实际上,我们能了解为,编译器在分析这段代码时,这段代码的第二行会被编译器解析成两部分, var a
和 a = 61
。就像刚刚所提到的,公告确实是要被提升的,于是 var a
就被“拿到最上面”去了,而 a = 61
则留在原地,所以,这段代码实际会输出一个 undefined ,而不是在我们还不知道 提升 这种说法时可可以猜测的结果 ReferenceError ,以及以为会把赋值也提升上去得到的 61 。
上面提到了,函数的公告也会提升,假如你之前曾经在你定义一个 function 之前就尝试用这个 function 但没有出错的起因了,这也是为何你能把外部 js 代码在页面最底部引入你也仍然可以够用那些代码的起因。
当然,也有少量需要注意的地方,函数表达式的提升规则比较奇怪,比方下面这段代码。
foo(); // TypeError
它大致上会被这样解释:
var foo;
假如你不清楚函数公告和函数表达式的区别,能参见这个和这个 。
作使用域(Scope)
当你知道了提升的概念,反过来看最上面的示例代码,可可以仍然会觉得不正常——我们分明是在for循环这个代码块里才公告了变量,为什么在外面也可以使用它?刚刚不是说,代码只被提升到一个作使用域之内的最上面吗?于是我们来看下面的这段代码:
//console.log(bar);
最直观的印象里,这段代码在函数 foo 内的一个条件语句成立的条件下会公告变量 bar 并赋值 61,而实际上我们会发现,除了函数外我们注释掉的那个语句之外,我们都能访问到 bar 。
刚刚不是说,提升仅限所在的作使用域吗?对,确实如此,但实际上,JavaScript的作使用域本身并不解决这样的,由 if, for 等后面的花括号构成的块作使用域。因而,此处公告的 bar 实际所在的作使用域是函数 foo 之内,而不是由 if 构成的块级作使用域。不过例外的,需要注意的是, with 和 try/catch 是能创立自己单独的作使用域的。
当然,实际在 ES6 引入的新关键字 let 处理了这个问题,用 let 公告的变量就只存在于块级作使用域内了,这处理了 var 导致的名称污染问题。
那么我们回到最开始的例子,我们看上去是在for循环中才公告的变量实际被提升到了for循环之外的作使用域,于是剩下的内容就没有什么说不通的问题了。额外的一点是,对于已经公告过的变量,再次发现公告同名变量的行为会被忽略。
闭包(Closure)
跟据刚刚讲的内容,看下面这段代码
function foo() {
显然,我们在 foo 内公告的变量 t 所在的作使用域就是 foo 函数本身,我们不可以在外部访问 foo ,而实际上我们可可以总是需要访问封闭在 foo 作使用域内的变量 t ,于是,为了可以够访问这个变量,我们使 foo 返回了 bar 使用以访问 t 变量,并使用 baz 来保存了对 bar() 的引使用。于是当我们执行 baz() 的时候,会看到输出了 t 的值,并且 t 的值会加一。
事实上,我们通过 baz 引使用 bar 以防止 bar 所处的作使用域被引擎回收,于是我们保住了这个作使用域里的变量,以便以后再次用,并且我们还能在外部访问它(这种需求就像面向对象语言中一个类对象中的私有成员一样)。而我们做的这种事情,实际就叫做闭包。
为了不搞混,还是重新说一下闭包的概念:当函数能记住并访问所在的词法作使用域时,就产生了闭包,即便函数是在当前词法作使用域之外执行。
我们来看下面一段代码
var a = 61;
如上是一个立即执行函数(IIFE),而这是一个闭包吗?答案是:并不是。由于函数本身并不是在它之外的词法作使用域所执行的,其中用的变量 a 也并不是函数 IIFE 所封闭的变量。所以,这不是一个闭包。
再考虑下面一段代码
for(var i = 1; i <= 5; i++) {
这段代码我们把直接执行函数塞到了for循环里,IIFE里的内容则是推迟i秒后输出i的值。看上去应该输出的是1到5,一秒一个,而实际上输出的则是66666(一秒一个6)。
其实和上一段代码一样,这个立即执行函数和这段代码中的并没有什么异样,用的i仍然是外部作使用域的i(而不是IIFE构成的作使用域内的自有变量)。于是,由于函数被推迟执行,执行的时候for循环已经循环完了,自然输出了66666。而假如想要达到本身的目的,只要要这样修改:
for(var i = 1; i <= 5; i++) {
这看上去是一个很蛋疼的把戏,但我们通过参数传入的 i 在 IIFE 内成了隐式公告的变量 j ,而j的作使用范围是 IIFE 所构成的语法作使用域内,自然不会有问题。
最后我们简单提及一下板块机制。回到闭包段落的第一个例子,我们能看到我们以通过返回一个能访问闭包内部变量的函数来达到访问闭包内部的变量的目的(听上去如同是废话),而当我们在编写一个板块时,我们通常需要通过这种行为去模拟一个类,这种行为的实现方式很多,比方这样:
function moduleFoo() { // ps: 以函数表达式的方式公告该函数,即可以达到单例的效果
关于更多板块机制的实现方式,其实能开展成单独的文章来说了,这里就不再阐述,而需要额外提到的是,ES6引入了 import 关键字能将一个单独的文件视为一个板块来引入和用。当然,这就不在刚刚所探讨的闭包的范围内了。 蛤!