
你有没有遇到过这样的诡异现象?代码明明没有错,console.log却打印出
undefined而不是报错?或者定时器里的变量值永远都一样?这些"灵异事件"的幕后黑手,就是JavaScript的Hoisting机制。
我看过太多年轻开发者在面试时对Hoisting一知半解。更糟糕的是,即使工作三五年,他们对Hoisting的理解仍然停留在"var会提升,let不会"这样的模糊概念上。结果呢?线上bug、performance问题、内存泄漏……一大堆诡异问题都跟这个"隐形杀手"有关。
今天我要做的,就是把Hoisting从神秘的"黑魔法"变成你能完全掌控的工具。
很多人说"Hoisting就是变量声明被提到了作用域的顶部"。这个说法既对又不对。
准确的说法应该是:JavaScript引擎在代码执行前,会对代码做一次预编译扫描,把所有的声明(变量、函数)登记到内存中。这个过程,就是Hoisting。
关键点:只有"声明"被提升,"赋值"不会。
让我用一个更贴切的比喻:
想象你去一家餐厅,点菜流程是这样的:
在JavaScript中,Hoisting表现出三种完全不同的行为:
┌─────────────────────────────────────────────────────────────┐
│ JavaScript变量的三种Hoisting模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ var let/const function │
│ ├─ 声明提升 ├─ 声明提升 ├─ 声明+定义 │
│ ├─ 初始化为undefined ├─ 暂时性死区(TDZ) │ 完全提升 │
│ └─ 可访问 └─ 不可访问 └─ 可直接调用 │
│ │
└─────────────────────────────────────────────────────────────┘
console.log(a); // 打印:undefined (不是ReferenceError!)
var a = 10;
console.log(a); // 打印:10
JavaScript引擎实际执行的是这样的:
// 预编译阶段(Hoisting发生的地方)
var a; // 只有声明被提升,赋值留在原地
// 执行阶段
console.log(a); // 此时a已被声明,但还没赋值,所以是undefined
a = 10; // 这里才是赋值
console.log(a); // 10
深度解析:为什么初始化为undefined而不是报错?
因为JavaScript在设计之初,规定了var声明的变量在创建阶段就要被初始化为undefined。这是为了兼容某些特殊场景(比如函数顶部的var声明),但这个设计决策带来了很多问题。
很多开发者会在这里踩坑:
function test() {
console.log(x); // undefined
if (true) {
var x = 5;
}
console.log(x); // 5
}
test();
为什么第二个console.log是5而不是报错?
因为var是函数作用域,不是块作用域。上面的代码在引擎看来其实是这样的:
function test() {
var x; // 整个函数内都提升到顶部
console.log(x); // undefined
if (true) {
x = 5; // 这里才是赋值
}
console.log(x); // 5
}
这就是var最臭名昭著的问题所在——函数级作用域导致的意外提升。
// 在全局作用域
console.log(window.myVar); // undefined
var myVar = 100;
console.log(window.myVar); // 100
每一个var声明都会污染全局对象。这在大型应用中是灾难性的,容易导致命名冲突和难以追踪的bug。
这是最容易被误解的概念。很多开发者说"let和const不会被提升",但这是错误的。
正确的说法是:let和const也被提升,但它们进入了暂时性死区。
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 5;
┌──────────────────────────────────────────────────────────────┐
│ 从代码开始执行到let声明行的过程(TDZ演示) │
├──────────────────────────────────────────────────────────────┤
│ │
│ 预编译阶段: │
│ ┌─────────────────────────────────────────────┐ │
│ │ 发现let声明的b → 开始TDZ ✗ → b不可访问 │ │
│ └─────────────────────────────────────────────┘ │
│ ↓ │
│ 执行阶段: │
│ ┌─────────────────────────────────────────────┐ │
│ │ 第1行: console.log(b) → 进入TDZ → ❌错误 │ │
│ │ 第2行: let b = 5 → TDZ结束 → ✓初始化 │ │
│ │ 第3行: console.log(b) → ✓可以访问 = 5 │ │
│ └─────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
有人会问,既然都提升了,为什么还要设计这个"禁区"?答案是:为了防止var带来的那些脑子坑爹的问题。
对比一下:
// var的陷阱 - 我想要的是100,结果被提升搞成了undefined
function oldWay() {
console.log(x); // undefined ← 这是很多bug的源头
var x = 100;
}
// let的安全设计 - 我要么完全不访问,要么等到初始化
function newWay() {
console.log(x); // ReferenceError ← 明确的错误提示
let x = 100;
}
TDZ的哲学是:与其悄悄给你undefined这个"地雷",不如直接告诉你"这个变量还没准备好"。
for (let i = 0; i < 3; i++) {
console.log(i); // 0, 1, 2
}
// 这里访问i → ReferenceError(i只在for块内可见)
let/const遵守块作用域,这对循环、条件语句的行为有重大影响。我们待会在实战部分会看到。
这是最容易混淆的地方。我见过很多开发者弄反了:
// ✅ 函数声明 - 完全提升,包括函数体
greet(); // 打印:"Hello!"
function greet() {
console.log("Hello!");
}
背后发生了什么:
// 预编译阶段
function greet() { // 整个函数连同函数体一起被提升
console.log("Hello!");
}
// 执行阶段
greet(); // 此时函数已完全可用
但函数表达式呢?
// ❌ 函数表达式 - 函数体NOT提升(只有变量提升)
sayHi(); // TypeError: sayHi is not a function
var sayHi = function() {
console.log("Hi!");
};
为什么是TypeError而不是ReferenceError?
因为var sayHi被提升并初始化为undefined,所以变量存在,但它的值是undefined。当你试图调用undefined时,就是TypeError。
// 引擎实际执行的
var sayHi; // 提升并初始化为undefined
sayHi(); // ❌ 试图调用undefined() → TypeError
sayHi = function() {
console.log("Hi!");
};
现代写法应该这样做:
// ✅ 用const + 箭头函数(推荐)
const sayHello = () => {
console.log("Hello!");
};
// sayHello(); ← 只有在这行之后才能调用
箭头函数+const的组合给了你最好的保护:TDZ确保你不会在变量初始化前访问它。
这个问题被无数初学者问过,也被无数老鸟踩过:
// ❌ 使用var的错误代码
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出:3, 3, 3
}, 100);
}
为什么全是3?
┌─────────────────────────────────────────────────────────────┐
│ var的函数作用域导致的"灾难" │
├─────────────────────────────────────────────────────────────┤
│ │
│ 预编译阶段: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ var i; ← 只有一个i,作用域是整个函数! │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 执行阶段: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 第一次循环: i = 0, setTimeout添加回调 │ │
│ │ 第二次循环: i = 1, setTimeout添加回调 │ │
│ │ 第三次循环: i = 2, setTimeout添加回调 │ │
│ │ 循环结束: i = 3 ← 循环变量停在这里! │ │
│ │ │ │
│ │ 100ms后,三个回调执行,都访问同一个i │ │
│ │ 此时i已经是3了,所以三个都打印3 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
使用let的正确做法:
// ✅ 使用let - 每次迭代都有新的i
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出:0, 1, 2
}, 100);
}
为什么let就行了?
let在for循环中有特殊处理——每次迭代都会创建一个新的块作用域和一个新的i绑定。这样每个setTimeout的回调都"记住"了各自时刻的i值。
┌─────────────────────────────────────────────────────────────┐
│ let的块作用域如何解决问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第一次迭代:创建块作用域1,let i = 0 │
│ ├─ setTimeout回调"捕获"这个i(值为0) │
│ │
│ 第二次迭代:创建块作用域2,let i = 1 │
│ ├─ setTimeout回调"捕获"这个i(值为1) │
│ │
│ 第三次迭代:创建块作用域3,let i = 2 │
│ ├─ setTimeout回调"捕获"这个i(值为2) │
│ │
│ 100ms后,三个回调执行,各自访问各自作用域的i │
│ 结果:0, 1, 2 ✓ │
│ │
└─────────────────────────────────────────────────────────────┘
这个在大型项目中制造过无数bug:
function processUser(hasPermission) {
if (hasPermission) {
var userData = fetchData(); // 从服务器获取
}
console.log(userData); // 如果hasPermission为false,这里是undefined
// 后续代码可能会因为userData是undefined而崩溃
}
初学者会被这样的逻辑迷惑:
用let就不会有这个问题:
function processUser(hasPermission) {
if (hasPermission) {
let userData = fetchData();
}
console.log(userData); // ReferenceError ← 清楚地告诉你变量不存在
}
我见过一个实际的线上bug:
// 某个列表渲染函数
function renderList(items) {
var result = [];
for (var i = 0; i < items.length; i++) {
var item = items[i]; // ❌ 这会被提升到函数顶部
var html = renderItem(item);
result.push(html);
}
// 这里还能访问i和item(函数作用域)
console.log('Last item:', item); // 仍然存在!
return result;
}
这看起来是小问题,但在高频调用的情况下(比如列表滚动、实时搜索),每次调用都会保留这些临时变量在内存中,导致垃圾回收效率降低。
用let就能让这些临时变量在块结束时立即释放。
console.log(global.x); // undefined
var x = 5;
// 为什么是undefined而不是报错?
// 因为var x被提升并在全局对象上创建属性
function test() {
console.log(y); // undefined
var y = 10;
}
test();
// 这里的y是局部变量,不会污染全局作用域
if (true) {
console.log(z); // ReferenceError - TDZ
let z = 15;
}
┌────────────────────────────────────────────────────────────────────┐
│ JavaScript Hoisting 完整地图 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┬──────────────┬─────────────┬──────────────────────┐ │
│ │ │ var │ let/const │ function声明 │ │
│ ├─────────┼──────────────┼─────────────┼──────────────────────┤ │
│ │ 是否提升 │ ✓ 提升 │ ✓ 提升 │ ✓ 完全提升 │ │
│ │ 初始化 │ undefined │ TDZ(禁区) │ 完整函数体 │ │
│ │ 作用域 │ 函数级 │ 块级 │ 函数/块级 │ │
│ │ 何时可用 │ 声明时 │ 执行到声明行 │ 任何时候(如在块内) │ │
│ │ 访问前 │ undefined │ ReferenceErr│ 可调用 │ │
│ │ 污染全局 │ 是(危险) │ 否(安全) │ 是(如果全局) │ │
│ └─────────┴──────────────┴─────────────┴──────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
// ❌ 不好 - 还在用var
var name = "张三";
var age = 25;
// ❌ 不好 - 不必要的let
let name = "张三"; // 这个值永远不变,为什么不用const?
let age = 25;
// ✅ 好 - const为默认选择
const name = "张三";
const age = 25;
// 只有当确实需要重新赋值时才用let
let counter = 0;
counter++;
理由:
// ❌ 差 - 为什么还要用var
function processData(items) {
var result = [];
for (var i = 0; i < items.length; i++) {
var item = items[i];
result.push(transform(item));
}
return result;
}
// ✅ 好 - 使用let + const
function processData(items) {
const result = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
result.push(transform(item));
}
return result;
}
在现代JavaScript项目中(2024年及以后),如果你看到var,那么:
// ❌ 非常差 - 利用Hoisting的"特性"
console.log(x); // undefined
// ... 中间大量代码 ...
var x = 100;
// ✅ 好 - 遵循声明在前的原则
const x = 100;
console.log(x); // 100
// .eslintrc.json
{
"rules": {
"no-var": "error", // 禁止var
"prefer-const": "warn", // 优先const
"no-use-before-define": "error" // 禁止使用前定义
}
}
当你遇到undefined的问题时,这样做:
// 原代码
console.log(userData); // undefined
var userData = fetchUser();
// 改成这样便于理解发生了什么
var userData; // ← Hoisting后的实际情况
console.log(userData); // undefined ← 问题确认
userData = fetchUser();
// 不好 - 污染全局
var count = 0;
function increment() {
count++; // 如果误写,容易改全局的count
}
// 好 - 用IIFE隔离
const counter = (() => {
let count = 0; // 块作用域内
return {
increment() { count++; },
get() { return count; }
};
})();
// 如果遇到ReferenceError,使用这个技巧
try {
console.log(x); // 这行会抛错
} catch (e) {
console.log('错误详情:', e.message);
// 查看代码,找到let x在哪一行
}
let x = 5;
var x = 1;
function test() {
console.log(x); // 打印什么?
if (true) {
var x = 2;
}
console.log(x); // 打印什么?
}
test();
答案:
let a = 1;
function test() {
console.log(a); // 打印什么?
if (true) {
let a = 2;
}
console.log(a); // 打印什么?
}
test();
答案:
console.log(typeof fn); // 打印什么?
var fn = () => {
console.log('Hi');
};
答案:
Hoisting不仅仅是JavaScript语言特性,它是理解代码执行模型的关键。当你真正理解了Hoisting:
想要成为真正的JavaScript高手?理解Hoisting只是第一步。更多的深度内容、实战项目、源码解读,请关注《前端达人》
下期预告: 《事件循环(Event Loop)真的这么复杂吗?从源码到实战,彻底搞懂异步JavaScript》
一起进阶JavaScript,成为真正的前端高手!💪