对于从 C 系语言转来学习 js 的开发者而言,最难理解的就是 js 中的
var
、let
、const
的设计。在其他语言中,每个变量默认都有自己的作用域,然而在 js 中并不是这样。直到ECMAScript 6
标准的确立,才给 js 开发带来了更简单的作用域管理。ECMAScript 6
是在 2015 年正式发行,所以又叫ECMAScript 2015
。本篇将全面的分析var
、let
、const
关键字的使用及作用域。最佳实践
想急于知道最佳实践,又不想看完冗长的文章,那么只需要记住,最佳实践:
- 如果可以,不要使用
var
来定义变量,而是使用let
。
- 优先使用
const
,而非 let。
undefined
和is 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 的类型是Boolean
,Boolean
是 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-in
和for-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-in
和for-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
是一样的。全局块绑定
let
、const
与var
的另一个区别,表现在全局作用域上。当在全局作用域上用var
创建一个变量时,这个变量会被挂载在全局对象上,如果是浏览器,这个全局对象就是 window。var r = "hello"; console.log(window.r); // hello
如果用
let
或者const
定义全局变量,则该变量不会被挂载在 window 上:let r = "hello"; console.log("r" in window); // false
(全文完)