js的作用域总结
此篇为《你不知道的JavaScript》读书笔记
小声说:如果看过之前总结的this再读本篇,会发现js的作用域和this是完全不同的两套机制。
前言
要理解作用域,首先要了解引擎、编译器和作用域在JavaScript扮演了什么角色。
var a=2;
怎么理解 var a=2 ?
当你看见var a = 2;
这段程序时,很可能认为这是一句声明。事实上,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。
事实上编译器会进行如下处理。
-
遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。 -
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果否,引擎就会使用这个变量;如果不是,引擎会继续查找该变量。
如果引擎最终找到了a变量,就会将2赋值给它。否则引擎就会举手示意并抛出一个异常!
总的来说,变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
编译
尽管通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript引擎用尽了各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
简单地说,任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript编译器首先会对var a = 2;这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。
作用域
一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。
我们可以直观地感受到,所谓的作用域其实是一群变量的集合。
JavaScript所采用的是词法作用域模型。
词法作用域就是定义在词法阶段的作用域。词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
要强调的是,作用域是在书写代码时就已经确定的,不像this那样——函数的执行过程中调用位置决定this的绑定对象。
- 从词法作用域理解变量取值
上面大段大段的概念看得令人发困,就算我们知道了js的作用域是词法作用域又能做什么呢?
来看一个例子吧
- 例1
function foo(a) {
var b = a * 2;
function bar(c) {
console.log( a, b, c );
}
bar( b * 3 );
}
foo( 2 ); // 2, 4, 12
这是一段常见的代码结构,1、2、3层作用域层层嵌套。
例1bar作用域
- 例2
如果我们把代码改成这样,结果能说明什么呢?
function bar(c) {
console.log( a, b, c );
}
function foo(a) {
var b = a * 2;
bar( b * 3 );
}
var a=1;
var b=2;
foo(2);// 1、2、12
因为作用域是在书写代码时就已经确定的,所以看bar函数声明的位置,就决定了他的作用域
因此,虽然在foo里也定义了一个与外层同名的b
变量,但此时bar作用域并没有包含在foo作用域内,所以bar去寻找b变量的值时,读取的是全局作用域Global的b
。
-
函数作用域
上面提到的作用域,我们称之为函数作用域。
顾名思义,函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用)。 -
块作用域
直观上,块作用域就是被{}框起来的作用域。常见的比如for、if。如果希望使用块作用域,则需要使用let、const关键字。
换句话说,let为期声明的变量隐式地附加在一个已经存在的块作用域。
- 例1 var
for(var a=0;a<6;a++){
console.log(a);
}
var
如果我在全局作用域下使用for进行循环遍历,那么用var关键字定义的a,其实是在全局定义了一个a,即使for循环已经结束,a也将存在于这个作用域里。此时没有块作用域的存在。
- 例2 let
for(let a=0;a<6;a++){
console.log(a);
}
如果将var换成了let,可以看到a出现在了块级作用域内。
值得注意的是,for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
提升
函数优先
函数声明会被提升到普通变量之前。
思考例子:
foo(); // 位置1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
foo(); // 位置2
位置1: var 声明的变量,将会被提升到作用域起始位置,而赋值会在原位置发生。所以位置1不可能是2。而根据函数优先的原则,var foo
尽管出现在function foo()...
的声明之前,但它是重复的声明(因此被忽略了), 输出结果为1。
位置2:后一个函数声明覆盖掉前面的声明,故结果为2。
作用域闭包
函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。
也就是说,闭包可以让你从内部函数访问外部函数作用域。
在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。
- 例1
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
根据上面说到的,在for循环里的var声明,将会在全局环境创建一个变量,而setTimeout的回调会等到循环结束后才执行,所以会输出5个6.
怎么让这个代码输出正常的递增的i是个老问题了。
思路在于,闭包可以从内部函数访问外部函数作用域,所以写一个闭包,将setTimeout的回调函数作为内部函数,访问外部函数中的变量就能实现这个效果。
解决1:
for (var i=1; i<=5; i++) {
(function() {
var j = i;
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})();
}
解决2:
使用块作用域
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
timer作用域
可以看到在timer的作用域里,访问的i是处于嵌套于他外层的块级作用域。