跳转到内容

第二章 Let 和 const 命令

第二章分别讲解了 letconst 两个用于声明变量的命令。并介绍了他们与 var 之间的异同点。下面来分别依次介绍。

let 命令

书中从 基础用法、变量提升、暂时性死区、重复声明 四个方面做了讲解。

基础用法

let 用于声明变量,与 var 相比,let 声明的变量只在代码块内有效;var 声明的变量在全局范围内都有效。这是一个作用域的概念,这个在后续更详细介绍。

有一个经典的 bug 如下:

js
for (var i = 0; i < 10; i ++) {
  a[i] = function() {
    console.log(i)
  };
}
a[6]() // 10
console.log(i) // 10。var 声明的,在全局范围内都有效,所以全局只有 一个变量 i。 每一次循环,变量 i 的值都会发生改变,而循环内,被赋给数组 a 的函数内部的 console.log(i)中的 i 指向全局的 i。也就是说,所有数组 a 的成员中的 i 指向的都是同一个 i,导致运行时输出的是最后一轮的 i 值,也就是 10

// ------------------------------------

for (let i = 0; i < 10; i ++) {
  a[i] = function() {
    console.log(i)
  };
}
a[6]() // 5
console.log(i) // i is not defind

变量提升

var 存在变量提升的现象,即变量可以在声明之前使用,值为 undefined

let 命令改变了语法行为,它所声明的变量一定要在声明后使用,否则便会报错。也就是说,let 不允许变量提升行为。

js
// var的情况
console.log (foo); // 输出 undefined
var foo = 2;

// let的情况
console.log(bar); // 报错ReferenceError
let bar = 2;

暂时性死区

若在块级作用域中存在 let 命令,它声明的变量就 “绑定” 这个区域,不再受外部影响。

ES6 规定,若使用 letconst 声明变量,则在变量声明前都不可使用该变量,否则报错。在语法上被称为暂时性死区( temporal dead zone,简称 TDZ)。示例代码如下:

js
while (true) {
  // TDZ 开始
  tmp = 'abc'; // ReferenceError
  console.log (tmp); // ReferenceError
  
  let tmp; // TDZ结束
  console.log (tmp) ; // undefined
 
  tmp = 123;
  console.log(tmp); // 123
}

有一些死区比较隐蔽,如:

js
function (x = y, y = 2) {
  console.log(x); // ReferenceError。因为x用到了y变量赋值,而y变量在其后面声明,因此是死区
}

let x = x // ReferenceError

🧬 本质

只要进入当前作用域,所要使用的变量就己经存在,但是 不可获取,只有等到声明变量的那 一行代码出现 ,才可以获取和使用该变量。

重复声明

let 相比 var ,还有一个不同点是不允许重复声明。

js
var a = 1;
var a = 2;
console.log(a); // 2

let b = 1;
let b = 2; // 报错

作用域

作用域包含了 作用域作用、ES6 的块级作用域、块级作用域与函数声明、do 表达式 四个模块进行讲述。

作用域作用

阮一峰老师从 ES5 的作用域 入手,通过对比解释了 ES6 作用域的作用。在 ES5 中,只存在 全局作用域函数作用域 两种,很多场景都显得很不合理。如:

  • 场景一:内部变量覆盖外部变量

    js
    var a = 1;
    
    if (true) {
      var a = 2;
    }
    
    console.log(a); // 2
  • 场景二:循环变量全局泄漏

    js
    for (var i = 0; i <= 3; i++) {
      // ...
    }
    console.log(i); // 4

ES6 的块级作用域

let 为 JavaScript 提供了块级作用域。外层作用域无法访问内层作用域的变量;内层作用域可以定义外层作用域同名变量且不会覆盖。

js
let a = 1;

if (true) {
  let a = 2;
  console.log(a); // 2
}
console.log(a); // 1

🖇 拓展

在 ES6 块级作用域出来前,想要实现上述方案,需要用到立即执行函数(IIFE),代码如下:

js
(function () {
  var tmp = '...'
  // ...
})

块级作用域的出现让立即执行函数的写法不再必要。

块级作用域与函数声明

在 ES5 中,函数只能在顶层作用域和函数作用域中声明,不能在块级作用域中声明。但是由于浏览器的兼容性考虑,在一些旧的浏览器中,函数声明在块级作用域中仍然是有效的。

ES6 引入了块级作用域,并明确允许在块级作用域中声明函数。在 ES6 规定中,块级作用域内声明的函数的行为类似于使用 let 声明的变量,只在块级作用域内有效,对作用域之外没有影响。

然而,在实际运行中,不同的浏览器对于块级作用域内函数声明的处理方式可能不同。ES6 规范允许浏览器的实现可以不遵守块级作用域内函数声明的规定,而有自己的行为方式。具体来说,这些浏览器会将块级作用域内的函数声明提升到全局作用域或函数作用域的头部。

💡 总结

  • 在 ES5 中,函数不能在块级作用域中声明,但一些浏览器为了兼容性支持在块级作用域中声明函数。
  • 在 ES6 中,函数可以在块级作用域中声明,但不同浏览器对此的处理方式可能不同。

为了避免代码的可读性和兼容性问题,应该避免在块级作用域内声明函数。如果确实需要,建议在块级作用域中使用函数表达式或箭头函数来代替函数声明。

js
{
  let a = 1;
  let f = function () {
    // ...
  }
}

在 ES6 中,块级作用域允许声明函数的规则只在使用大括号的情况下成立。如果没有使用大括号,就会导致语法错误。

具体来说:

  • 在使用大括号包裹的块级作用域内声明函数是有效的。
  • 在没有使用大括号包裹的情况下声明函数会导致语法错误。
js
if (true) {
  function f() {} // 不报错
}

if (true) function f() {} // 报错

因此,需要注意在 ES6 中声明函数时,确保在块级作用域内使用大括号来包裹函数声明,以避免出现语法错误。

do表达式

在ES6中,块级作用域引入了 letconst 关键字,使得在代码块中声明的变量可以在该代码块外部使用。但是,由于块级作用域没有返回值,当我们需要在代码块外部获取代码块中计算结果时,就需要将变量定义在全局作用域中。

为了解决这个问题,提案(do expressions)提出了一种新的语法形式:do 表达式。使用 do 表达式可以将一段代码块封装成一个表达式,并且可以在该表达式后直接返回一个值。因此,我们可以使用do表达式来获得代码块中的计算结果,并将其赋值给变量。

例如,假设我们需要计算一个数的平方并加上另一个数,我们可以使用如下的代码:

javascript
let t = 2;
let l = 3;
let r = do {
  let x = t * t;
  x + l;
}
console.log(r); // 输出 7

以上代码中,我们使用do表达式将两个操作封装在一起,并将计算结果赋值给变量r。最终,我们可以在代码块外部获取变量r的值。

const 命令

书中从 基本用法、本质、声明变量的6种方法 三个方向做了详细介绍,其中:

基本用法

  1. const 声明的是一个只读的常量,声明后该变量不可改变
  2. const 声明常量不可改变,因此在声明时就必须立即初始化,否则报错
  3. const 声明的变量不存在变量提升,同时也存在暂时性死区
js
const a = 1;
a = 2; // TypeError: Assignment to constant variable

const foo; // SyntaxError: Missing initializer in const declaration

console.log(tmp); // ReferenceError
const tmp = 123;

本质

const关键字实际上保证的是变量指向的内存地址不得改动,对于简单类型的数据(如数值、字符串、布尔值),其值保存在变量指向的内存地址中,因此等同于常量。

但是对于复合类型的数据(主要是对象和数组),变量指向的内存地址保存的只是一个指针,因此 const 只能保证这个指针是固定的,至于它指向的数据结构是否可变,这完全不能控制。

因此,当将一个对象声明为常量时必须非常小心。例如:

javascript
const foo = {};
// 为foo添加一个属性,可以成功
foo.prop = 123;
console.log(foo.prop); // 输出 123

// 将foo指向另一个对象,就会报错
foo = {}; // TypeError: "foo" is read-only

上面的代码中,常量 foo 储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把 foo 指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。

另外,如果真的想将对象冻结,应该使用 Object.freeze 方法。例如:

javascript
const foo = Object.freeze({});
// 在严格模式下,下面一行会报错
foo.prop = 123;

除了将对象本身冻结,对象的属性也应该冻结。以下是一个将对象彻底冻结的函数:

javascript
var constantize = (obj) => {
  Object.freeze(obj);
  Object.keys(obj).forEach((key) => {
    if (typeof obj[key] === 'object') {
      constantize(obj[key]);
    }
  });
};

这样,通过调用 constantize 函数,可以彻底冻结一个对象及其所有属性。

ES6 声明变量的6种方法

ES5中只有两种声明变量的方法:使用 var 命令和 function 命令。而 ES6 除了添加了 letconst 命令之外,还引入了 import 命令和 class 命令用于声明变量。

因此,ES6 一共有6种声明变量的方法:varfunctionletconstimportclass

顶层对象属性

在 ES5 中,顶层对象的属性与全局变量是等价的,即顶层对象的属性赋值与全局变量的赋值是同一件事。这种设计被认为是 JavaScript 语言中最大的设计败笔之一,因为会导致无法在编译时提示未声明变量、容易不知不觉地创建全局变量以及不利于模块化编程。

为了改变这一点,ES6做出了规定:var 命令和 function 命令声明的全局变量依旧是顶层对象的属性,但 let 命令、const 命令、class 命令声明的全局变量不再是顶层对象的属性。从 ES6 开始,全局变量逐渐与顶层对象的属性隔离。

举例来说,使用 var 声明的全局变量会成为顶层对象的属性,而使用 let 声明的全局变量则不会成为顶层对象的属性,返回 undefined。这样的设计改变了全局变量与顶层对象属性之间的关系,使得代码更加清晰和可靠。

js
var a = 1;
window.a // 1。如果是在 node 环境中,可写成 global.a 或 this.a

let b = 2;
window.b // undefined

global 对象

ES5 顶层对象也有不统一的问题,其中:

  • 在浏览器中,顶层对象是 windowself
  • 在 Web Worker 中,顶层对象是 self
  • 在 Node 中,顶层对象是 global

目前为了能在各种环境中获取顶层对象,通用方法是使用 this 变量,但是这也有缺点:

  • 在全局环境中,this 会返回顶层对象,但在 Node 模块和ES6模块中,this 会返回当前模块
  • 对于函数中的 this,如果函数不是作为对象的方法运行,则 this 会指向顶层对象,但在严格模式下会返回 undefined
  • new Function('return this')() 无论在何种模式下都会返回全局对象。

然而,在各种情况下找到一个通用的方法来获取顶层对象是很困难的。目前有一个提案在语言标准中引入 global 作为顶层对象,可以在所有环境下拿到顶层对象。同时,垫片库system.global 可以模拟这个提案,保证在各种环境下 global 对象都是存在的。

总结

第二章从 ES6 推出的 letconst 着手,讲解了他们和 var 之间的区别,如不存在变量提升、暂时性死区、不可重复声明、作用域等。

其中作用域阮一峰老师也展开讲解,ES6 与 ES5 相比多了一个块级作用域,其作用是为了解决 内部变量覆盖外部变量循环变量全局泄漏

最后聊到了 顶层变量 ,在浏览器、Web Worker 和 Node 中,他们各自的顶层变量都不相同,为了解决这一现象,有人提出了引入 global 垫片方案。