变量是 JavaScript 程序中最基本的"容器",而作用域则决定了这些容器在代码中的可见范围与生命周期。看似简单的 var、let、const,背后牵扯出变量提升、暂时性死区、词法作用域、作用域链、闭包等一系列核心机制。理解它们,不仅能让你写出更可靠的代码,更是读懂框架源码、排查诡异 Bug 的必备功底。本文将从零开始,层层递进地拆解变量与作用域的完整知识体系,配合大量代码示例与可视化图解,带你用 25 分钟彻底吃透这块 JavaScript 的地基。
为什么变量与作用域是 JavaScript 的地基
在 JavaScript 中,变量是存放数据的"命名容器",而作用域是这些容器的"可见范围规则"。几乎所有 JavaScript 的"诡异行为"——循环里的回调全都输出同一个值、函数内部访问不到外部变量、变量莫名其妙变成 undefined——追根溯源,都是作用域规则在起作用。
let userName = "张玥";
let age = 28;
const isDeveloper = true;
// 作用域:决定变量在哪里能被访问
function greet() {
const message = "你好," + userName;
return message;
}
greet(); // "你好,张玥"(可以访问外层变量)
console.log(message);
// ReferenceError: message is not defined
// 函数内部声明的变量,外部无法访问
三种声明方式:var、let、const
ES6 之前,JavaScript 只有 var 一种声明方式;ES6 引入了 let 和 const,从根源上修补了 var 的诸多设计缺陷。理解三者的差异,是掌握作用域的第一步。
var a = 1;
var a = 2; // 不报错,覆盖前者
/* 2. let:块级作用域,不可重复声明 */
let b = 1;
// let b = 2; // SyntaxError: 重复声明
b = 3; // 允许重新赋值
/* 3. const:块级作用域,不可重新赋值 */
const c = 1;
// c = 2; // TypeError: Assignment to constant
/* 注意:const 保证的是"绑定"不变,不是值不变 */
const list = [1, 2];
list.push(3); // ✅ 允许:修改的是对象内容
// list = []; // ❌ 禁止:重新绑定引用
现代开发默认使用 const,需要重新赋值时再用 let
作用域家族:全局、函数、块级
JavaScript 中的作用域可以分为三类:全局作用域、函数作用域和块级作用域。它们像俄罗斯套娃一样层层嵌套,构成了变量的"可见性地图"。
const globalVar = "全局";
/* 2. 函数作用域:仅函数内部可见 */
function outer() {
var funcVar = "函数内";
console.log(globalVar); // ✅ 可访问
/* 3. 块级作用域:{} 内部 */
if (true) {
let blockVar = "块内";
console.log(blockVar); // ✅
}
// console.log(blockVar); // ❌ ReferenceError
for (let i = 0; i < 3; i++) {
// 每次循环都是一个新的块级作用域
}
}
// console.log(funcVar); // ❌ ReferenceError
作用域由内向外层层嵌套,访问方向只能从内到外
作用域链与词法作用域
当代码访问一个变量时,JavaScript 引擎会从当前作用域开始,逐层向外查找,直到找到该变量或抵达全局作用域为止。这条查找路径就叫作用域链。而作用域链的构建依据,是代码书写位置而非调用位置——这就是词法作用域(静态作用域)。
const name = "全局";
function outer() {
const name = "外层";
function inner() {
console.log(name);
}
return inner;
}
const fn = outer();
fn(); // 输出 "外层",而非 "全局"
// 查找顺序:inner 作用域 → outer 作用域 → 全局作用域
// 沿途第一个匹配的变量胜出
name = "外层" ✅
找到即停,不会继续向外查找
变量提升与暂时性死区(TDZ)
在代码执行之前,JavaScript 引擎会先做一次"预解析",把变量和函数声明提前处理。这个机制就是变量提升(Hoisting)。它解释了为什么有些变量在声明前就能使用,而有些却会直接报错。
console.log(a); // undefined(不报错)
var a = 1;
/* 上面的代码等价于 */
var a;
console.log(a); // undefined
a = 1;
/* let / const:也存在提升,但进入 TDZ */
console.log(b);
// ReferenceError: Cannot access 'b'
// before initialization
let b = 2;
/* 函数声明:整体提升,可直接调用 */
sayHi(); // ✅ "hi"
function sayHi() {
console.log("hi");
}
/* 函数表达式:按变量规则处理 */
sayBye(); // TypeError: sayBye is not a function
var sayBye = function () {};
闭包:作用域的"超能力"
当内部函数引用了外部函数的变量,并且这个内部函数在外部函数返回后仍然被使用,就形成了闭包(Closure)。闭包让函数拥有了"记忆"能力,是模块化、私有变量、函数柯里化等高级模式的基石。
function createCounter() {
let count = 0; // 被闭包"捕获"
return {
increment() {
count += 1;
return count;
},
getCount() {
return count;
}
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getCount(); // 2
/* count 是私有的,外部无法直接修改 */
// console.log(counter.count); // undefined
外部函数已执行完毕,但其变量依然存活
循环中的变量陷阱:var vs let
这是面试与实战中出现频率极高的经典问题:在循环中创建函数,使用 var 和 let 会得到截然不同的结果。理解它,就真正理解了块级作用域的价值。
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 100);
}
// 输出:3 3 3
/* ✅ let:每次迭代生成新的块级作用域 */
for (let j = 0; j < 3; j++) {
setTimeout(() => {
console.log(j);
}, 100);
}
// 输出:0 1 2
/* 使用 IIFE 模拟块级作用域(ES5 时代的方案) */
for (var k = 0; k < 3; k++) {
(function (n) {
setTimeout(() => console.log(n), 100);
})(k);
}
// 输出:0 1 2
全局对象、模块作用域与 IIFE
不同声明方式在全局环境中会产生不同的"副作用"。同时,模块与 IIFE 提供了隔离作用域的有效手段,避免污染全局命名空间。
var globalVar = 1;
console.log(window.globalVar); // 1
/* 2. let / const 不会挂载到 window */
let globalLet = 2;
console.log(window.globalLet); // undefined
/* 3. 跨环境的全局对象:globalThis */
console.log(globalThis === window); // 浏览器
console.log(globalThis === global); // Node.js
/* 4. IIFE:立即执行函数表达式,隔离作用域 */
(function () {
var privateVar = "外部访问不到";
console.log(privateVar);
})();
/* 5. ES Module:每个模块拥有独立作用域 */
// module.js
const moduleVar = 1;
export default moduleVar;
// 模块内变量默认不污染全局
常见错误排查清单
掌握了原理,还要能快速定位问题。下面这份清单覆盖了变量与作用域相关的绝大多数"翻车现场"。
console.log(x);
let x = 1;
// → ReferenceError
/* 错误 2:const 未初始化 */
// const y; // SyntaxError
/* 错误 3:隐式全局变量(严格模式下报错) */
function test() {
undeclared = 1; // 不要这么做!
}
/* 错误 4:块级作用域失效(用 var) */
for (var i = 0; i < 3; i++) {}
console.log(i); // 3(变量泄漏到外层)
/* 错误 5:this 与作用域混淆 */
const obj = {
name: "obj",
arrow: () => this.name,
normal() { return this.name; }
};
obj.arrow(); // undefined(箭头函数无自身 this)
obj.normal(); // "obj"
ReferenceError?先检查 TDZ 与拼写
undefined?检查 var 提升
let
this 指向不对?区分箭头函数与普通函数
建议始终开启严格模式 "use strict"
最佳实践与总结
- 默认使用 const:只有确实需要重新赋值时才用
let,彻底告别var - 声明即初始化:避免声明与赋值分离,减少 TDZ 风险
- 缩小作用域:变量声明在尽可能靠近使用位置的最小作用域内
- 避免隐式全局:始终开启严格模式,防止变量意外泄漏
- 善用块级作用域:用
{}显式划分逻辑边界,配合let隔离临时变量 - 理解闭包开销:闭包会延长变量生命周期,避免无谓的内存占用
- 模块化隔离:使用 ES Module 或 IIFE 避免全局污染
- 区分作用域与 this:作用域由书写位置决定,
this由调用方式决定
"use strict";
const TAX_RATE = 0.06;
function createCart() {
let items = [];
return {
add(item) {
items.push(item);
},
total() {
const sum = items.reduce((s, i) => s + i.price, 0);
return sum * (1 + TAX_RATE);
}
};
}
/* items 被闭包保护,外部无法直接篡改 */