前言:JavaScript 诞生于 1995 年,Brendan Eich 只花了一周就完成了最初的实现。作为浏览器的"副产品",JS 在匆忙之中留下了不少设计瑕疵——
var关键字就是其中最典型的一个。2015 年 ES6 发布,let和const正式登场,补齐了 JS 在作用域和常量声明方面的短板。本文从var_let_const目录下的实验代码出发,系统梳理三者的区别、作用域规则、变量提升机制,以及从 ES5 迁移到 ES6+ 的实践。
目录
- JavaScript 与 ES6:一次迟到的补课
- 声明变量并赋值:var vs let vs const
- 作用域:变量住在哪里?
- var 的块级作用域缺陷
- for + setTimeout:一道经典面试题
- const 的"不可变"到底指什么?
- 变量提升:先编译,再执行
- 总结与实践
JavaScript 与 ES6:一次迟到的补课
JavaScript 诞生之初,目标很简单——给网页添加一点交互能力(幻灯片、表单校验等)。它是一门弱类型、动态类型的脚本语言,值决定类型,变量只是容器的标签。
随着 Web 从"展示页"变成"应用平台",企业级大型项目对 JS 提出了更高的要求。2015 年发布的 ES6(ECMAScript 2015) 是 JS 历史上最大的一次版本升级,引入的 let 和 const 直接改变了开发者声明变量的方式。
| 时代 | 声明方式 | 特点 |
|---|---|---|
| ES5 及以前 | var | 只有函数作用域,没有块级作用域,存在变量提升 |
| ES6+ | let / const | 支持块级作用域,let 无变量提升,const 声明常量 |
ES5 时代没有真正的常量机制,开发者只能靠命名约定来"假装"有常量——
var PI = 3.1415926,var CHATMODEL = 'deepseek-chat'。全大写的变量名是在告诉同事"别改我",但语言层面没有任何约束。ES6 的const终于把这个约定变成了规则。
声明变量并赋值:var vs let vs const
三种声明方式的语法差异,从 3.js 中可以一目了然:
// const:声明时必须赋值
// const item; // ❌ 报错:Missing initializer in const declaration
const item = 1; // ✅ 声明 + 赋值一步到位
// let:声明和赋值可以分开
let a; // ✅ 先声明,值为 undefined
a = 100; // ✅ 后赋值
// var:ES5 的旧方式,不推荐
var height = 200; // 能跑,但现在不该用
| 关键字 | 声明时赋值 | 可重新赋值 | 可重新声明 | 块级作用域 |
|---|---|---|---|---|
var | 不必须 | ✅ | ✅ | ❌ |
let | 不必须 | ✅ | ❌ | ✅ |
const | 必须 | ❌ | ❌ | ✅ |
核心原则就一条:默认用 const,需要改值用 let,永远别用 var。
作用域:变量住在哪里?
作用域决定了变量的"可见范围"。JavaScript 有三种作用域层级,从 1.js 可以清晰看到它们的嵌套关系:
flowchart TD
A["全局作用域<br/>var height = 200"] --> B["函数局部作用域<br/>function setWidth() { ... }"]
B --> C["块级作用域<br/>if (age > 12) { ... }"]
A --> C
三种作用域对比
| 作用域类型 | 边界 | 适用声明 | 示例 |
|---|---|---|---|
| 全局作用域 | 整个脚本 | 尽量少用 | var height = 200 |
| 函数局部作用域 | function 的花括号内 | var / let / const | function setWidth() { var width = 100; } |
| 块级作用域 | 任意 { } 内(if/for/裸花括号) | 仅 let / const | { const name = "张三"; } |
变量查找规则:冒泡查找
flowchart LR
A["当前作用域查找"] -->|"找到了"| B["✅ 使用该变量"]
A -->|"没找到"| C["向外层作用域查找"]
C -->|"找到了"| B
C -->|"没找到"| D["继续向外冒泡..."]
D -->|"到全局都没找到"| E["❌ ReferenceError: xxx is not defined"]
1.js 中的例子完美演示了这一点:
var height = 200; // 全局作用域
function setWidth() {
var width = 100; // 函数局部作用域
console.log(width, height);// ✅ 100 200 —— width 在当前作用域找到,height 冒泡到全局找到
}
setWidth();
// console.log(width); // ❌ ReferenceError —— width 在函数内,外面看不见
当函数执行完毕,其内部的局部变量会被垃圾回收。从内存角度看:声明变量 = 申请一块内存区域,函数销毁 = 回收那块内存。这就是变量的生命周期。
var 的块级作用域缺陷
这是 var 最大的设计问题——它不认 { } 代码块的边界。看 1.js 中的 if 语句:
var age = 100;
if (age > 12) {
// 这里是一个块级作用域
var dog = age * 7; // ❌ var 把 dog 泄露到了外层
let x = 111; // ✅ let 把 x 关在了块里
console.log(dog); // 700
}
console.log(dog); // 700 —— var 声明的 dog 跑出来了!
console.log(x); // ❌ ReferenceError: x is not defined —— let 守住了边界
flowchart TD
subgraph "if 代码块 { }"
A["var dog = age * 7"]
B["let x = 111"]
end
A -->|"var 无视块边界"| C["外层作用域能访问 dog ✅"]
B -->|"let 尊重块边界"| D["外层作用域访问 x ❌<br/>ReferenceError"]
2.js 进一步验证——用一个裸花括号创建的代码块:
{
const name = "张三";
console.log(name); // ✅ "张三"
}
// console.log(name); // ❌ ReferenceError —— 退出代码块,变量已被回收
设计背景:JS 是浏览器大战时期的仓促产物,设计时并未考虑大型应用的复杂性。
var的块级作用域缺失不是"错误",而是 JavaScript 1.0 时代根本没有"块级作用域"这个需求——当时一个脚本文件也就几十行代码。今天任何一个前端项目都可能包含成百上千个模块,没有块级作用域的var已经变成了绕不开的坑。
for + setTimeout:一道经典面试题
var 和 let 在 for 循环中的行为差异是一道高频面试题。2.js 给出了教科书级的演示:
// 使用 var —— 不想要的结果
for (var i = 0; i < 10; i++) {
setTimeout(function() {
console.log(`This number is ${i}`);
}, 1000);
}
// 输出:10 个 "This number is 10"
// 使用 let —— 想要的结果
for (let i = 0; i < 10; i++) {
setTimeout(function() {
console.log(`This number is ${i}`);
}, 1000);
}
// 输出:This number is 0, 1, 2, ... 9
为什么 var 打印的全是 10?
flowchart TD
A["var i = 0 → i 是全局(或函数)作用域的<br/>整个循环共享同一个 i"] --> B["i 从 0 递增到 9 → 循环结束"]
B --> C["i = 10 → 条件 i < 10 不满足,退出循环"]
C --> D["1 秒后,10 个 setTimeout 回调执行"]
D --> E["所有回调读取的都是同一个 i → 10"]
var 不支持块级作用域,整个 for 循环只有一个 i。同步代码(循环本身)执行得很快,i 从 0 跑到了 10 退出循环。1 秒后 setTimeout 回调触发时,它们读到的都是同一个已经是 10 的 i。
为什么 let 能正确打印?
flowchart TD
A["let i = 0 → 每次迭代创建一个<br/>独立的块级作用域"] --> B["迭代 0: 块级 i=0,setTimeout 捕获 i=0"]
A --> C["迭代 1: 块级 i=1,setTimeout 捕获 i=1"]
A --> D["..."]
A --> E["迭代 9: 块级 i=9,setTimeout 捕获 i=9"]
let 支持块级作用域,每次循环迭代都会创建一个独立的作用域,各自保存各自的 i 值。所以 10 个 setTimeout 回调各自读到自己闭包里的 i。
这道题考察的不只是
var和let的语法区别,更核心的是对**作用域 + 事件循环(同步/异步)**的理解。
const 的"不可变"到底指什么?
const 全称是 constant variable(常量变量),这个名字本身就透露出它的双重性格。从 3.js 的实验中可以梳理出完整的规则:
简单数据类型:值不可变
const key = 'abc123';
// key = 'ABC123'; // ❌ TypeError: Assignment to constant variable
let points = 50;
points = 51; // ✅ let 可以改值
points = '52'; // ⚠️ let 甚至可以改类型(不要这么做!)
复杂数据类型:值可变,类型(引用)不可变
const person = {
name: '李',
age: 18
};
person.age++; // ✅ 可以修改对象属性
console.log(person); // { name: '李', age: 19 }
// person = '111'; // ❌ TypeError: Assignment to constant variable
flowchart TD
subgraph "const person = { name: '李', age: 18 }"
A["栈内存<br/>person → 引用地址 0x001"]
B["堆内存<br/>0x001: { name: '李', age: 18 }"]
end
A -->|"const 锁定的是这个引用<br/>不能指向别的地址"| A
B -->|"对象的属性不在此约束范围内<br/>可以自由修改"| B
核心区分:
- 简单数据类型(string、number、boolean 等):
const让值本身不可变 - 复杂数据类型(object、array):
const锁定的是引用地址,对象的内部属性仍可修改
打个比方:const 是一根定海神针——针的位置不能动,但绑在针上的东西可以换。
变量提升:先编译,再执行
JavaScript 的执行分为两个阶段,4.js 揭示了 var 和 let 在这两个阶段中的行为差异:
// var 的变量提升
console.log(pizza); // undefined —— 变量提升了,但值还没赋
var pizza = 'Deep Dish';
// let 没有变量提升
console.log(pizza); // ❌ ReferenceError: Cannot access 'pizza' before initialization
let pizza = 'Deep Dish';
flowchart TD
subgraph "编译阶段"
A["扫描代码,建立执行上下文"]
B["var pizza → 在变量环境中注册,初始化为 undefined"]
C["let pizza → 注册但保持 uninitialized 状态<br/>(暂时性死区 TDZ)"]
end
subgraph "执行阶段"
D["逐行执行代码"]
E["var: 读到 var pizza = 'Deep Dish' 才赋值"]
F["let: 声明前访问 → ReferenceError"]
end
A --> B
A --> C
B --> D
C --> D
D --> E
D --> F
三种错误信息的含义
在调试过程中,你可能会遇到这三种错误,它们各自指向不同类型的问题:
| 错误信息 | 含义 | 典型场景 |
|---|---|---|
Assignment to constant variable | 试图给 const 变量重新赋值 | const x = 1; x = 2; |
ReferenceError: xxx is not defined | 变量从未声明(冒泡到全局也没找到) | 直接用未声明的变量名 |
ReferenceError: Cannot access 'pizza' before initialization | let/const 的暂时性死区 | 声明前使用 let/const 变量 |
变量提升是一个"不应存在"的设计缺陷——它与代码的书写顺序和直觉相悖。好在
let和const直接废掉了这个机制:用let声明的变量,在声明之前的那段"暂时性死区"(TDZ)中无法访问。只要你坚持先声明、后使用的原则,就不会踩坑。
总结与实践
从 var 到 let/const,JavaScript 的变量声明体系完成了一次从"能用"到"好用"的跨越:
graph TD
subgraph "ES5 时代的困境"
A["var 没有块级作用域"]
B["没有真正的常量"]
C["变量提升打乱直觉顺序"]
end
subgraph "ES6+ 的解法"
D["let / const<br/>支持块级作用域"]
E["const<br/>真正的不可变绑定"]
F["let / const<br/>不支持变量提升"]
end
A --> D
B --> E
C --> F
三条核心规则
- 默认用
const——只要不需要重新赋值,就用const。它最安全、意图最清晰。 - 需要改值用
let——循环计数器、累加器、状态变量,用let。 - 永远别用
var——除非你在维护上古代码。var的块级作用域缺失和变量提升是颗定时炸弹。
声明方式速查表
| 场景 | 用哪个 | 示例 |
|---|---|---|
| 不变的值 / 配置常量 | const | const API_URL = 'https://...' |
| 对象引用不变 | const | const user = { name: '李' } |
| 循环计数器 | let | for (let i = 0; i < n; i++) |
| 需要重新赋值的变量 | let | let result = 0; result += n; |
| 任何情况 | ❌ var | 2026 年了,别再用了 |
最终建议:JS 的变量声明并不复杂,关键是理解背后的作用域和执行阶段两个概念。打开控制台,把
var_let_const目录下的四个 JS 文件跑一遍,亲眼看看var泄露到块外、let在 setTimeout 里保留闭包值、const锁定引用但不锁定属性——当你看到每个 console.log 的输出和预期一致时,这套知识就真正内化了。