JavaScript的变量var、let和const

78 阅读11分钟

ECMAScript 变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。

有 3 个关键字可以声明变量:varconstlet。其中,var 在 ECMAScript 的所有版本中都可以使用,而 constlet 只能在 ECMAScript 6 及更晚的版本中使用。

var (变量-函数作用域)、 let (变量-块作用域)、 const (常量-块作用域),优先使用 const let 次之。

限制自己只使用 let const 有助于提升代码质量,因为变量有了明确明确的作用域、声明位置,以及不变的值。

特性varletconst
作用域函数作用域 / 全局作用域块级作用域块级作用域
变量提升是(值为 undefined存在暂时性死区(TDZ),声明前访问会报错存在暂时性死区(TDZ),声明前访问会报错
重复声明允许不允许不允许
重新赋值允许允许不允许(必须初始化且不能改变基本类型的值)

1. var 关键字

定义变量,可以使用var操作符。

var message;//不初始化的情况下,变量会保存一个特殊值 undefined
var message = "hi";//初始化变量

var message = "hi"; 
message = 100; // 合法,但不推荐改变变量保存值的类型

1.1 var 声明的作用域

在函数内部声明的变量,在整个函数内部都可用;在函数外部声明则成为全局变量。它不具备块级作用域,因此在 iffor等代码块内声明的变量会泄漏到块外。

//使用 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是同一个变量,导致内部赋值影响外部。

解决方案:使用 letconst声明块级作用域变量。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

解决方案:使用 letconst,它们存在“暂时性死区”(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是函数,但后被变量赋值覆盖为字符串。

解决方案:避免在同一作用域内重复声明同名标识符。使用 letconst声明变量可避免此问题,因为它们不允许重复声明。

function  bar () {}
let bar = "variable" ; // 报错:Identifier 'bar' has already been declared

2. let声明

letvar的作用差不多,但有着非常重要的区别。最明显的区别是,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  
} 

对声明冗余报错不会因混用 letvar 而受影响。这两个关键字声明的并不是不同类型的变量, 它们只是指出变量在相关作用域如何存在。

var name;  
let name; // SyntaxError 语法错误

let age;  
var age; // SyntaxError 语法错误

2.1 暂时性死区

letvar的另一个重要的区别,就是**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-offor-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
}