XiaoboTalk

js 变量 let 和 var

对于从 C 系语言转来学习 js 的开发者而言,最难理解的就是 js 中的varletconst 的设计。在其他语言中,每个变量默认都有自己的作用域,然而在 js 中并不是这样。直到ECMAScript 6标准的确立,才给 js 开发带来了更简单的作用域管理。ECMAScript 6是在 2015 年正式发行,所以又叫ECMAScript 2015。本篇将全面的分析varletconst关键字的使用及作用域。

最佳实践


想急于知道最佳实践,又不想看完冗长的文章,那么只需要记住,最佳实践:
  • 如果可以,不要使用var来定义变量,而是使用let
  • 优先使用const,而非 let。

undefinedis not defined的区别


开始前,有必要先了解这两个重要概念,*** is not defined是一个运行时报错:
console.log(a); // 运行报错: "Uncaught ReferenceError: a is not defined"
原因很简单,上下文并没有定义 a 变量。
undefined是全局对象的一个属性,在浏览器环境里,undefined就是window的一个属性,在运行时创建:
console.log(window.undefined); // 输出 undefined
可以认为undefined就是全局作用域下的一个变量,且该值不可写,是只读的。undefined的是 js 的一个原始类型,该类型是undefined类型,该类型只有一个真值,就是undefined
如果觉得这段话很绕,那么可以做个类比,假设window对象有个属性叫 bl。bl 的类型是BooleanBoolean是 js 的一个原始类型。它有两个真值: true ' false
undefined的字面意思是未定义,但在代码中的真正含义并不像它的字面意思那样,它的真实意思是:定义了,但是没有初始化的变量,即uninitialized:
var a; console.log(a); // 输出 undefined
所以,在 js 中大部分情况下,undefined = declared but not initialized或者undefined = uninitialized。换句话说,定义了但没有被赋值的变量被称为undefined
但是typeof这个被称为安全运算符的是个例外:
console.log(typeof(a)); // undefined console.log(a); // "Uncaught ReferenceError: a is not defined"
上边的代码,第一行正常输出undefined,第二行报错。可以看到,即使没有定义变量 a,typeof依然输出了undefined,而不是报运行时错误。但typeof并不总是安全的,这个后文在时间死区部分在讲。

var 与 变量提升 (Hoisting)


在很多语言中,默认一对大括号就是一个作用域,作用域内的变量,无法在作用域外访问,以 C 语言为例:
{ int x = 10; } printf("%d", x); // 编译报错,无法访问 x。
然而,在 js 中,当你用 var 定义一个变量的时候,上述规则就会被打破,比如下边的 js 代码:
const bl = false; if (bl) { var x = 10; } else { console.log("The value of x in else block is: ", x); } console.log("The value of x in outer is: ", x);
上述代码,不会报错,可以正常执行,并输出:
The value of x in else block is: undefined The value of x in outer is: undefined
这个特征在 js 里称为hoisting,这里就翻译为变量提升吧。事实上,在解释执行时,上述代码会被调整为:
const bl = false; var x; // hoisting if (bl) { x = 10; } else { console.log("The value of x in else block is: ", x); } console.log("The value of x in outer is: ", x);
变量x的声明被提升到了全局作用域上,而变量初始化依然保留在原味。如果在函数内部,则变量声明会被提升到函数的最开始,例如:
function printX() { const bl = false; if (bl) { var x = 10; } else { console.log("The value of x in else block is: ", x); } console.log("The value of x in outer is: ", x); } // call printX function printX();
上述代码依然可以正常执行,输出:
The value of x in else block is: undefined The value of x in outer is: undefined
只不过,这次变量的声明提升,只会发生在函数内部,不会超出函数的范围:
function printX() { const bl = false; var x; // hoisting if (bl) { x = 10; } else { console.log("The value of x in else block is: ", x); } console.log("The value of x in outer is: ", x); } // call printX function printX();
由于这个hoisting特性,很不符合直觉,也容易引发一些难以察觉的问题。所以 ES 6 就带来了一个block-level scope,块级作用域这个概念。

Block-Level Scope


块级作用域这个功能的出现,终于让 js 能想其他语言一样,有了正常的变量作用域,用let定义的变量,或者用const定义的常量,都满足块级作用域。
const bl = false; if (bl) { let x = 10; // 用 let 来限制块级作用域 } else { console.log("The value of x in else block is: ", x); } console.log("The value of x in outer is: ", x);
上述代码执行,会报错:
ReferenceError: x is not defined
let定义的变量,就不会发生hoisting提升。变量 x 只在 if 的条件块能被定义和初始化,出了该作用域,变量就无法访问。

const 常量


const关键字用来定义常量,常量一旦定义,就不可更改。所以就要求,常量必须在声明的时候,就初始化。
const x = 10; // 正确 const y; y = 20; // 错误
在用const定义对象的时候,需要注意,对象本身不可更改,但是对象内部的值可以被修改:
const person = { name: "Jack" }; person.name = "Rose"; // 正确 person = {}; // 错误

时间死区


文章开头提到过,js 中typeof是一个’安全操作符’:
console.log(typeof x); // 输出: undefined if(true) { let x = 10; }
变量 x 用let定义,所以 x 只能在 if 块中有效,但是typeof依然不会报错,而是输出undefined。但是下边代码就出现了例外,typeof这个安全操作符也不安全了:
if(true) { console.log(typeof x); // 报错: ReferenceError! let x = 10; } // 执行报错: "Uncaught ReferenceError: Cannot access 'x' before initialization"
这在 js 社区里被称为时间死区 TDZ(Temporal Dead Zone)let定义的变量 x,只在 if 块中生效,且该区域严格执行未定义的变量不能访问这一原则,就连typeof也不例外。需要注意,const同样适用这一原则:
if(true) { console.log(typeof x); // 报错: ReferenceError! const x = 10; } // 执行报错: "Uncaught ReferenceError: Cannot access 'x' before initialization"

在 for 循环中的块级绑定


在 for 循环中,如果在用var定义计数变量,通常会发生一些难以理解的输出:
var funcs = []; for (var i = 0; i < 10; i ++) { funcs.push(function() { console.log(i) } ); } funcs.forEach(function(f) { f(); // 连续输出 10 次 10。 });
对大部分开发者,都希望这部分代码输出 0…9,但由于var变量的hoisting特性,导致每个函数捕获到的都是同一个变量 i。所以最终都是 10。所以以前的 js 开发者们通过即执行函数来捕获一个 i 的 copy 值解决:
var funcs = []; for (var i = 0; i < 10; i ++) { funcs.push((function(counter) { return function() { console.log(counter); } }(i))); } funcs.forEach(function(f) { f(); // 输出 0...9 });
每次循环,都执行一个函数,i 通过函数参数的形式传入函数内部,就会发生值拷贝,内部的函数就会捕获到一个拷贝后的值。这给很多从其他语言转来 js 的开发者带来很大的疑惑。
最终,ES 6 的块级作用域,运行我们用let轻松解决上述问题:
var funcs = []; for (let i = 0; i < 10; i++) { funcs.push(function() { console.log(i); }); } funcs.forEach(function(func) { func(); // outputs 0, then 1, then 2, up to 9 })
使用let后,每次循环,都会创建一个新的变量 i,新变量 i 的值为上次的变量 + 1,这样函数每次都捕获到自己的变量 i。在for-infor-of循环中,这一特性同样适用:
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(); // outputs "a", then "b", then "c" });
let在 for 循环中的特性是特别定义的行为,与非 hoisting 没有必然联系。

在 for 循环中使用 const


const 在不同的循环中,有着不同的表现。在 for-i 循环中,使用 const 会抛出错误:
for (const i = 0; i < 10; i ++) { console.log(i); } // TypeError: Assignment to constant variable.
错误具体是在, i++ 上报出的,试图修改一个常量而报错。但是,在for-infor-of中,const的表现就和let一样了。
var funcs = [], object = { a: true, b: true, c: true }; // doesn't cause an error for (const key in object) { funcs.push(function() { console.log(key); }); } funcs.forEach(function(func) { func(); // outputs "a", then "b", then "c" });
在 for-in 和 for-of 循环中,每次循环都会创建一个新的 const 常量与块绑定,这一点和let是一样的。

全局块绑定


letconstvar的另一个区别,表现在全局作用域上。当在全局作用域上用var创建一个变量时,这个变量会被挂载在全局对象上,如果是浏览器,这个全局对象就是 window。
var r = "hello"; console.log(window.r); // hello
如果用let或者const定义全局变量,则该变量不会被挂载在 window 上:
let r = "hello"; console.log("r" in window); // false
(全文完)