Skip to content

深入理解 ES6 #1-块级作用域绑定

🏷️ 《深入理解 ES6》

var 声明及变量提升(Hoisting)机制

在函数作用域或全局作用域中通过 var 关键字声明的变量,无论实际上是在哪里声明的,都会被当成在当前作用域顶部声明的变量。

这就是我们常说的提升(Hoisting)机制

通过下面的 getValue 函数来说明:

js
function getValue(condition) {
  if (condition) {
    var value = "blue";
    console.log(value); // blue
  } else {
    console.log(value); // undefined
  }
  
  console.log(value); // 再次打印 value 的值
  return value;
}
js
getValue(true);
// 打印结果
// blue
// blue
js
getValue(false);
// 打印结果
// undefined
// undefined

之所以会这样就是因为上面说的提升机制。

在预编译阶段,JavaScript 引擎会将上面的 getValue 函数修改成如下这样:

js
function getValue(condition) {
  var value;
  if (condition) {
    value = "blue";
    console.log(value); // blue
  } else {
    console.log(value); // undefined
  }
  
  console.log(value); // 再次打印 value 的值
  return value;
}

变量 value 的作用域被提升到了函数顶部,而不仅仅是在 if 块中。

这同其它大部分编程语言都不一样,很容易引起 bug。

为此,ES6 中引入了块级作用域来强化对变量生命周期的控制。

块级声明

块级声明用于声明在指定块的作用域之外无法访问的变量。

块级作用域(亦称为词法作用域)存在于:

  • 函数内部

  • 块中(字符 {} 之间的区域)

let 声明

let 声明的用法与 var 相同。用 let 代替 var 来声明变量,就可以把变量的作用域限制在当前代码块中。

由于 let 声明不会被提升,因此开发者通常将 let 声明语句放在封闭代码块的顶部,以便整个代码块都可以访问。

js
function getValue(condition) {
  if (condition) {
    let value = "blue";
    console.log(value); // blue
  } else {
    console.log(value); // Uncaught ReferenceError: value is not defined
  }
  
  console.log(value); // Uncaught ReferenceError: value is not defined
  return value;
}

调用该方法会在第 6 行和第 9 行出现如下异常:

js
Uncaught ReferenceError: value is not defined

禁止重声明

假设作用域中已经存在某个标识符,此时再使用 let 关键字声明它就会抛出错误。

js
var a = 1
var a = 2
let a = 3

执行到 let 时会出现如下错误:

js
Uncaught SyntaxError: Identifier 'a' has already been declared

const 声明

使用 const 声明的是常量,其值一旦被设定后不可更改。

因此,每个通过 const 声明的变量必须进行初始化。

js
const maxItems = 30;
const name;

第二行代码会抛出如下错误:

js
Uncaught SyntaxError: Missing initializer in const declaration

const 和 let

constlet 声明的都是块级标识符,所以常量也只在当前代码块内有效,一旦执行到块外会立即被销毁。

常量同样也不会被提升至作用域顶部。

let 类似,在同一作用域用 const 声明已经存在的标识符也会导致语法错误,无论该标识符是以 var 还是 let 声明的。

奇怪的现象

这里我写代码测试时发现了一件比较奇怪的事情。

js
var name = 'jiajia';
const name = 'ljj';

按照上面的定义,第二行代码应该抛出错误才对,结果却是正常的执行了。

当把变量名 name 改成 message 时,第二行代码是会正常的抛出错误的。

那就只能是 name 这个变量名的问题了,这个名字比较特殊。

特殊在哪里呢?

因为代码是在全局作用域执行的,使用 var 声明的变量会自动变成全局对象(浏览器环境中的 window 对象)的一个属性。

name 特殊在它是 window 的一个固有属性。

在后面的 全局作用域绑定 小节中有讲到:用 letconst 不能覆盖全局变量,而只能遮蔽它。

估计是这个遮蔽导致了这个现象。

遮蔽不知道是不是可以理解为:编译后会自动变成一个不同的变量名,如 constname 为变成 name_1 之类的。

用 const 声明对象

JS 中常量如果是对象,则常量中的值是可以修改的。

这同 C# 是一样的,java 中好像也是这样的。

js
const person = {
    name : 'JiaJia'
}

// 可以修改对象属性的值
person.name = 'ljj'

// 抛出语法错误
person = {
    name : 'ljj'
}

最后的赋值会抛出如下错误:

js
Uncaught TypeError: Assignment to constant variable.

临时死区 (Temporal Dead Zone)

var 不同,letconst 声明的变量不会被提升到作用域顶部。如果在声明之前访问这些变量,即使是相对安全的 typeof 操作符也会触发引用错误。

js
function getValue(condition) {
  if (condition) {
    console.log(typeof value); // 引用错误
    let value = "blue";
  }
}

getValue(true);

console.log(typeof value) 会抛出如下错误:

js
Uncaught ReferenceError: value is not defined

但在 let 声明的作用域外对该变量使用 typeof 则不会抛出错误:

js
function getValue(condition) {
  console.log(typeof value); // undefined
  
  if (condition) {
    let value = "blue";
  }
}

getValue(true);

循环中的块作用域绑定

最常见的 for 循环:

js
for (var i = 0; i < 10; i++) {

}

console.log(i); // 10

最终会打印 10,说明 i 的作用域在循环体外,不是想象中的 for 循环内部。

var 改为 let 即可以实现想定的效果。

js
for (let i = 0; i < 10; i++) {

}

// i 在这里不可访问,抛出一个错误
console.log(i); // Uncaught ReferenceError: i is not defined

循环中的函数

长久以来,var 声明让开发者在循环中创建函数变得异常困难,因为变量到了循环之外仍能访问。

js
var funcs = [];

for (var i = 0; i < 10; i++) {
  funcs.push(function() {
    console.log(i);
  });
}

funcs.forEach(function(func) {
  func(); // 输出 10 次数字 10
});

预想的是输出 0~9,但实际输出的是 10 次 10。

是因为循环里的每次迭代都共享着变量 i,循环内部创建的函数全部都保留了对相同变量的引用。循环结束时变量 i 值为 10,所以调用 console.log 时就会输出数字 10.

为了解决这个问题,开发者们在循环中使用了立即调用函数表达式(IIFE),以强制生成计数器变量的副本

js
var funcs = [];

for (var i = 0; i < 10; i++) {
  funcs.push(function(value) {
    return function() {
      console.log(value);
    }
  }(i));
}

funcs.forEach(function(func) {
  func(); // 输出 0~9
});

在循环内部,IIFE 表达式为接受的每一个变量 i 都创建了一个副本并存储为变量 value。这个变量就是响应迭代创建的函数所使用的值,因此调用每个函数都会像从 0 到 9 循环一样得到期望的值。

循环中的 let 声明

let 声明模仿上述实例中 IIFE 所做的一切来简化循环过程,每次迭代都会创建一个新变量,并以之前迭代中同名变量的值将其初始化。

不需要使用 IIFE,只需将示例中的 var 改成 let 就可以得到想要的结果。

js
var funcs = [];

for (let i = 0; i < 10; i++) {
  funcs.push(function() {
    console.log(i);
  });
}

funcs.forEach(function(func) {
  func(); // 输出 0~9
});

对于 for-in 循环也是一样的。

js
var funcs = [],
    object = {
      a: true,
      b: true,
      c: true
    };

for (let key in object) {
  funcs.push(function() {
    console.log(key);
  });
}

funcs.forEach(function(func) {
  func(); // 输出 a,b 和 c
});

如果变量 key 使用 var 声明,则会打印三个 c。

可见 for-in 循环与 for 循环的表现是一致的。

循环中的 const 声明

在 ES6 标准中没有明确指明不允许在循环中使用 const 声明,然而针对不同类型的循环它会表现出不同的行为。

对于普通的 for 循环来说,可以在初始化时使用 const,但是更改这个变量的值就会抛出错误。

js
var funcs = [];

// 第二次迭代时会抛出错误
for (const i = 0; i < 10; i++) {
  funcs.push(function() {
    console.log(i);
  });
}

funcs.forEach(function(func) {
  func();
});

第二次迭代时会抛出如下错误:

js
Uncaught TypeError: Assignment to constant variable.

for-infor-of 循环中使用 const 时的行为与使用 let 一致。

全局块作用域绑定

letconstvar 的另外一个区别是它们在全局作用域中的行为。

var 被用于全局作用域时,它会创建一个新的全局变量作为全局对象(浏览器环境中的 window 对象)的属性。

这意味着 var 可能会无意中覆盖一个已经存在的全局变量。

js
console.log(window.RegExp); // ƒ RegExp() { [native code] }
var RegExp = "Hello!";
console.log(window.RegExp); // "Hello!"

var ncz = "Hi!";
console.log(window.ncz); // "Hi!"

全局变量 RegExp 会覆盖之前 window 中的 RegExp 属性(window 中既存的一个方法),变成了一个字符串。

同样 window 中也多了一个 ncz 的属性。

如果你在全局作用域中使用 letconst,会在全局作用域下创建一个新的绑定,但该绑定不会添加为全局对象的属性。换句话说,letconst 不能覆盖全局变量,而只能遮蔽它。

Note

如果希望在全局对像下定义变量,仍然可以使用 var。这种情况常见于浏览器中跨 frame 或跨 window 访问代码。

块级绑定最佳实践的进化

ES6 尚在开发时,人们普遍认为应该默认使用 let 而不是 var

然而,当更多的开发者迁移到 ES6 后,另一种做法日益普及:默认使用 const,只有在确实需要改变变量的值时使用 let