JavaScript:聊聊变量声明的var、let、const

2,789 阅读13分钟

前言

一个程序语言在运行的过程中,变量的声明在整个程序的生命周期中,是不断在进行的过程。任何程序的计算都会涉及至少一个变量,而计算的结果的则可能会涉及到另外的一个或者多个变量。变量在使用前是要声明,变量声明的过程在计算机的底层,牵涉到的是内存空间和内存地址的分配。当然啦,有内存空间的分配,就会有内存空间的回收和再分配。可以看出,变量声明是我们在接触一门程序语言中起始的也是很关键的一步。

而本篇文章想跟大家聊聊的,是关于JavaScript的变量声明中的一些内容。

内容

JavaScript的变量声明

虽然一门语言的语法发展,变量声明的语法和方式通常是比较起始的。程序语言的设计者通常都会在语言的初始的版本尽量根据自己对语言的设计思想,设计好语言的变量声明的语法和方式,以保证好程序语言在未来的很长的道路上有一个比较好的起始。但是,JavaScript却没有一个这么好的起始。

JavaScript的设计之初衷,就是一门为了解决简单的网页互动的脚本语言。它的设计,只用了短短的10天时间。就像初生的婴儿一样,稚嫩而没有太多的规划和愿景。设计者做梦都没有想到,JavaScript将来可以在整个互联网的发展中占有这么大的一个分量,不仅在浏览器端,而且在移动端、APP端、服务端、桌面应用上都扮演着重要的角色。

JavaScript的设计的雏形是稚嫩的,但是随着这么语言逐渐的受到关注和使用,语言本身的设计和语法,也在不断的发展着。而JavaScript的变量声明方式也从野蛮的var的声明方式,前行到了ECMAScript标准的letconst的声明方式。

接下来,我们一起来讨论它们。

var的变量声明

var用于声明一个函数范围或者全局范围的变量,并且可以为其初始化一个值。它是ECMAScript2015之前的变量声明的唯一关键字,曾经承载过一代前端开发者的青葱岁月以及水深火热。它具有如下的特性:

变量声明在函数作用域中

与大多数的语言不同,作为过去的JavaScript的唯一变量声明方式,var的所声明的变量并不是声明在块级作用域中 ,而是声明在最近的函数上下文中,也就是局部函数作用域。对于未在任何函数中声明的变量,其声明范围则是在全局对象global之中。

/* === 全局作用域 start === */
/*
  变量globalVar声明在任何函数之外,为全局作用域中的变量
  任何的函数和方法都能访问到这个变量
*/
var globalVar = 'global var';

/* === f1函数作用域 start === */
function f1() {
  /*
    变量localVar1声明在函数f1中,为f1的局部函数作用域中的变量
    只有代码词法分析中的f1函数的内部函数能够访问到这个变量
    在这个作用域中可以访问到全局作用域中的变量globalVar
  */
  var localVar1 = 'local var1';
  /* === f2函数作用域 start === */
  return function f2() {
    /*
      变量localVar2声明在函数f2中,为f2的局部函数作用域中的变量,
      只有代码词法分析中的f2函数的内部函数能够访问到这个变量,
      在这个作用域中可以访问到全局作用域中变量globalVar以及外层f1函数作用域中的localVar1
    */
    var localVar2 = 'local var2';
    return localVar2;
  /* === f2函数作用域 end === */
  }
/* === f1函数作用域 end === */
}

f1();
/* === 全局作用域 end === */

上面的代码中,globalVar作为定义在全局对象global中的一个全局变量,除了可以直接通过变量标识符globalVar访问到之外,也可以通过global.globalVar或者window.globalVar的对象属性访问方式来访问到。

同时还能看到,上面的作用域定义构建成了一条global -> f1 -> f2的函数作用域链, 函数执行过程中的变量访问会根据这条函数作用域链进行规则查找和访问。

变量重复声明

var的变量声明支持在同一作用域中进行同一个变量的多次重复的声明,多次声明中只有首次的变量声明会被执行,其他声明会因为当前的执行上下文对象中已存在当前变量而被忽略。但是,声明变量同时如果对变量进行了初始赋值,赋值操作依旧会被执行。

/*
  变量的首次声明在当前执行上下文对象中新增一个value变量
*/
var value = 1/*
  除了变量的首次声明外,其他相同的声明,都不会被执行,
  但是,声明中的初始赋值依旧会被当作正常的赋值操作执行
*/
var value = 2console.log(value); // 2

变量声明提升

var的变量声明存在“提升”(hoisting)的特性。JavaScript在执行函数代码的前,首先会对函数内当前执行上下文中的所有var的变量声明进行扫描确认,并将所有的声明按顺序提前到当前执行上下文的顶部,也就是函数内的顶部。这种变量声明提升的特性,让我们在同一作用域中的可以直接访问和使用一个在后续的代码才进行了首次变量声明的变量,即在变量声明之前使用变量。

虽然,变量的声明在执行时被提升了,但是,变量声明中的初始赋值操作却并没有被提升,从而形成一种声明和初始赋值的执行逻辑上的割裂。也就是说,即使我们可以直接访问和使用在后续的代码才被声明和赋值的变量,然而,变量的值却是undefined。代码一直执行到声明和初始赋值语句后,变量的值才会被赋值为它的初始赋值。

/*
  由于变量声明提升了,可以在变量声明前访问使用变量,
  但是,访问到的变量值是undefined
*/
console.log(value); // undefined

/* 变量赋值并没有被提升,只有执行完这一句语句,变量才被赋值为1 */
var value = 1;

console.log(value); // 1

怪异危险的var

var的这些的怪异的特性,虽然在程序上都属于合法可运行,但是,对于编写程序的人来说,有着许多有违正常逻辑的地方,而且容易造成代码调试的困难。例如:面对变量的声明和管理,我们不得不以函数为基本的管理区进行管理;对变量的重复声明,让我们对代码从上而下的变量的安全访问和变量值的确认变得不容易控制;

虽然,为了防止这些危险的出现,我们可以通过编程习惯的方式来进行约束。但是,这些终究只是软约束,随着JavaScript的不断发展和使用来构建复杂的应用,这些约束就愈加捉襟见肘。

letconst的变量声明

黑暗和混沌,终于迎来了曙光的到来。ECMAScript2015的到来,就像一束光辉照耀进来,给JavaScript变量声明这一块带来了翻天覆地的变化。不仅带来了letconst这两个新的变量声明关键字,而且还直接一跃变成了最佳的变量声明方式。

而这些惊天动地变化的出现都是因为,相对于varletconst带来了如下新的共同特征:

块级作用域

letconst关键字带给我们的第一个让人兴奋的特性就是块级作用域。苦于之前var关键字的基于函数作用域的特性,我们不得在函数范围内小心的定义变量名称和使用变量,特别是在条件判断和循环逻辑中,甚至不得不在循环中使用上立即执行函数来进行变量值的读取和保存。

function f() {
  /*
    由于var基于函数作用域,回调函数内的i变量都为同一个副本,
    下面程序的输出结果为5、5、5、5、5
  */
  for (var i = 0; i < 5; i++) {
    setTimeout(() => { console.log(i); });
  }
    
  /*
    通过立即执行函数来构建基本数据类型的值传递,从而创建新的变量副本,
    下面程序的输出结果为0、1、2、3、4
  */
  for (var i = 0; i < 5; i++) {
    (function(i) {
      setTimeout(() => { console.log(i); }, 0);
    })(i);
  }
}

f();

现在,我们可以通过花括号{}以及letconst关键词声明限制于块级作用域中的变量了。if块、while块、function块甚至单独的{}都是良好的块级作用域声明区块。还有一点,相对于var,在全局作用域中使用letconst声明的变量并不会添加到全局上下文对象global中。综合看来,块级作用域的特性使我们的变量的声明和使用更加的简洁和安全。

/*
  由于使用的基于跨级作用域的let,每次循环体都会产生一个i变量的副本,
  下面程序的输出结果为0、1、2、3、4
*/
for (let i = 0; i < 5; i++) {
  setTimeout(() => { console.log(i); }, 0);
}

if (true) {
  let a = 1;
}
/*
  抛出ReferenceError,
  因为用let定义的a变量限制于块级作用域,只在前面方括号中可以访问
*/
console.log(a); // ReferenceError

/*
  let和const声明在全局作用域中声明的变量,不会被隐晦添加到global对象中,
  保证了全局环境的安全,
*/
let b = 1;
console.log(window.b); // undefined

不可重复声明

相比于var的重复声明会被忽略,letconst在同一作用域中对同一标识符的变量不能重复声明。这种变量声明执行上的约束,避免了同作用域下不同位置的变量声明冲突,消除了许多不容易预见的运行问题,让JavaScript在构建复杂应用中的变量声明过程变得更加的安全。

let a = 1;
{
  /* 不在同一块级作用域内,而在子级块级作用域内 */
  let a = 1;
}
/*
  和最上面的声明在同一块级作用域内,属于重复声明,
  这条语句执行会抛出SyntaxError
*/
let a = 1; // SyntaxError

暂时性死区

相对于var的变量声明提前导致的变量在函数作用域中可以“先使用后声明”的现象。letconst关键字声明的变量也具有执行中变量声明提前的特性,但是却不能在变量完成声明之前进行访问和修改,否则会抛出ReferenceError的错误。

这种变量声明所绑定的块级作用域的顶部,一直到变量声明语句执行完成之前的,变量不能访问和修改的区域,称为这个变量的暂时性死区(TDZ,temporal dead zone)。暂时性死区的引入,在代码执行上约束了变量必须遵循“声明后才能使用”的原则,使JavaScript的变量声明和使用更加安全和具有更好的可读性。

{
  /* === 变量a和b的TDZ start === */
  /*
    抛出ReferenceError,
    TDZ范围内不能读取和修改变量b
  */
  console.log(b); // ReferenceError
  let a = 1;
  /* === 变量a的TDZ end === */
  let b = 2;
  /* === 变量b的TDZ end === */
  console.log(a); // 1
}

值得注意的是,暂时性死区是基于执行顺序(时间)上的,而不是编写代码顺序(位置)上的。只要变量的访问和修改的代码的执行,是在变量声明之后,就是合法的。

{
  /* === 变量value的TDZ start ===*/
  function f() {
    console.log(value);
  }

  /* 在DZT内访问和修改变量value,会抛出错误 */

  let value = 1;
  /* === 变量value的TDZ end ===*/
  /*
    函数f中对value的访问发生在TDZ外面,
    所以访问合法
  */
  f(); // 1
}

使用好letconst

letconst两者的这些新的共同特性,让它们直接变成了变量声明的最佳方式和实践。但是,两者的除了上面说道的共同特性,还存在一个两者的差异。

简单来说,letconst的区别在于,let用于声明基于块级作用域的变量,而const用于声明基于块级作用域的常量。

  • 使用let声明的变量,在变量的整个生命周期中,能够对变量随时进行赋值修改。
  • 使用const声明的变量为引用常量,必须在声明的同时进行初始赋值,在变量的整个生命周期中,无法再通过赋值的方式来修改变量值。

const的这个无法再赋值的特性,在不同的变量类型下会有不同的表现:

  1. 如果const声明并且初始化赋值是基础数据类型变量(StringNumberBooleanSymbol),那么该变量之后就不能再进行任何值的修改了。因为基础数据类型变量的值必须通过变量赋值来进行修改。
  2. 如果const声明并且初始化赋值是引用数据类型变量(ArrayObjectMapSet),那么我们则可以随意对该变量的字段属性进行修改。因为引用数据类型变量的内容修改(新增、修改、删除属性)并不会出现所声明变量的赋值操作.
/* let声明的变量可以任意赋值和修改 */
let value1 = 1;
value1 = 2;

/*
  抛出SyntaxError,
  const声明的变量必须赋值初始值
*/
const value2; // SyntaxError

/*
  抛出TypeError,
  const声明的变量在整个变量生命周期内不能再赋值
*/
const value3 = 3;
value3 = 4 // TypeError

/* const声明的引用数据类型变量,依旧可以修改其字段值 */
const value4 = {
  key1: 1,
  key2: 2,
};
value4.key = 3

在实践中,我们应该尽可能的多使用const进行变量声明,当确定一个变量需要重新赋值的时候才将其改用let进行声明,这样可以让我们对程序执行中的可能发生重新赋值的变量有一个清晰的了解,减少可能出现的bug。

总结

ECMAScript2015带来了letconst这两个新的变量声明关键字,带来了块级作用域和暂时性死区等新特性,解决过去var的变量声明中的一些怪异问题,在语法层面和运行层面上,保证了变量声明的“声明后才可以使用”的安全特性,提高了JavaScript在编写大型应用时的变量声明和使用安全。

当前,letconst已经是我们日常开发中的变量声明的最佳时间方式。了解它们,才能用好它们。

参考资料

  • 《JavaScript高级程序设计》
  • 《你不知道的JavaScript》
  • MDN