在 JavaScript 中,var、let、const 是声明变量 / 常量的三种方式,核心差异体现在
作用域、提升规则、重复声明、可修改性等方面;
核心概念铺垫
作用域
-
作用域:是指在程序中定义变量的区域,该位置决定了变量的生命周期 (通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期);
-
作用域有如下三种:
- 全局作用域:变量在任何地方都能访问,其生命周期伴随着页面的生命周期;
- 函数作用域:定义的变量或者函数只能在函数内部被访问;函数执行结束之后,函数内部定义的变量会被销毁;
- 块级作用域 (es6):变量仅在声明它的 「块」 ({} 包裹的区域,如 if/for/while、直接的 {}) 内部可访问;
变量提升
-
变量提升:JS 引擎在执行代码前,会将变量声明「提升」到其作用域顶部,但赋值操作保留在原位置;
-
变量提升所带来的问题:
- 变量容易在不被察觉的情况下被覆盖掉
var myname = "张三" function showName(){ console.log(myname); // undefined if(0){ var myname = "李四" } console.log(myname); // undefined } showName(); - 本应销毁的变量没有被销毁
function foo(){ for (var i = 0; i < 7; i++) { } console.log(i); // 循环结束之后 i 的值并未被销毁,打印出来 7 } foo()
- 变量容易在不被察觉的情况下被覆盖掉
逐一对比解析
var:ES5 声明方式(函数作用域 + 提升 + 可重复声明)
-
var 是 ES5 及更早版本的变量声明方式,设计上存在诸多「反直觉」特性,ES6 引入 let/const 后,已不推荐优先使用;
-
核心特性
特性 说明 作用域 函数作用域 (忽略块级作用域,如 if/for 的 {} 无法限制 var 范围) 变量提升 声明提升到作用域顶部,且默认赋值 undefined (「声明 + 初始化」都提升) 重复声明 允许同一作用域内重复声明同一变量 (后声明覆盖前声明) 全局声明 全局作用域中用 var 声明的变量,会挂载到 window 对象 (浏览器) 可修改 / 可重新赋值 支持后续修改值、重新赋值
let:ES6 声明变量(块级作用域 + TDZ + 不可重复声明 + 提升但未初始化)
-
let 是 ES6 为解决 var 的缺陷设计的变量声明方式,优先用于声明需要后续修改的变量;
-
核心特性:
特性 说明 作用域 块级作用域 ({} 内声明的 let 变量,外部无法访问) 变量提升 声明提升,但未初始化 (「暂时性死区」TDZ:访问前不可用) 重复声明 同一作用域内禁止重复声明 (报错:Identifier ‘x’ has already been declared) 全局声明 全局作用域中用 let 声明的变量,不会挂载到 window 对象 可修改 / 可重新赋值 支持后续修改值、重新赋值 -
关键概念:暂时性死区 (TDZ),let 声明的变量,从作用域开始到变量声明行之间的区域,称为「暂时性死区」,在此区域访问变量会直接报错 (而非 undefined);
console.log(e); // 报错:ReferenceError: Cannot access 'e' before initialization let e = 60; // 声明行是 TDZ 的结束点
const:ES6 声明常量(块级作用域 + TDZ + 不可重复声明 + 不可重新赋值)
-
const 用于声明「常量」,优先用于声明不需要修改的变量 (如配置项、固定值、引用类型的引用);
- ES5 中不能直接声明一个常量,如果想声明一个不可修改的变量需要借助 defineProperty 方法;
- ES6 为了弥补这方面的缺失,新增了 const 语句用于声明一个常量;
function setConst(key, value) { // 在 window 对象上添加属性,也可以自己定义一个对象进行添加,可以实现局部作用域的效果 Object.defineProperty(window, key, { get: function(){ return value; }, set: function(){ console.error('Uncaught TypeError: Assignment to constant variable'); }, }); } setConst('PI', 3.1415); console.log(PI) // 3.1415 PI = 3; // Uncaught TypeError: Assignment to constant variable. -
核心特性:
特性 说明 作用域 块级作用域 (同 let) 变量提升 声明提升,但未初始化 (同 let,存在 TDZ) 重复声明 同一作用域内禁止重复声明 (同 let) 全局声明 全局作用域中用 const 声明的变量,不会挂载到 window 对象 (同 let) 初始化要求 声明时必须立即赋值 (报错:Missing initializer in const declaration) 可修改性 「引用不可改,内容可改」:
- 基本类型 (数字 / 字符串 / 布尔):值不可改;
- 引用类型 (对象 / 数组):引用 (地址) 不可改,但内部属性 / 元素可改可重新赋值 禁止重新赋值 (报错:TypeError: Assignment to constant variable)
三者核心差异总结
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 声明 + 初始化 (undefined) | 声明提升,未初始化 (TDZ) | 声明提升,未初始化 (TDZ) |
| 重复声明 | 允许 | 禁止 | 禁止 |
| 全局声明挂载 window | 是 | 否 | 否 |
| 声明时必须赋值 | 否 | 否 | 是 |
| 重新赋值 | 允许 | 允许 | 禁止 |
| 基本类型值修改 | 允许 | 允许 | 禁止 |
| 引用类型内容修改 | 允许 | 允许 | 允许 |
JavaScript 是如何支持块级作用域的
-
在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域的呢?
-
先看下面这段代码:
function foo(){ var a = 1 let b = 2 { let b = 3 var c = 4 let d = 5 console.log(a) console.log(b) } console.log(b) console.log(c) console.log(d) } foo() -
接下来一步步分析上面这段代码的执行流程:
- 第一步是编译并创建执行上下文,下面是 foo 执行上下文示意图,可以得出以下结论:
- 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了;
- 通过 let 声明的变量,在编译阶段会被存放到词法环境 (Lexical Environment) 中;
- 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中;
- 第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:
- 从图中可以看出,当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在;
- 其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构;
- 再接下来,当执行到作用域块中的 console.log(a) 这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找;这样一个变量查找过程就完成了,可以参考下图:
- 当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:
- 通过上面的分析,想必已经理解了词法环境的结构和工作机制,块级作用域就是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了;
- 第一步是编译并创建执行上下文,下面是 foo 执行上下文示意图,可以得出以下结论:
面试题
看下面这样一段代码,能通过分析词法环境,得出来最终的打印结果吗
let myname= '李四'
{
console.log(myname)
let myname= '张三'
}
// 解答
// VM6277:3 Uncaught ReferenceError: Cannot access 'myname' before initialization
// 进入块级作用域不会有编译过程,只不过通过let或者const声明的变量会在进入块级作用域的时被创建,但是在该变量没有赋值之前,引用该变量JavaScript引擎会抛出错误---这就是“暂时性死区”
下面的代码输出的结果是什么?
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000*i);
}
// var 声明的变量是全局作用域的,循环完后 i 的值是 3
// 3
// 3
// 3
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
}
// 这里循环的变量 i 是 let 声明的,当前的 i 只在本轮循环有效,所以每一次循环的 i 其实都是一个新的变量
// for 循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域
// 0
// 1
// 2
for (var i = 0; i < 3; i++) {
(function (i) {
setTimeout(() => {
console.log(i);
}, 1000 * i);
})(i)
}
嵌套作用域下的变量优先级
// 请说出输出结果及原因
var a = 100;
function fn() {
console.log(a);
let a = 200;
}
fn();
Uncaught ReferenceError: Cannot access 'a' before initialization
函数作用域内存在 let a,因此变量提升规则按 let 处理(声明提升,未初始化)
console.log(a) 位于 let a 声明前,处于「暂时性死区」,直接报错(而非访问全局 a=100)
第 1️⃣ 座大山:数据类型和堆栈内存
上一篇