ECMAScript 变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。
有 3 个关键字可以声明变量:var、const 和 let。其中,var 在 ECMAScript 的所有版本中都可以使用,而 const 和 let 只能在 ECMAScript 6 及更晚的版本中使用。
var (变量-函数作用域)、 let (变量-块作用域)、 const (常量-块作用域),优先使用 const , let 次之。
限制自己只使用 let 和 const 有助于提升代码质量,因为变量有了明确明确的作用域、声明位置,以及不变的值。
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 / 全局作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 是(值为 undefined) | 存在暂时性死区(TDZ),声明前访问会报错 | 存在暂时性死区(TDZ),声明前访问会报错 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 重新赋值 | 允许 | 允许 | 不允许(必须初始化且不能改变基本类型的值) |
1. var 关键字
定义变量,可以使用var操作符。
var message;//不初始化的情况下,变量会保存一个特殊值 undefined
var message = "hi";//初始化变量
var message = "hi";
message = 100; // 合法,但不推荐改变变量保存值的类型
1.1 var 声明的作用域
在函数内部声明的变量,在整个函数内部都可用;在函数外部声明则成为全局变量。它不具备块级作用域,因此在 if、for等代码块内声明的变量会泄漏到块外。
//使用 var 操作符定义的变量会成为包含它的函数的局部变量
function test() {
var message = "hi"; // 局部变量
}
test();
console.log(message); // 出错!
//去掉之前的 var 操作符之后,message 就变成了全局变量。只要调用一次函数 test(),就会定义这个变量,并且可以在函数外部访问到。
function test() {
message = "hi"; // 全局变量,不推荐圣罗var 操作符
}
test();
console.log(message); // "hi"
//定义多个变量
var message = "hi",
found = false,
age = 29;
1.2 var 声明的提升
所谓的“提升”(hoist),就是所有变量声明会被提升到其作用域(函数或全局)的顶部。这意味着变量可以在声明之前被访问,但其值在赋值前为 undefined。
function foo() {
console.log(age);
var age = 26;
}
foo(); // undefined
//ECMAScript 运行时把它看成等价于如下代码
function foo() {
var age;
console.log(age);
age = 26;
}
foo(); // undefined
//在同一作用域内,可以多次使用 `var`声明同名变量而不会报错,后面的声明会覆盖前面的
function foo() {
var age = 16;
var age = 26;
var age = 36;
console.log(age);
}
foo(); // 36
1.3 var声明在实际开发中可能引发的问题
在实际开发中,var的变量提升特性会带来一些意想不到的问题。下面是几个典型的场景:
1.3.1 循环中的变量泄漏
在 for循环中使用 var声明计数器,循环结束后该变量仍然存在于循环体所在的作用域中,这可能导致错误引用。
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出 3, 3, 3
}, 100);
}
console.log("循环结束后的 i:", i); // 输出 3
问题原因:变量 i被提升到全局或函数作用域,整个循环中共享同一个变量。异步操作(如 setTimeout)触发时,循环已结束,i的值为 3。
解决方案:使用 let声明循环变量,它为每次迭代创建新的绑定。每个回调函数捕获的是当次循环的 j值,而且循环外部无法访问 j。
for (let j = 0; j < 3; j++) {
setTimeout(function() {
console.log(j); // 输出 0, 1, 2
}, 100);
}
console . log (j); // 报错:j is not defined
1.3.2条件块内的意外覆盖
由于 var是函数级作用域,在条件块(如 if)内部声明的变量会覆盖外部同名变量。
function example() {
var x = 1;
if (true) {
var x = 2; // 覆盖了外层的 x
console.log(x); // 输出 2
}
console.log(x); // 输出 2,而非预期的 1
}
example();
问题原因:if块内的 var x被提升到函数顶部,与函数开头声明的 x是同一个变量,导致内部赋值影响外部。
解决方案:使用 let或 const声明块级作用域变量。if块内的 x与外层的 x互不干扰。
function exampleFixed() {
let x = 1;
if (true) {
let x = 2; // 独立的块级变量
console.log(x); // 输出 2
}
console.log(x); // 输出 1
}
1.3.3 变量使用先于声明
变量提升允许在声明前使用变量,但其值为 undefined,这可能导致逻辑错误。
console.log(username); // 输出 undefined
var username = "Alice";
问题原因:var username的声明被提升,但赋值 (= "Alice") 仍在原地。在声明前访问,得到 undefined。
解决方案:使用 let或 const,它们存在“暂时性死区”(TDZ),在声明前访问会抛出错误,有助于提前发现问题。
console.log (usernameLet); // 报错:Cannot access 'usernameLet' before initialization
let usernameLet = "Alice";
1.3.4 函数与变量声明的优先级
当同一作用域下存在同名函数声明和 var变量声明时,它们的提升优先级可能导致意外。
console.log(typeof foo); // 输出 "function"
var foo = "variable";
function foo() {
console.log("I'm a function");
}
console.log(typeof foo); // 输出 "string"
问题原因:函数声明提升优先于变量声明提升。最初 foo是函数,但后被变量赋值覆盖为字符串。
解决方案:避免在同一作用域内重复声明同名标识符。使用 let或 const声明变量可避免此问题,因为它们不允许重复声明。
function bar () {}
let bar = "variable" ; // 报错:Identifier 'bar' has already been declared
2. let声明
let跟var的作用差不多,但有着非常重要的区别。最明显的区别是,let 声明的范围是块作用域,而 var 声明的范围是函数作用域。
let 声明的变量只在其所在的代码块(例如 {} 、 if 语句、 for 循环)内有效。 这能有效避免变量泄露到外部,尤其是在循环或条件判断中,可以避免许多意想不到的错误。
if (true) {
var name = 'Matt';
console.log(name); // Matt
}
console.log(name); // Matt
//age 变量之所以不能在 if 块外部被引用,是因为它的作用域仅限于该块内部。块作用域是函数作用域的子集,因此适用于 var 的作用域限制同样也适用于 let。
if (true) {
let age = 26;
console.log(age); // 26
}
console.log(age); // ReferenceError: age 没有定义
let也不允许同一个块作用域中出现冗余声明。这样会导致报错:
var name;
var name;
let age;
let age; // SyntaxError;标识符 age 已经声明过了
JavaScript 引擎会记录用于变量声明的标识符及其所在的块作用域,因此嵌套使用相同的标识符不会报错,而这是因为同一个块中没有重复声明:
var name = 'Nicholas';
console.log(name); // 'Nicholas'
if (true) {
var name = 'Matt';
console.log(name); // 'Matt'
}
let age = 30;
console.log(age); // 30
if (true) {
let age = 26;
console.log(age); // 26
}
对声明冗余报错不会因混用 let 和 var 而受影响。这两个关键字声明的并不是不同类型的变量, 它们只是指出变量在相关作用域如何存在。
var name;
let name; // SyntaxError 语法错误
let age;
var age; // SyntaxError 语法错误
2.1 暂时性死区
let与var的另一个重要的区别,就是**let** 声明的变量不会在作用域中被提升。
在代码块内,使用 let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(Temporal Dead Zone,简称 TDZ)。在此阶段引用任何后面才声明的变量都会抛出ReferenceError。这强制要求你先声明后使用,有助于提前发现错误 。
// name 会被提升
console.log(name); // undefined
var name = 'Matt';
// age 不会被提升
console.log(age); // ReferenceError:age 没有定义
let age = 26;
2.2 全局声明
与var关键字不同,使用let在全局作用域中声明的变量不会成为 window 对象的属性。不过,let声明仍然是在全局作用域中发生的,相应变量会在页面的生命周期内存续。因此,为了避免SyntaxError,必须确保页面不会重复声明同一个变量。
var name = 'Matt';
console.log(window.name); // 'Matt'
let age = 26;
console.log(window.age); // undefined
2.3 条件声明
在使用var声明变量时,由于声明会被提升,JavaScript引擎会自动将多余的声明在作用域顶部合并为一个声明。因为let的作用域是块,所以不可能检查前面是否已经使用let声明过同名变量,同时也就不可能在没有声明的情况下声明它。 因此,不建议使用 let 进行传统的条件声明。
<script>
var name = 'Nicholas';
let age = 26;
</script>
<script>
// 假设脚本不确定页面中是否已经声明了同名变量
// 那它可以假设还没有声明过
var name = 'Matt'; // 这里没问题,因为可以被作为一个提升声明来处理
// 不需要检查之前是否声明过同名变量
let age = 36; // age 之前声明过,这里会报错
</script>
使用try/catch语句或typeof操作符也不能解决,因为条件块中let声明的作用域仅限于该块。
<script>
let name = 'Nicholas';
let age = 36;
</script>
<script>
// 假设脚本不确定页面中是否已经声明了同名变量
// 那它可以假设还没有声明过
if (typeof name === 'undefined') {
let name; // 这个声明被限制在 if 块内
}
name = 'Matt'; // 这个赋值形同全局赋值
try {
console.log(age); // 如果 age 没有声明过,则会报错
}
catch(error) {
let age;// age 被限制在 catch {}块的作用域内
}
age = 26; // 这个赋值形同全局赋值
</script>
如果确实需要条件声明模式,var是唯一的选择,因为它的提升机制允许重复声明。
如果目的是为一个已存在的变量有条件地赋值,可以使用逻辑运算符。
let someVar; // 先声明
// ... 一些逻辑
if (someCondition) {
someVar = 'value A';
} else {
someVar = 'value B';
}
// 或者使用三元运算符
let someVar = someCondition ? 'value A' : 'value B' ;
2.4 for 循环中的 let 声明
在 let 出现之前,for 循环定义的迭代变量会渗透到循环体外部:
for (var i = 0; i < 5; ++i) {
// 循环逻辑
setTimeout(() => console.log(i), 0)
// 你可能以为会输出 0、1、2、3、4
// 实际上会输出 5、5、5、5、5
//在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的 i 都是同一个变量,因而输出的都是同一个最终值。
}
console.log(i); // 5
改成使用 let 之后,这个问题就消失了,因为迭代变量的作用域仅限于 for 循环块内部:
for (let i = 0; i < 5; ++i) {
// 循环逻辑
setTimeout(() => console.log(i), 0)
// 会输出 0、1、2、3、4
//这种每次迭代声明一个独立变量实例的行为适用于所有风格的 for 循环,包括 for-in 和 for-of循环
}
console.log(i); // ReferenceError: i 没有定义
3. const声明
const的行为与let基本相同,唯一一个重要的区别是用它声明变量时必须同时初始化变量,而且修改 const 声明的变量会导致运行时错误。
const MY_CONSTANT; // SyntaxError: Missing initializer in const declaration
const age = 26;
age = 36; // TypeError: 给常量赋值
// const 也不允许重复声明
const name = 'Matt';
const name = 'Nicholas'; // SyntaxError
// const 声明的作用域也是块
const message = 'Hello World';
if (true) {
const message = 'Hello';
console.log(message); // 输出: Hello
}
console.log(message); // 输出: Hello World
const声明的限制只适用于它指向的变量的引用。换句话说,如果 const 变量引用的是一个对象,那么修改这个对象内部的属性并不违反 const 的限制。
// 对于对象
const person = { name: 'Alice', age: 30 };
person.age = 31; // 这是允许的,修改的是对象内部的属性
// person = { name: "Bob" }; // 这是不允许的,尝试改变引用会报错
// 对于数组
const colors = ['red', 'green'];
colors.push('blue'); // 这是允许的,修改的是数组内部的元素
// colors = ['yellow']; // 这是不允许的,尝试改变引用会报错
不能用const来声明迭代变量(因为迭代变量会自增):
for (const i = 0; i < 10; ++i) {} // TypeError:给常量赋值
只想用const声明一个不会被修改的for循环变量,那也是可以的。也就是说,每次迭代只是创建一个新变量。这对for-of和for-in循环特别有意义:
let i = 0;
for (const j = 7; i < 5; ++i) {
console.log(j); // 7, 7, 7, 7, 7
}
for (const key in {a: 1, b: 2}) {
console.log(key); // a, b
}
for (const value of [1,2,3,4,5]) {
console.log(value); // 1, 2, 3, 4, 5
}