7

我在上一篇文章javascript中词法环境、领域、执行上下文以及作业详解中的最后稍微提到了有关var、let、const声明的区别,在本篇中我会重点来分析它们之间到底有什么不同。

提到var、let、const中的区别很多人一下子就想到了,var声明的变量是全局或者整个函数块的而let、const声明的变量是块级的变量。var声明的变量存在变量提升,let、const声明的变量不存在变量提升。let声明的变量允许重新赋值,const声明的变量不允许重新赋值。那么它们之间真的只有这么一点区别吗,我们先来看下面一个例子:

注:本篇文章中的所有例子都以最新版chrome浏览器为标准(低版本浏览器实现会有区别)。

//我们看一下这三句话,你认为会发生什么
let let = 1;
console.log(let);
//
const let = 1;
console.log(let);
//
var let = 1;
console.log(let);

很多人会认为,let是关键字,上面这三句声明都会报错。可事实真的是这样吗?不是。let、const的声明会报错,但是var声明被认为是规范的,更重要的是let、const声明报错的原因也不是因为let是关键词而是由于ECMAScript语言规范中规定了当用let、const声明时如果标识符是let则报错。

该代码是运行在非严格模式下的,严格模式则报错,值得注意的是严格模式下上面三句话都是因为标识符let是保留字而报错的。有兴趣可以在严格模式和非严格模式下测试let let = 1;报错原因是不同的。

下面的所有代码都在非严格模式下进行,如果是严格模式我会明确指出。

那么上面三句话中的标识符let改为const会怎么样?无论是严格模式还是非严格模式都报错,错误原因是因为const是关键字,这时候问题又来了,为什么标识符let和const的行为会不同呢?这个锅说到底还是得ES5规范背,在ES5规范中const被认为是未来保留字(FutureReservedWords)而let只有在严格模式下才被认为是未来保留字,这导致var可以声明let却不能声明const,那到了ES6时代为什么不改呢?哎!不是不改而是心有力而余不足啊,鬼知道在ES6时代之前有多少代码中出现过var let这个声明啊,这要是改了得有多少网站得炸啊。

基于上面的原因,你看到下面的代码时不要惊讶:

var let = 1;
console.log(let);                  //1
let a = 2;
console.log(a);                   //2
//看着怪异但是完全可以工作,不会有任何错误

看完上面一个不同点,我们再看下面这个例子:

var a;
console.log(a);                    //undefined
//
let a;
console.log(a);                    //undefined
//
const a;
console.log(a);                    //?

我们都知道如果var和let只声明变量而不赋值,那么默认赋值undefined,那么const会怎样呢?
你在Chrome控制台上试一下就知道了,语法错误缺少初始化,ES6规范指出const声明的标识符一定要初始化赋值,这不是运行时错误,这是个早期错误,编译器在执行脚本之前会检测早期错误。

我们接着看下一个问题:

let a = 1;
let a = 2;

var可以重复声明变量,那么let和const可以吗?答案是不可以。你可以认为let和const声明的变量名称在该作用域内是唯一的,不能重复声明。那如果用var可以覆盖let声明的变量吗?答案是不能。不管你是let或const先声明变量var后面重复声明,还是var先声明变量let或const后声明都会报错。这个错误是一个早期错误。

注意:let/const跨脚本声明重复变量也会报错。但这个时候的错误被认为是运行时错误,不是早期错误。上面所指的let/const声明都指在同一作用域下。

块(Block)

上面列出了var、let、const静态语义上的区别。在该小节中我会讲述在javascript内部它们之间的不同,不过在此我们先要了解(块)Block,可以说let、const是因为Block存在的。
不过提到Block之前我们需要花几分钟了解几个名词:

我拿个例子简单说明一下:

//全局声明
var a=1;
let b=1;
const c=1;

function foo(){};
class Foo{};
{
   //块级声明
   var ba=1;
   let bb=1;
   const bc=1;

   class BFoo{};
   function bfoo(){}
}
  1. LexicallyDeclaredNames(词法声明名称列表):« bb,bc,bfoo,BFoo »
  2. LexicallyScopedDeclarations(词法作用域声明列表):« let bb=1,const bc=1,function bfoo(){},class BFoo{} »
  3. VarDeclaredNames(var声明名称列表):« ba »
  4. VarScopedDeclarations(var作用域声明列表):« ba=1 »
  5. TopLevelLexicallyDeclaredNames(顶级词法声明名称列表):« b,c,Foo »
  6. TopLevelLexicallyScopedDeclarations(顶级词法作用域声明列表):« let b=1,const c=1,class Foo{} »
  7. TopLevelVarDeclaredNames(顶级var声明名称列表):« a,ba,bfoo »
  8. TopLevelVarScopedDeclarations(顶级var作用域声明列表):« a=1,ba=1,function foo(){}»

注:« »结构是ECMAScript中的一个规范类型,表示一个List,具体你可以认为它是一个类数组(当然实际肯定不是,只是方便理解)

有没有看到怪异的地方?function声明在顶级作用域(TopLevel)中被视为var声明,而不在顶级作用域也就是Block或catch块中被认为是词法声明,这就导致了一些有趣的事情。
Block只有前四个列表,函数(function)和脚本(script)只有后四个列表(其实函数和脚本也只有前四个,不过前四个列表的值取的是后四个列表的值)。Block虽然有自己的作用域但是它和函数有着本质上的区别。函数和脚本你可以看成是相互独立的而Block是属于function和script的一部分。具体就是Block中的var声明同时也被认为是顶级声明,不管你嵌了多少层块在里面都不会变,因为Block没有顶级作用域。

理解了上面的8个名称,我们再来看看Block中的声明与function和script中有何不同:

  1. LexicallyDeclaredNames中如果包含任何重复项,则语法错误。
  2. LexicallyDeclaredNames中出现的任何元素在VarDeclaredNames声明中出现,语法错误。

规则1很正常,LexicallyDeclaredNames这个列表里不能有重复项,即不能重复声明。
规则2这就很有意思了,我们上面说到了在Block中function声明属于词法声明,于是你会在Block中看到:

{
  var foo=1;
  function foo(){}        
//Syntax Error,var和function不能声明同一个标识符,脚本和函数中是不存在这个问题的。

//我大胆推测一下,可能在不久的将来脚本和函数中var和function也不能声明同一个标识符了。
}

补充规则1中function声明

{
  function a(){};  
  function a(){};      //it's ok,no syntax Error
}
//-----------------------
'use strict';
{
  function a(){};  
  function a(){};      //error, syntax Error redeclaration a; 
}

这里我不得不吐槽一下了,就因为在非严格模式下Block中的function可以重复声明害我以为规范1我理解错了,导致我把文档中有关Block规范说明部分翻来覆去看了好几遍,最后我才在规范文档的附录中找到原因:为了实现网页浏览器的兼容性,允许在非严格模式下的Block中的function可以重复声明。

这里有个建议,最好永远不要在一个作用域内同时使用var和let/const声明,还有不要在Block中使用var声明,至于Block中的function声明,除非你确切的知道你需要这个function做什么,否则也不要在Block中使用function。Block中的function是如此的怪异。

1.非严格模式下,block中的function声明的标识符会被提到顶级作用域下,但是只提标识符,并赋值undefined,不提函数体。你可以把它看成是一个var声明的变量,具体如下:

console.log(foo);            //undefined
{
   function foo(){
      console.log(1);
   }
}
foo();                      //1

2.非严格模式下,block中的function声明的函数对象对这个block来说形成了一个闭包,我认为‘闭包’这个词是最好的解释:

var a = 'outer a';
{
   let a = 'inner a';
   function foo(){
      console.log(a);
   }
}
console.log(a)              //outer a
foo();                      //inner a,     not outer a

3.严格模式下,block中的function声明只能在block中访问到,离开这个block无法访问:

'use strict';
console.log(foo);            //Uncaught ReferenceError: foo is not defined
{
   function foo(){
      console.log(1);
   }
}
foo();                       //Uncaught ReferenceError: foo is not defined

出现这种情况是因为ES5之前,block中不能出现function声明,但是不同的浏览器实现不一样,到了现在只能通过浏览器扩展进行填补。在非严格模式下,编译器进行全局声明实例化是也就是上篇文章中说道的GlobalDeclarationInstantiation方法时会对block、switch中case和default语句中的function声明进行额外的操作,如果function声明的标识符在全局环境下没有找打其它的词法声明名称即在TopLevelLexicallyDeclaredNames列表中不存在function声明的标识符,则在全局环境记录下创建function绑定,但是设置的值不是声明的函数体而是是undefined。函数中有相似的操作。

block中的一些注意点以及和function还有script中的区别我大致讲了一下。那么block是如何做到有块级作用域的功能的呢?
我在上一篇文章中讲到了执行上下文,提到执行上下文是编译器用来跟踪代码执行时评估的一种规范设备,每个执行上下文都有自己的LexicalEnvironment和VariableEnvironment组件。编译器在评估Block做了如下操作:

  1. 让oldEnv成为正在运行的执行上下文(running execution context)的LexicalEnvironment。
  2. 让blockEnv成为一个新的声明性环境,它的外部词法环境引用指向oldEnv。
  3. 对block中的声明进行实例化。
  4. 把正在运行的执行上下文(running execution context)的LexicalEnvironment设为blockEnv。
  5. 让blockValue成为执行block中的代码的结果。
  6. 把正在运行的执行上下文(running execution context)的LexicalEnvironment设为oldEnv。
  7. 返回blockValue。

我们看到了执行block中代码时不会新建执行上下文,它只是改变了正在运行的执行上下文的LexicalEnvironment组件值,block运行完成后又恢复成以前的LexicalEnvironment组件,这指明了block中声明的变量只在该block中起作用,这也表示为什么block是块级作用域。这跟函数不一样,执行函数时会创建新的执行上下文。
我这再说明一下,步骤3中的声明进行实例化指得是LexicallyScopedDeclarations列表中的声明,block不会对其中的var声明进行操作。步骤5中的blockValue指得是block中最后一个语句执行后的返回值。

知道了这个,我们来看个let和var在Block中的不同:

for(var i = 0;i < 10;i++){
   setTimeout(function(){console.log(i)})
}
//输出10个10

for(let i=0;i<10;i++){
   setTimeout(function(){console.log(i)})
}
//输出0到9

我这边做个简单说明:

  1. 把全局环境记录记gec,for循环里的环境记录记为bec,匿名函数的环境记录记为fec。
  2. gec的外部环境null,bec的外部环境gec,fec的外部环境bec。
  3. 第一个for循环中函数输出i,fec中没有i的记录,向外找bec,没有i的记录,向外找找gec,发现i,值为10,所以输出10个10。
  4. 第二个for循环中函数输出i,fec中没有i的记录,向外找bec,找到i的记录,并输出i,这个i是当前bec记录中i的值,每次循环都会创建一个新的bec记录。

变量提升(Hoisting)

我们都知道var和function声明在作用域内存在着变量提升,但是let/const或者class呢?究竟有没有存在变量提升。这个问题存在着争议,可谓仁者见仁智者见智。

我在上篇文章中提到了全局声明实例化和block中的block声明实例化以及没有提到的function声明实例化,你会发现一个关键,就是这些操作都是在执行代码之前做的,全局声明实例化在脚本执行之前进行,block声明实例化在block中的代码执行之前进行,包括函数也是如此。那么声明实例化究竟是做什么的呢?

具体的操作就是把存在LexicallyScopedDeclarations、VarScopedDeclarations、TopLevelLexicallyScopedDeclarations和TopLevelVarScopedDeclarations的信息进行操作,存到环境记录中。这些词都是静态语义,也就在在脚本执行之前就已经存储了。

var a = 1;
let b = 1;
//执行代码前环境记录(Environment Record)绑定了a,b,并给a赋值为undefined,b不赋值。
//注:let、const和class只绑定(实例化)不初始化,var和function会进行初始化,function初始化指的就是整个函数。

//执行代码时----------------
console.log(a);      //undefined   环境记录中有a的这个绑定,并且值是undefined,所以输出undefined
var a = 1;

//----------------
console.log(a);      //Uncaught ReferenceError: a is not defined   环境记录中有a的这个绑定,但是没有值,所以error。
//可能a is not defined改为a is not initialized更能让人容易理解。
// not defined容易和undefined混淆。
let a = 1;

//一个更好的例子
var a = 1;
{
    console.log(a);        //Uncaught ReferenceError: a is not defined,not value 1;
    let a = 2;             //let声明的变量实际上也提升了
} 

正是这样原因导致“变量提升”存在争议,一部分人认为let、const、class和var一样,在一开始就已经提升了,所以let、const、class存在“变量提升”。有的人认为所谓“变量提升”,是指代码不报错,还能运行,而let、const、class会出现错误,所以不能算“变量提升”。

ECMAScript规范一直没有给出准确的说明,甚至不同版本说法不一样,在最新的ES8规范中虽然没有给出准确的说明,但是规范定义了一个HoistableDeclaration文法,该文法中包含了FunctionDeclaration、GeneratorDeclaration和AsyncFunctionDeclaration文法。HoistableDeclaration文法又与ClassDeclaration和LexicalDeclaration(let/const的语法规则)文法组成Declaration文法。

这里是不是可以推断出ECMAScript规范认为let、const和class不存在“变量提升”呢。当然这只是我的一个推测。

结束语

到这里let/const和var的解释基本就完结了。我大致的对let/const以及var做了一个区别介绍,但是还有很多小的细节不能涵盖到,如果感兴趣想了解更多的话可以查看官方文档13.2 Block13.3 let/const和var
算上最开始的javascript强制转化,这是我对ES8文档讲解的第三篇文章,之后我会陆续发表一些我对ES8文档的理解,希望能与人一起交流共进。


fanqifeng
536 声望24 粉丝

javascript语言爱好者,喜欢发掘js中的特异之处。现阶段沉迷购书无法自拔。