🔥本文献给所有在深夜调试
undefined和ReferenceError的你。JavaScript 并非“随便写写就能跑”的玩具语言——它的执行机制精妙、严谨,甚至充满哲学意味。掌握它,是成为 AI+全栈工程师 的必经之路。
🌐 为什么你需要彻底理解 JS 执行机制?
你是否曾遇到以下场景:
- 明明函数写在后面,却能在前面调用?
var声明的变量输出undefined,而let直接报错?- 函数名和变量名冲突时,到底谁赢?
- 修改一个对象,另一个“看似无关”的对象也变了?
这些不是玄学,而是 JavaScript 执行机制 的自然结果。
JS 虽然是解释型脚本语言,但现代引擎(如 Chrome 的 V8)早已采用 即时编译(JIT) 技术,在执行前进行深度分析。理解这一过程,不仅能写出更健壮的代码,还能在面试中秒杀 90% 的候选人。
⚙️ 一、JavaScript 的两大阶段:编译 + 执行
很多人误以为 JS 是“边解释边执行”,其实不然。
根据《你不知道的 JavaScript》(You Don’t Know JS)系列作者 Kyle Simpson 的观点,JavaScript 在执行前会经历一个“编译阶段”,尽管这个阶段极短,但至关重要。
JavaScript 虽然是解释型脚本语言,但现代引擎(如 Chrome 的 V8)早已采用 即时编译(JIT) 技术,在执行前进行深度分析。理解这一过程,不仅能写出更健壮的代码,还能在面试中秒杀 90% 的候选人。
下图展示了 JavaScript 从源码到执行的完整生命周期:
如图所示,JS 代码并非“直接运行”,而是经历 词法分析 → 语法分析 → 编译(含提升)→ 执行上下文创建 → 调用栈执行 的完整链路。
✅ 编译阶段(Compilation Phase)
V8 引擎在真正执行代码前,会完成以下工作:
-
语法错误检测
若代码存在语法错误(如if (true {),V8 会在编译阶段直接抛出SyntaxError,根本不会进入执行阶段。 -
词法分析(Lexical Analysis)
将源码拆分为 词法单元(tokens),例如:var a = 1; // → ['var', 'a', '=', '1', ';'] -
语法分析(Parsing)
将 tokens 构建成 抽象语法树(AST),这是后续优化和执行的基础。 -
作用域确定(Lexical Scope)
根据《JavaScript 语言精粹》(JavaScript: The Good Parts)作者 Douglas Crockford 的强调:“JavaScript 使用 词法作用域(Lexical Scope),即作用域由代码书写位置决定,而非运行时调用位置。”
-
变量与函数提升(Hoisting)
var声明的变量 → 提升至作用域顶部,初始化为undefinedfunction声明 → 整个函数体被提升let/const声明 → 提升但进入 暂时性死区(Temporal Dead Zone, TDZ)
-
创建执行上下文(Execution Context)
每段可执行代码(全局或函数)都会生成一个 执行上下文对象,包含:- 变量环境(Variable Environment):存放
var、函数声明、this - 词法环境(Lexical Environment):存放
let/const、块级作用域变量
- 变量环境(Variable Environment):存放
📦 二、执行上下文与调用栈:JS 如何“记住”自己在哪?
JavaScript 是单线程语言,靠 调用栈(Call Stack) 管理函数调用顺序。
🧱 执行上下文的结构(源自《你不知道的 JS》)
每个执行上下文包含三部分:
-
变量对象(Variable Object, VO)
存储所有变量、函数声明。全局上下文中即为global object(浏览器中是window)。 -
作用域链(Scope Chain)
一个链表结构,指向当前作用域及所有父级作用域的 VO。
这就是闭包能访问外层变量的原因! -
this 绑定
由调用方式决定(默认绑定、隐式绑定、显式绑定、new 绑定)。
下图直观展示了执行上下文的内部组成:
注意:变量环境与词法环境在 ES6 后被明确区分,分别管理不同类型的声明。
🗃️ 调用栈的工作流程
┌──────────────────────┐
│ fn() 执行上下文 │ ← 栈顶(先执行)
├──────────────────────┤
│ 全局执行上下文 │ ← 栈底(最后退出)
└──────────────────────┘
更直观的可视化如下:
每次函数调用都是一次“入栈”,返回则“出栈”。若递归过深,就会触发 栈溢出(Stack Overflow) 错误。
- 全局代码执行 → 创建 全局执行上下文,压入栈底
- 调用函数 → 创建 函数执行上下文,压入栈顶
- 函数返回 → 上下文出栈,内存释放(除非被闭包引用)
💡 关键点:函数执行完毕后,其上下文通常被销毁。但若存在闭包,相关变量会被保留在堆内存中,直到无引用为止。
🔄 三、变量提升 vs 函数提升:谁优先?为什么?
来看经典案例(来自 1.js):
showName(); // ✅ 输出:函数showName被执行
console.log(myName); // ✅ 输出:undefined
console.log(hero); // ❌ ReferenceError: Cannot access 'hero' before initialization
var myName = 'WJ';
let hero = '钢铁侠';
function showName() {
console.log('函数showName被执行');
}
📌 分析:
showName()能调用 → 函数声明整体提升myName输出undefined→var提升但未赋值hero报错 →let进入 TDZ,在声明前不可访问
📘 《你不知道的 JS》指出:
“提升的本质是 声明被移动到作用域顶部,但赋值仍留在原地。”
再看 2.js:
var myName = 'WJ';
function myName() {
console.log('函数myName被执行');
}
console.log(myName); // 输出:'WJ'
为什么不是函数?
因为:函数声明优先于变量声明,但在执行阶段,var myName = 'WJ' 会覆盖函数引用!
编译后等效于:
function myName() { ... } // 先提升函数
var myName; // 变量声明被忽略(同名)
myName = 'WJ'; // 执行阶段赋值,覆盖函数
🧪 四、函数参数、局部变量、函数声明的“三国杀”
看 3.js 的复杂案例:
var a = 1;
function fn(a) {
console.log(a); // [Function: a]
var a = 2;
function a() {}
var b = a;
console.log(a); // 2
}
fn(3);
🧠 执行上下文构建过程(函数 fn 被调用时):
- 形参
a被传入值3 - 函数声明
function a(){}被提升 → 覆盖形参a var a声明被忽略(同名)- 执行阶段:
- 第一次
console.log(a)→ 输出函数a a = 2→ 赋值,覆盖函数- 第二次
console.log(a)→ 输出2
- 第一次
✅ 结论:函数声明 > 形参 > var 声明
🚫 五、var vs let/const:不仅仅是“能不能重复声明”
4.js 示例:
var a = 1;
console.log(a); // 1
var a = 2; // ✅ 允许重复声明
console.log(a); // 2
// let b = 3;
// let b = 4; // ❌ SyntaxError: Identifier 'b' has already been declared
关键区别:
| 特性 | var | let/const |
|---|---|---|
| 作用域 | 函数级 | 块级({}) |
| 提升 | 提升至顶部,值为 undefined | 提升但处于 TDZ |
| 重复声明 | 允许(静默忽略) | 禁止(语法错误) |
| 全局属性 | 是(挂载到 window) | 否 |
📚 《JavaScript 语言精粹》警告:
“var的设计缺陷导致了无数 bug,应尽量使用let/const。”
🔒 六、严格模式(Strict Mode)真的能阻止重复声明吗?
看 5.js:
'use strict';
var a = 1;
var a = 2; // ✅ 不报错!
很多人误以为严格模式禁止 var 重复声明——这是错的!
严格模式主要禁止:
- 隐式全局变量(
a = 1会报错) - 删除不可删除属性
with语句arguments.callee
但 var 重复声明在严格模式下依然合法,因为这是语言规范允许的。
📉 七、函数表达式不会提升!
6.js:
let func = () => {
console.log('函数表达式不会提升');
};
若你在声明前调用 func(),会得到:
func(); // ReferenceError: Cannot access 'func' before initialization
let func = () => { ... };
因为 func 是 变量,使用 let 声明,处于 TDZ。
即使换成 var:
func(); // TypeError: func is not a function
var func = function() { ... };
因为 var func 提升后值为 undefined,调用 undefined() 报错。
✅ 只有函数声明(
function foo(){})会被提升!
💾 八、内存管理:栈 vs 堆,值拷贝 vs 地址引用
7.js 揭示了 JS 内存模型的核心:
let str = 'hello';
let str2 = str;
str2 = '你好';
console.log(str, str2); // 'hello' '你好'
let obj = { name: 'wj', age: 18 };
let obj2 = obj;
obj2.age++;
console.log(obj.age); // 19
🧠 原理解析:
-
基本类型(String, Number, Boolean, null, undefined, Symbol, BigInt)
→ 存储在 栈内存,赋值时 复制值 -
引用类型(Object, Array, Function)
→ 实际数据存储在 堆内存,变量保存的是 内存地址(指针)
→ 赋值时 复制地址,多个变量指向同一对象
内存布局对比如下图所示:
正因如此,修改 obj2 会同步影响 obj——它们共享同一块堆内存。
📚 《你不知道的 JS》强调:
“JavaScript 中没有‘对象赋值’,只有‘引用赋值’。”
这也解释了为什么修改 obj2 会影响 obj——它们指向同一个堆内存地址。
♻️ 九、垃圾回收:V8 如何清理无用内存?
V8 使用 标记-清除(Mark-and-Sweep) 算法:
- 标记阶段:从根对象(如全局对象)出发,遍历所有可达对象,打上“存活”标记
- 清除阶段:回收未被标记的对象所占内存
💡 闭包会阻止垃圾回收!
如果内部函数引用了外部变量,即使外部函数已执行完毕,其上下文也不会被回收。
🎯 十、总结:JS 执行机制全景图
| 阶段 | 关键动作 | 数据结构 | 引擎行为 |
|---|---|---|---|
| 编译阶段 | 语法检查、提升、作用域确定 | AST、执行上下文 | 创建变量/词法环境 |
| 执行阶段 | 赋值、函数调用、表达式求值 | 调用栈 | 上下文入栈/出栈 |
| 内存管理 | 值/引用存储、垃圾回收 | 栈、堆 | 标记-清除算法 |
🚀 给 AI+全栈工程师的建议
- 永远不要依赖提升写代码 → 声明前置,清晰可读
- 优先使用
const,其次let,避免var - 理解闭包 = 函数 + 词法环境引用
- 调试时思考:“此刻调用栈里有什么?”
- 阅读经典:《你不知道的 JS》《JavaScript 语言精粹》《深入浅出 Node.js》
🌈 最后的话
JavaScript 的执行机制,是语言设计者在灵活性与性能之间权衡的艺术。
它既有历史包袱(如 var),也有现代智慧(如 TDZ、块级作用域)。
作为 AI+全栈工程师,我们不仅要会调 API、写 React、训模型,更要 理解底层逻辑——因为真正的高手,知其然,更知其所以然。
🕊️ 愿你从此不再惧怕
undefined,
愿你的代码如 V8 引擎般高效、优雅、可靠。