JavaScript 中变量的创建方式取决于你如何声明它,ES6 提供了额外的选项方便你能自由地控制变量的作用范围。

var 声明与变量提升

使用 var 声明的变量,不论在何处都会被视为在函数级作用域内顶部的位置发生 (如果不包括在函数内则为全局作用域内)。

function getValue (condition) {
  if (condition) {
    var value = 'blue';
    return value;
  } else {
    // value 在这里可以访问到,值为 undefined
    return null;
  }
  // 这里也可以访问到,值为 undefined
}

块级声明

块级声明,指该声明的变量无法被代码块外部访问。块级作用域,又被称为词法作用域。可以在如下的条件下创建:

  • 函数内部
  • 在代码块 (即在 {} ) 内部

let 声明

let 声明的语法和 var 完全一样。区别是,变量的作用域会限制在当前的代码块中。

另外,let 声明不会将变量提升到当前作用域的顶部,所以你或许应该把它们放到代码块的开头,这样,才能被代码块的其他部分访问到。

function getValue (condition) {
  if (condition) {
    let value = 'blue';
    return value;
  } else {
    // 这里无法访问 value
    return value;
  }
  // 这里也无法访问 value
}

禁止重复声明

如果一个标识符在当前作用域内已经存在,那么再用 let 声明相同的标识符或抛出错误。

var count = 10;
let count = 20;  // 语法错误  Uncaught SyntaxError: Identifier 'count' has already been declared

let 不会重新定义已经存在的标识符。但是,如果如果在当前包含的作用域内 let 声明了一个全新的变量,那么就不会有错误抛出。

var count = 10;
if (condition) {
  let count = 20;
}

const 声明

在 ES6 中可以使用常量语法声明常量。一旦声明了,就意味着它们不能再次被赋值。

所以,所有 const 声明的变量都必须在声明处初始化。

const num1 = 10;
const num1 = 20;  // Uncaught SyntaxError: Identifier 'num1' has already been declared

const num2;  // Uncaught SyntaxError: Missing initializer in const declaration
// 必须声明的时候初始化赋值

const 声明 VS let 声明

constlet 都是块级声明,意味着执行流跳出声明所在的代码块后就没有办法再访问它们,同样 const 变量也不会被提升。

if (condition) {
  const num = 10;
}
// 在代码块外面无法访问 num

另一处与 let 相同的地方是,const 也不能对已存在的标识符重复定义,不论该标识符由 var 还是 let 定义。

var a = 'hello';
let b = 10;

// 下面的语句都会报错
const a = 'world';
const b = 20;

尽管 constlet 使用起来很相似,但是必须要记住它们的根本性差异:不管是在严格模式还是非严格模式,const 变量都不允许被重复复制。

const num = 1;
num = 2;  // Uncaught TypeError: Assignment to constant variable.

但是需要注意的是:const 变量的值如果是个对象,那么这个对象本身可以被修改。

const a = {
  name: 'LiLei',
  age: 12
};
a.age = 20;

// 但是如果改变了该对象的地址,这样就会报错
const a = {
  name: 'Tony',
  age: 11
}  // 报错 Uncaught SyntaxError: Identifier 'a' has already been declared

const 声明只是阻止变量和值的再次绑定而不是值的修改。这意味着 const 不能限制对于值的类型为对象的变量的修改

暂存性死区 (The Temporal Dead Zone)

letconst 声明的变量在声明之前不能被访问。如果执意这么做会出现错误,甚至是 typeof 这种安全调用 (safe operations) 也不被允许的:

if (condition) {
  console.log(typeof num);  // Uncaught ReferenceError: num is not defined
  let num = 1;
}

在这里,变量 num 由 let 声明并被初始化,但是由于该语句之前就跑出了错误,导致其从未被执行。

这种现象的原因是该语句存在于暂存性死区内。

当 JavaScript 引擎在作用域中寻找变量声明时,会将变量提升到函数/全局作用域的顶部 (var) 或是放入暂存性死区内部 (let 和 const)。

任何试图访问暂存性死区内部变量的行为都会抛出运行时 (runtime) 错误。当执行流达到变量声明的位置时,变量才会移出暂存性死区,代表它们可以使用。

letconst 声明的变量,如果你想在定义它们之前就使用,那么请记住以上准则。

不过,你可以在代码块之外的地方对变量使用 typeof 操作符,但结果可能不是你想要的。

console.log(typeof num);  // undefined
if (condition) {
  let num = 1;
}

当对 num 变量使用 typeof 操作符时,它并没有处在暂存性死区内部,因为它的位置在变量声明位置的代码块之外。

这意味着没有发生任何绑定,所以 typeof 仅返回 undefined

暂存性死区只是发生在块级绑定中独特的设定之一。另一个特殊设定是在循环中。

注意:如果这里是 console.log(num) ,照样会报错。这里显示 undefined ,仅仅是因为 typeof 是 safe operations

console.log(typeof a);  // undefined
console.log(a);  // Uncaught ReferenceError: a is not defined

循环中的块级绑定

获取开发者对于块级作用域有强烈需求的场景之一就是循环。因为他们不想让循环外部访问到内部的索引计数器。

for (var i = 0; i < 5; i++) {
  console.log(i);  // 1, 2, 3, 4
}
console.log('i : ', i);  // i : 5

循环外部仍然可以访问 i

但是,如果用 let 代替 var

for (let i = 0; i < 5; i++) {
  console.log(i);  // 1, 2, 3, 4
}
console.log('i : ', i);  // Uncaught ReferenceError: i is not defined

所以,如果用 let,循环外部就不能访问到 i

循环中的函数

长久以来,var 声明的特性使得在循环中创建函数问题多多。因为循环中声明的变量在代码块外也可以被访问。

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

funcs.forEach(function (func) {
  func();  // 输出 “10” 共 10 次
});
// forEach() 方法对数组的每个元素执行一次提供的函数

你可能以为这段代码只是普通地输出 0 - 9 这 10 个数字,但事实上它会连续 10 次输出 “10”。这是因为每次迭代的过程中 i 是被共享的。

这意味着循环中创建的函数都保持着对相同变量的引用。当循环结束后,i 的值为 10,于是当 console.log(i) 被调用后,该值会被输出。


为了修正这个问题,可以在循环内部使用即时调用函数表达式 (IIFEs) 来迫使每次迭代时创建一份当前索引值的拷贝

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, 1, 2 ... 9
});

这种写法在循环内部使用了 IIFE,并将变量 i 的值传入 IIFE 以便拷贝索引值并存储起来。这里传入的索引值同样被当前的迭代所使用。

所以,循环完毕后每次调用的输出值正如所期待那样。

但是,用 letconst 的块级绑定可以对循环代码进行简化。

循环中的 let 声明

let 声明,可以在每次迭代中,一个新的同名变量会被创建并初始化。这意味着你可以不使用 IIFE ,也可以达到同样的效果。

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

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

let 声明使得每次迭代都会创建一个变量 i,所以循环内部创建的函数会获得各自的变量 i 的拷贝。

每份拷贝都会在每次迭代的开始被创建并被赋值。这同样适用于 for-infor-of 循环。

var funcs = [],
    obj = {
      a: true,
      b: true,
      c: true
    };
for (let key in obj) {
  funcs.push(function () {
    console.log(key);
  });
}

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

顺便说一下,let 声明在上述循环内的表现是在规范中特别定义的,和 “非变量提升“ 这一特性没有直接关系。

循环中的 const 声明

首先,ES6 中并没有明确禁止在循环中使用 const 声明。

对于普通的 for 循环你可以初始化语句里使用 const 声明,但当你想要修改该声明变量时,循环会报错:

var funcs = [];

for (const i = 0; i < 10; i++) {
  funcs.push(function () {
    console.log(i);
  });
}
// Uncaught TypeError: Assignment to constant variable

另外,当使用 for-infor-of 循环时,const 声明的变量的表现和 let 完全一致。

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

for (const key in obj) {
  funcs.push(function () {
    console.log(key);
  });
}

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

这段代码的作用和在循环中的 let 声明一样,唯一的差别是变量 key 的值不能被修改。

for-infor-of 循环能正常使用 const 是因为每次迭代都会创建一个新的变量绑定而不是去试图修改已存在的绑定。

全局块级绑定

letconst 另一处不同体现在全局作用域上。

如果在全局作用域使用 letconst,那么绑定就会发生在全局作用域内,但不会向全局对象内部添加任何属性

let a = 1;
console.log(a);  // 1
console.log(window.a);  // undefined

const b = 2;
console.log(b);  // 2
console.log(window.b);  // undefined

总结

letconst 块级绑定给 JavaScript 引入了词法作用域的概念。这些声明不会被提升且仅存在声明它们的代码块中。 但是,这就不能在声明之前就使用它们,即使是 typeof 这种安全操作。

暂存性死区中绑定的存在会导致在声明位置之前的访问以失败告终。

另外,在大多数情况下,letconst 的表现和 var 很相似,但在循环中并不是这样。

对于 letconst 来说,每次迭代的开始都会创建新的绑定,意味着循环内部创建的函数可以在迭代时访问到当前的索引值, 而不是在整个循环结束之后 (即 var 的表现形式)。在 for 中使用 let 声明也同样试用,但 const 声明则会抛出错误。

目前关于块级绑定的最佳实践是使用 const 作为默认的声明方式,但变量需要更改时切换为 let 声明。

保证代码中最基本的不可变性能防止错误的发生。