最近在读《你不知道的JavaScript》一书,书中的很多知识让我茅塞顿开
一个简单例子:
for(var i=0;i<5;i++){
setTimeout(function(){
console.log(i);
},1000*i);
}
初看,不就是连着输出0,1,2,3,4嘛,还不简单。但是,等我运行输出的时候却是5,5,5,5,5。嗯?这就有点东西了 ,这是为啥子呢?往下看就知道了
作用域
作用域就是一套规则,用于确定在何处以及如何查找变量
理解一些简单的编译原理,对我们理解作用域和闭包有很大用处
首先,简单理解下面代码的编译过程
var a=2;
第一步:编辑器遇到var a就会询问作用域是否有一个该名称的变量存在于同一个作用域的集合中,有就忽略该声明,继续编译;否则就会要求作用域在当前作用域集合中创建一个名称为a的新变量
第二步:编辑器会为引擎生成运行时所需要的代码,这些代码用来处理a=2这个赋值操作。当引擎运行时首先会询问作用域,在当前作用域集合中是否存在a变量。如果有,就使用这个变量;否则,引擎就会往上查找该变量,直到全局作用域中还没找到就会抛出异常
在第二步的过程中,引擎的查找又分为俩种,一种是LHS查询,另一种是RHS查询。
LHS查询就是当变量出现在赋值操作的左边时进行,可以理解为“目的是给该变量赋值”
RHS查询就是当变量出现在赋值操作的右边时进行,可以理解为“目的是取得该变量的值”
这么说,肯定很难理解。看例子:
console.log(a);//RHS查询
a=2;//LHS查询
可能你会有点疑惑,俩者的区别到底是什么?我的理解是:RHS查询就是取,取什么?取得这个变量的值。而LHS查询就是给,给什么?给这个变量一个值
这样是不是有点理解了。那你可能会想这俩个查询有什么用处呢?
有用处的。当引擎对俩种查询都失败时,二者的用处就明显体现出来了
看下面例子:
function foo(a){
b=a;
console.log("b="+b);
console.log(c);
}
foo(2);
分析一下,foo()函数的执行需要一个参数即一个值,就需要对foo进行RHS查询,刚好foo(2)中有一个值,2就会被当成参数传递给foo()。注意,此时foo(a)中的a并不等于2,函数并没有执行。要执行还需要对给a分配一个值,就要进行LHS查询,给a一个值,a=2。然后b=a;对a进行RHS查询,取得2;对b进行LHS查询,但是失败了,为什么?往上看第二步中的说明,引擎会在当前作用域集合中引用LHS查询,但是b是没有声明的,所以不会出现在当前作用域集合中,所以LHS查询会失败,但是LHS查询会主动创建一个变量b放入当前作用域集合中,也就是隐式全局变量。当console.log("b="+b);时会输出b=2;然后console.log(c);对c进行RHS引用失败,直接报出异常信息Uncaught ReferenceError: c is not defined
总结一下:
作用域就是一套规则,用来确定和查找变量的
引擎会在代码执行前进行编译,就像var a=2;那样分步骤进行
LHS查询是对变量进行赋值,RHS查询是获取变量的值
LHS查询和RHS查询都会在当前执行作用域中开始,然后一直向上级作用域寻找,直到全局作用域,不论有没有找到都会停止
失败的LHS查询会创建一个隐式全局变量,失败的RHS查询会报出异常
词法作用域
词法作用域是作用域的一种工作模型
简单来理解就是词法作用域是由你在书写代码时将变量和块作用域写在哪里来决定的。当词法分析器处理代码时会保持作用域不变(大部分情况下)
function foo(a){
var b=2+a;
function bar(c){
console.log(a,b,c);
}
bar(b+2);
}
foo(2);
全局作用域中,只有一个标识符:foo
foo所创建的作用域,有三个标识符:a,bar,b
bar所创建的作用域,只有一个标识符:c
作用域由其所对应的作用域块代码写在哪里决定的,它们是逐级包含的
作用域的查找是在找到第一个匹配的标识符时就停止。在多层的嵌套作用域中可以定义同名的标识符,这叫“遮蔽效应”(内部标识符遮蔽外部的标识符)。抛开遮蔽效应,作用域查找始终是从最内部作用域开始查找,逐级往上查找,直到找出第一个匹配的标识为止或者抛出异常。
看例子:
var a=2;
function foo(){
var a=3,b=2;
function bar(){
var a=5;
console.log(a,b);
}
bar();
}
foo(); //输出5 2
对a进行RHS查询,首先在bar函数作用域中查找,找到了就会直接输出,不再查找。对b也进行RHS查找,在bar函数作用域中查找,没有找到,就会往上一级查找,找到了直接输出
函数作用域和块作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用以及复用(在嵌套的作用域中也可以使用)
这个应该能很好理解,就是函数中的变量只能在该函数作用域中使用,外部无法访问,很好的保护了变量的安全性,防止发生重命名造成覆盖
但是,这样写还是有问题,要是函数名被重名了呢?比如:
var a=1;
function foo(){
var a=2;
console.log(a);
}
function foo(){
var a=3;
console.log(a);
}
foo(); //输出3
当函数重名时,后面的函数会覆盖前面的函数,就像变量重名一样,那么有没有什么方法可以解决呢?
使用立即执行函数表达式,如下:
var a=1;
(function foo(){
var a=2;
console.log(a);
)();//输出2
(function foo(){
var a=4;
console.log(a);
)();//输出4
上面代码会分别输出2和4,俩个函数即使同名也互不影响
对比俩种函数方式,第一种function foo() {},foo是被绑定在所在作用域中的,可以直接通过foo()来调用。第二种(function foo(){})(),foo是被绑定在函数表达式自身的函数中,意味着foo中能在{}中被访问,外部无法访问,这样也就消除了函数重名覆盖的意外了
块作用域,很简单的理解就是在{}中的作用域。只要有{},就自成一个作用域,就像是函数作用域那样
let或const关键字可以隐式的劫持所在的块作用域,使得其变量只能在块作用域内部使用,不被暴露在全局作用域中
var ff=true;
if(ff){
{
let a=2;
console.log(a);//2
}
}
console.log(a);//ReferenceError: a is not undefined
只要声明是有效的,就可以在声明中的任何位置都使用{}来为let或const创建一个绑定的块作用域,不过要注意的是,使用let或const声明变量,不会进行变量提升,这个后面会讲
当然还有其他方式可以创建出块作用域,比如with关键字,还有try/catch
总结成一句话就是:
函数作用域和块作用域的行为是一样的,任何声明在某个作用域中的变量都将依附于这个作用域
提升
先看下面俩个例子:
a=2;
console.log(a);//2
var a;
console.log(a);//undefined
var a=2;
是不是觉得有点意思?一样的代码,只是略微的改变了一点位置,就输出了完全不一样的结果。这就是变量提升造成的
要搞清楚变量和函数提升的原理,回想一下在最前面写的编译原理的内容
对于var a=2;这样一行代码,其实是分为俩个步骤进行解析的
第一步声明:var a;这是在编译阶段进行的
第二步赋值:a=2;这是在执行阶段进行的
所以第一个例子会被这样处理:
var a;
a=2;
console.log(a);
而第二例子是这样处理:
var a;
console.log(a);
a=2;
每个声明都被提升到了最前面(声明是在编译阶段就进行了,编译阶段会寻找到当前作用域中的全部变量,并保留在一个当前作用域集合中),而其他代码都原地不动,按照顺序执行
是不是觉得很简单,对,就这么简单,提升就是只将声明提升,提升到什么位置呢?提升到当前作用域的顶部
当然,变量可以提升,那么函数肯定也能提升
foo();//2
function foo(){
console.log(a);
var a=2;
}
很简单,就像变量一样。要注意一点,函数声明可以提升,但是函数表达式不会提升
foo();//TypeError
var foo=function bar(){
var a=2;
console.log(a);
}
这个例子直接运行会报错。为什么?按照上面的分析,实际运行过程是这样的
var foo;
foo();
foo=function(){
var bar = ...self...
var a=2;
console.log(a);
}
所以,很明显,foo只是声明了,但是没有赋值,此时foo=undefined,foo()对于undefined值进行函数调用就会导致非法操作,抛出TypeError异常
还有一个例子也很重要,同时也很容易误解
foo();
bar();
var foo = function bar(){
var a=2;
console.log(a);
}
是不是觉得foo();会调用失败,但是觉得bar();可以调用呢?错了,同样不能调用,下面是实际的运行过程
var foo;
foo();
bar();
foo=function(){
var bar= ...self...
var a=2;
console.log(a);
}
注意到,此时bar被绑定到自身函数中而不是所在作用域中,此时的bar就像是一个变量,该变量保存了函数本身(可以往上看函数作用域中的立即执行函数表达式),所以只能在函数体中调用,在函数体外就会抛出TypeError异常
函数声明会提升,当然也会有函数重名的情况发生,此时就会发生覆盖现象
foo();//输出2
function foo(){
console.log("1");
}
function foo(){
console.log("2");
}
总结一下:
所有声明的变量和函数都会自动的移动到各自作用域中的最顶端,这就是提升
闭包
一句话解释:
当函数能记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
可能会有些不理解,没关系,看个小例子:
function foo(){
let a=2;
function bar(){
console.log(a);
}
return bar;
}
let bo=foo();
bo();//输出2
bar()的词法作用域能够访问foo()的词法作用域,然后将bar()函数本身当作一个值类型返回。在foo()执行之后,赋值给bo变量并调用bo(),实际上只是通过不同的标识符引用来调用了内部的bar()函数
通常foo()函数执行之后,foo()的整个内部作用域都会被销毁(垃圾回收器会释放不再使用的内存空间),但是,由于bar()拥有涵盖foo()内部作用域的闭包,使得该作用域能一直存活,以供bar()在之后的任何时间都能引用
bar()依然保持对foo()作用域的引用,就叫做闭包
简单理解就是闭包能够延长所在作用域内的变量的生命周期
那么现在要回到我最开始说的那个例子了:
for(var i=0;i<5;i++){
setTimeout(function(){
console.log(i);
},1000*i);
}
会每秒一次的输出一个5
首先要理解延迟函数的回调(setTimeout())会在循环完全结束之后才会执行。这样可能会理解为每一次循环setTimeout()都分别获取i的值,然后等到循环完全结束之后再执行,然后输出0,1,2,3,4。语义上可以这么理解,但是要注意一个问题,就是i是全局变量,用var 声明的。即使每次循环过程中获取一个i的值,i的值在循环过程中都会改变,但是i都是在全局作用域中的,意味着最后只能保留一个i,前面的i只会被后面的i所覆盖。所以等到循环结束之后,i=5,把前面赋值的i都覆盖掉了,最后当setTimeout()执行的时候,只能获取i=5;然后重复执行5次
那么如果创建更多的闭包作用域是不是就能解决这个问题呢?特别是在循环过程中的每个迭代都创建一个闭包作用域
就像下面这样
for(var i=0;i<5;i++){
(function(){
setTimeout(function(){
console.log(i);
},1000*i);
})();
}
想想看,觉得这样可以吗?立即执行函数表达式会在会在每个迭代中创建一个新的闭包作用域,但是里面并没有i变量。所以当setTimeout()执行的时候,首先会在新建的闭包作用域中查找,没有找到就会往上一级全局作用域中查找,刚好有一个var i;所以还是会获取到i=5。因此只是创建了新的闭包作用域并不能解决问题,不过也离解决问题不远了
就是在新建的闭包作用域中声明一个变量来保存每次迭代获取的i的值
for(var i=0;i<5;i++){
(function(j){
setTimeout(function(){
console.log(j);
},1000*i);
})(i);
}
每次迭代把i传递进去,由j来接收,然后在在内部供setTimeout()使用
也可以这样来解决问题,使用let来声明i变量,还记得let的用法吗?自成一个作用域,每个作用域中的i都是不同的,即都是一个新的变量
for(let i=0;i<5;i++){
setTimeout(function(){
console.log(i);
},1000*i);
}
那么上面就可以这么理解了。每个迭代都创建了一个新的块作用域,块作用域中保存了let i的值,且i的值是使用上一次迭代结束时的i值来初始化的。当setTimeout()执行时,在块作用域中就能直接查询到i的值,就可以直接使用。为什么每次迭代都会创建一个新的块作用域呢?就是for循环头部的let声明有一个特殊的行为,就是在每次迭代的时候都会let声明一次,且每次迭代都会使用上一次迭代结束时的值来初始化这个变量