JavaScript说难不难,说简单也不简单,其中有些知识(如:变量提升机制、作用域链及闭包)这三个概念以前对我来说是似懂非懂,并没有理解透彻。最近在重新翻阅、学习JavaScript的过程中,通过阅读网上多个文档教程、和自己的相关的测验,才算是真正搞明白了,我也将我这次理解到的知识点专门记录下来,给大家一个参考。如果其中有问题的地方,欢迎大家帮我指出。
关于理解变量提升机制,我从如下几点进行理解:
1. var与function修饰的变量提升
浏览器在执行程序时,首先会开辟一块内存(栈内存),作为程序的执行环境,这实际上就是我们所称的作用域或执行上下文(context)。这块内存一是用来执行代码,二来存储变量以及基本数据类型的值。也就是说,当程序执行时,浏览器第一步是开辟一块供程序执行的栈内存,但紧接着的第二步并不是让程序立即执行,而是进行词法解析与变量提升,把一些变量与值存储起来,待第二步完成后,第三步才是让程序自上而下开始执行。
何为变量提升,变量提升就是把当前作用域中所有带var与function关键字(注意只是var与function,let、const等不会)的变量在程序执行之前就进行声明和定义,并不是等到程序自上而下执行时,遇到了才去声明和定义,这就是变量提升机制。
而对var与function变量进行提升时,两者又有区别的。
带var的只是提前声明,比如代码中有一行代码是:var a=100,那么在变量提升阶段相当于var a,即只创建一个变量a,但并不给a赋值,此时a的值默认为undefined。
而对于带function关键字的变量,在提升时不但声明(创建),而且还要定义(赋值)。
总的来说,变量提升阶段,带var的只声明不定义,带function的既要声明又要定义
console.log(a);
// console.log(c);
var a = 12;
var b = a;
a = 13;
console.log(b);
console.log(sum(10, 20));
function sum(n, m) {
return n + m;
}
执行上述代码时
第一步:是开辟一块栈内存,此块栈内存也就是此程序的执行环境即全局作用域或称执行上下文,我们用一方框表示,如图所示:
第二步:进行词法解析与变量提升。词法解析以后再谈,这里我们说变量提升。在进行变量提升时,浏览器会在刚才开辟的栈内存中拿出一部分来,把其先划成两个区域,一个用于存储变量,我们称其变量存储区,另一个用于存储对应变量的值,我们称其为值存储区。浏览器会把所有带var与function的变量提取出来,把变量存储在变量存储区中,把变量的值存储在值存储区中。由于在变量提升阶段,带var的只声明不定义,所以在值存储区中没有变量a、b对应的值(如此时取值,浏览器就会处理成默认的undefined),而sum是带function的变量,在变量提升阶段还要为其赋值,而function又是一个函数,函数是引用类型,引用类型的值是存储在堆内存中的,此时浏览器会在另外的堆内存中开辟一个空间,把函数体中的代码以字符串的形式存储在这个堆内存中,然后把此堆内存的地址值赋给变量sum,也就是说,栈内存中的变量sum所存储的值是那个函数体的堆内存的地址值,如图所示:
第三步:完成了变量提升与词法解析后,浏览器会把代码调入栈内存的代码执行区,正式开始自上而下的执行过程。
下面我们来分析下上文的代码:
console.log(a);
// console.log(c);
var a = 12;
var b = a;
a = 13;
console.log(b);
console.log(sum(10, 20));
function sum(n, m) {
return n + m;
}
第一行代码是:console.log(a); 即输出变量a的值,此时程序会首先在变量存储区中去找此变量,由于在变量提升阶段就已经声明了此变量,即在程序执行时此变量就已经存在了,一找便找到了,所以程序不会报错,只不过此时还没有为变量a赋值,那么便会取其默认值undefined,故会输出undefined。
第二行代码是被注释起来的,故会略去第二行代码继续到第三行代码上。如我们去掉注释让第二行代码执行起来的话,程序则会报错,卡在第二行代码上,不再执行下面的代码了。为什么会报错呢?与第一行console.log(a)执行原理一样,这里的console.log(c)是要输出变量c的值,程序会首先在变量存储区中去找此变量,而变量存储区中却没有此变量,虽然在这个空间里找不到此变量,但程序不会立马报错,它会继续到上一级空间中去找,如果没有上一级作用域或者上级作用域以及上级的上级直到顶级中都没有,则会报错,输出结果为:
第三行代码 var a = 12; 这行代码是var a;与a=12;的合写,即声明的同时又定义,由于变量的提升机制的原因,在变量的提升阶段就提前声明了a变量了,此处程序会忽略声明而直接定义,即在值存储区中存入值12并与变量存储区中的a关联起来。
第四行代码与第三行一样,忽略声明(已提前声明了),而是直接定义赋值,只不过赋值的量不是字面直接量,而是把已经存在的a中的值取出来再存储在值存储区中并让b与之关联。
第五行代码是给a重新赋值,至此程序状态如图所示:
第六行代码是输出b的值,第4行在给b赋值时虽然使用的是a的值,但它是把a的值拷贝给b的,所以第5行虽然改变了a的值,但对b是没有影响的,输出结果是12。
第七行是:console.log(sum(10, 20));把函数执行的结果输出。这里是函数声明与定义在后面,而函数的执行在声明定义之前,也就是说是先使用后声明定义,会不会报错呢?是不会的,原因就是因变量的提升,在程序执行之前就已经对sum进行了声明与定义了,即此时函数已经存在了,故可正常执行而不会报错。当第七行执行完毕后再住下的8、9、10是函数的声明与定义,由于已经提前声明定义了,程序会直接全部忽略,至此程序的执行就全部结束了,如上图所示。
当然函数执行时还会新开辟一个私有作用域形成函数的私有执行环境,与前面的全局作用域一样,在这个私有作用域内也先有变量提升与词法解析,然后把函数存储在堆内存中的代码推入这个作用域的执行代码区,再自上而下执行,这个以后再讲,此处我们主要是理解变量提升这个知识点。
至此就详细的介绍了变量提升机制。
2. 函数表达式
而在真实项目中,往往不使用function关键字来定义函数,而常使用函数表达式来创建函数,如下所示:
sum(10,20);
var sum = function(n,m){return n+m;}
这里的sum不是使用function来声明定义的,而是使用var来声明赋值的,我们称其为函数表达式,此时的sum是使用var来修饰的,那么在变量提升阶段只会提前声明sum这个变量,而不会赋值,或者说其值为默认的undefined,是没有函数体的函数,不能执行,故上段代码执行起来会报错。
实际上,这里的sum你把它看成一个变量,即函数变量,是一个由var修饰的变量,其提升机制同前面所说的普通var变量是一样的。
函数的这两种创建方式都会用到,那么那种好些呢?从容错的角度来看用前面function那种方式要好些,但从严谨的角度来看用后面var这种函数表达式方式要好些,因这种符合我们先创建后使用的正常思维逻辑。正因如此,在ES6中使用let来创建一个箭头函数时,如:let sum = (n,m)=>n+m;由于是let,便没有变量的提升,只能先声明定义后使用,就是避免了提升带来的混乱,使其逻辑更加严谨。
3. let/const和var的区别
① let和const不存在变量提升机制。
创建变量的六种方式中:var/function有变量提升,而let/const/class/import都不存在这个机制。但我们要注意如下形式创建的a:
a=13;
console.log(a);
这里的a前没有那六种方式中的任何一个关键字,是不是执行后面的输出语句时就会出错呢?结果是不会出错的,且输出结果是13。这是怎么一回事呢?首先我们要明白一点,这里的a不是变量,这种写法它相当于是:window.a = 13这种写法,即给window对象上设置了一个值为13的a属性,同理第二个语句 console.log(a)相当于是: console.log(window.a),即这种写法是省略了默认的window对象了的,是直接设置或读取window上的一个属性。那么我们再看下面的代码,特别要注意最后一条输出语句:
var b=14;
console.log(b);
console.log(window.b);
这里是使用var在全局作用域下声明定义了值为14的全局变量b,即b是一个变量,第2条语句当然能顺理成章地输出这个变量的,肯定能得到14的输出的,而最后一条语句是指名道姓地输出window对象中的属性b,好像我们并没有给window对象设置名为b的属性,是不是不能正确输出呢?而实际上输出的结果也是14,原因在于:在全局作用域下用var声明变量或用function定义函数的同时,会同步给window对象增加一个同名的属性(只有全局作用域才具备这个特点)。
② 在同一个作用域下,var/function允许重复声明,而let/const 是不允许的。
③ let能解决typeof检测时出现的暂时性死区问题(let比var更加严谨)。(参看我后面的 《4.变量提升和暂时性死区》中的“词法解析”部分)
④ let能产生私有作用域
练习题
1、写出下面代码输出的结果
console.log(a); //=>undefined
var a = 12;
a = 13;
console.log(a); //=>13
console.log(a); //=>Uncaught ReferenceError: Cannot access 'a' before initialization
let a = 12;
a = 13;
console.log(a);
console.log(1);
let a = 12;
console.log(a);
let a = 13;
console.log(a);
//=>Uncaught SyntaxError: Identifier 'a' has already been declared
//词法解析与变量提升是在同一步骤中完成的,词法解析就是检测即将执行的代码是否会出现语法错误,如有则会报错。因词法解析是在变量提升阶段进行的,早于代码执行,所以这里一行代码都不会执行。
console.log(1);
console.log(a);
let a = 12;
//=> 1
//=> Uncaught ReferenceError: Cannot access 'a' before initialization
上面代码片段中的第一、第二片段说明在同一个作用域下var可以重复声明,let则不能。var可以,是因遇到第二次声明时,会忽略声明,相当于重新赋值而也。let不能,这是ES6为了克服这种混乱而新增的一种语法规范,如果重复声明,在变量提升、词法解析阶段就会报语法错误,程序停止,一行代码都不能执行。
上面代码片段中的第三、第四片段所产生的错误种类是不一样的,语法错误的典型词是:SyntaxError,是在变量提升、词法解析阶段就能捕获得到的,如出现此类错误整个程序都不能运行,一行代码都不能执行。引用错误的典型词是:ReferenceError,是在程序执行过程中捕获到的,引起错误的后面代码不可执行。
2、写出下面代码输出的结果
console.log(a);
var a = 12;
let a = 13;
console.log(a);
console.log(a);
let a = 13;
var a = 12;
console.log(a);
上面两代码片段均会出现语法错误,原因是重复声明了变量,所谓重复是:不管之前通过什么办法,只要当前栈内存中存在了这个变量,我们使用let/const等再声明这个变量就是语法错误(不管是先var后let,还是先let后var,均是重复声明。但我们要注意这种:先var后又var,这种不算,var是ES6之前的产物,浏览器对重复的var做了这样的处理:遇到后面的var时,会忽略声明,只是定义,相当于重新赋值而也)。
还有一点,词法解析、变量提升虽然是在同一个阶段,那么这个同一阶段里的两者又有没有先后呢?查阅了一些资料也没有一个确切的答案,不过大多认为词解析在变量提升之前,更有甚者认为变量提升是词法解析的一部分。
3、写出下面代码输出的结果
fn();
function fn(){console.log(1);}
fn();
function fn(){console.log(2);}
fn();
var fn = function(){console.log(3);}
fn();
function fn(){console.log(4);}
fn();
function fn(){console.log(5);}
fn();
//=> 5 5 5 3 3 3
4、写出下面代码输出的结果
console.log(a);
if(!('a' in window)){
var a = 13;
}
console.log(a);
//=>undefined undefined
//即使var在条件语句中,在变量提升阶段不管条件是否成立都要进行变量提升
fn();
if('fn' in window){
function fn(){
console.log('哈哈');
}
}
fn();
//=>Uncaught TypeError: fn is not a function
//用function修饰的函数变量,如在条件语句中,同样是不管条件是否成立都要进行变量的提前声明。在全局作用域中的function是既要提前声明,又要提前定义,而在条件语句中时,函数的变量提升有其特殊性:在老版本浏览器中,是不论条件是否成立,函数也是提前声明与提前定义的,但是在新版本浏览器中,为了兼容ES6严谨的语法规范,条件语句块中的函数在变量提升阶段只会提前声明,不会提前定义
在条件语句中,不管条件是否成立都要进行变量提升
条件语句块中的函数在变量提升阶段只会提前声明,不会提前定义,与var变量一样了
console.log(fn);
if('fn' in window){
fn();
function fn(){
console.log('哈哈');
}
}
fn();
//=>undefined
//=>哈哈
//=>哈哈
//条件语句里的变量提升,如是函数时有其特殊性,是只声明不定义,所以第一行语句输出fn的值时将是:undefined
//进入条件语句后,第一条语句便是执行fn函数,按常理,此时fn还没赋值,是undefined,应该报错的,而实际上fn却可正常执行。究其原因是浏览器把条件语句体当作块来对待,做了这样的处理:条件成立,进来后的第一件事是先给fn赋值,相当于提升,于是使用的时候就可行了。
5、写出下面代码输出的结果
f = function(){return true;}
g = function(){return false;}
~function(){
if(g() && [] == ![]){
f = function(){return false;}
function g(){return ture;}
}
}();
console.log(f());
console.log(g());
//=> Uncaught TypeError: g is not a function
相关知识点:
1、自执行函数有五种书写形式:
①(function(n){...})(10);
②~function(n){...}(10);
③-function(n){...}(10);
④+function(n){...}(10);
⑤!function(n){...}(10);
其中的 ~、-、+、! 没有多大的实际意义,要说它们的意义就是代替那对小括号,让书写简便,编辑器检测语法时不报错。
2、自执行函数是没有名字的函数,自执行函数本身是不会进行变量的提升的。
3、关于[] == ![]请看相关文章: [] == ![]结果为true的追根刨底
4、我们知道当主程序执行时,首先是开辟一块全局作用域,紧接第二步是词法解析以及对var、function修饰的变量进行提升,第三步才是逐行执行。同理,在分步执行中,当程序遇到到函数调用时,此时会形成函数作用域,跟全局作用域的形成一样,也是首先开辟一块栈内存作为私有作用域,第二步也是先进行词法解析以及对var、function修饰的变量进行提升,随后才逐行执行函数体。
此题解析:此主代码执行时,在全局空间由于没有var与function,故无变量提升(虽有一个function,但它是自执行函数,而自执行函数本身是不会进行变量的提升的),当程序执行到达自执行函数时,自执行函数也是函数,而函数执行时会开辟一块供函数执行的私有作用域(或称执行环境),在这个私有作用域内也会进行变量提升,在条件判断语句中有一个:function g()....,根据条件语句块中的函数在变量提升阶段只会提前声明,不会提前定义的原理,故if(g() && [] == ![]){...中的g()的值是undefined(有声明无定义),还不是一个真正的函数,故执行g()就会产生错误:Uncaught TypeError: g is not a function
4、变量提升、词法解析和暂时性死区
4.1 变量提升
因var命令会发生“变量提升”现象,所以变量可以在声明之前使用,虽然值为undefined,不会报错。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。
为了纠正这种现象,let命令改变了这种语法的怪异行为,它所声明的变量一定要在声明之后使才可用,否则报错。
console.log(foo); // 输出undefined
var foo = 2;
// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;
上面代码中,变量foo用var命令声明,会发生变量提升,即脚本开始运行时,变量foo已经存在了,但是没有值,所以会输出undefined。变量bar用let命令声明,不会发生变量提升。这表示在声明它之前,变量bar是不存在的,这时如果用到它,就会抛出一个错误。
4.2 暂时性死区
只要语句块中存在let命令,此语句就块会形成块级作用域,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
var tmp = 123;
if (true) {
tmp = 'abc'; // ReferenceError
let tmp;
}
上面代码中,虽然存在全局变量tmp,但是块级作用域内let又声明了一个局部变量tmp,实质上这是两个tmp变量。虽然全局tmp的作用域是在全局中,但由于在if这个块中存在一个局部的tmp,也就排斥了或者说屏蔽了全局的tmp,在这个块中如要用tmp,就只能访问到局部的tmp,相当于这个局部的tmp绑定到了这个块级作用域上,再加之这个局部的tmp是用let修饰的,所以在let声明前,对tmp赋值会报错。
ES6 明确规定,如果区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前使用这些变量,就会报错。
总之,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。这个死区就是从当前作用域开始到let或const声明这个变量之前的这个区域。
if (true) {
// TDZ开始
tmp = 'abc'; // ReferenceError
console.log(tmp); // ReferenceError
let tmp; // TDZ结束
console.log(tmp); // undefined
tmp = 123;
console.log(tmp); // 123
}
上面代码中,在let命令声明变量tmp之前,都属于变量tmp的“死区”。
下面的代码也会报错,与var的行为不同。
// 不报错
var x = x;
// 报错
let x = x;
// ReferenceError: x is not defined
上面代码报错,也是因为暂时性死区。使用let声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x的声明语句还没有执行完成前,就去取x的值,导致报错”x 未定义“。
ES6 规定暂时性死区以及let、const语句不进行变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。
总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现之后,才可以获取和使用该变量。
4.3 词法解析
另外,有一种浏览器的BUG,也称为暂时性死区,比如,一个程序里只有一行代码:
console.log(typeof a); //=> undefined
按理,变量a是根本没有被声明的不存在的,typeof是该报错的,而运行时不但不会报错,还会输出undefined,在没有let
之前,typeof
运算符是百分之百安全的,永远不会报错。这是浏览器的BUG,而浏览器厂商不承认这个错误,给其取了一名字说这是暂时性死区。但如果我们使用let或const来声明这个变量a:
console.log(typeof a); //=> Uncaught ReferenceError: Cannot access 'a' before initialization
let a;
现在运行时就会报错了:
错误是:引用错误,a不能在初始化之前访问获取。
这实际上是符合前面所说的:只有等到声明变量的那一行代码出现之后,才可以获取和使用该变量。也就是说,现在我们使用let就能解决浏览器厂商所说的那个暂时性死区问题。
问题是,原先只有一行代码console.log(typeof a),按理typeof是该感知到a的不存在却感知不到,而在后面加上let a;语句后为什么就能感知到呢?也许我们会说这个问题是很简单的,在ES6之前typeof是不管那些的,也就是说它是不会去感知的,后来使用带有let命令的ES6语法后,浏览器就加上这个感知功能了,所以能感知出来。当然事实也是如此,我这里要说的不是这点,而是ES6中浏览器到底是在程序执行的什么时候发挥这个感知功能的,答案是:在词法解析阶段,也就是说在ES6中,虽然let变量不会提升,然而在词法解析时,会把let变量绑定到当前作用域上,那么等到程序开始自上而下执行时,由于在此之前就绑定a了,也就能感知到变量a了。
词法解析,或者说是语法检查,我们无须关注很多,我们是通过这个小例子来让大家揣测一下词法解析是怎么一回事即可了。
关于let和const命令,请看阮一峰编著的《ECMAScript 6 入门》中的第2节:let和const命令,地址为:ES6 入门教程