一、调用栈与执行上下文:代码的 "任务调度中心"
1. 调用栈:函数执行的 "后进先出" 队列
调用栈是 JavaScript 引擎管理函数执行顺序的核心机制,本质是一个后进先出(LIFO)的数据结构。可以想象成一叠盘子:最后放入栈的函数会最先被执行,执行完毕后才会 "弹出" 栈,让前面的函数继续执行。
🌰 生活类比:
- 一次放一个的单口网球盒,你先放进去 A 网球(函数 A 入栈),再进去 B 网球(函数 B 入栈),此时调用栈里 B 网球在顶部。
- 你必须先拿出 B 网球(函数 B 出栈),才会拿出 A 网球(函数 A 继续执行)。
🔍 调用栈示意图:
执行上下文 就是上面提到的 网球
关键规则:后入栈的黄色上下文必须先出栈,橙色上下文才能执行。
2. 执行上下文:代码运行的 "环境档案"
执行上下文 是 JavaScript 引擎为每行代码创建的 "执行环境档案",记录了当前代码能访问的变量、函数等信息。它分为两部分:
- 变量环境:存储
var声明的变量和函数声明(值为undefined或函数体)。 - 词法环境:存储
let/const声明的变量(存在暂时性死区)。
🔍 执行上下文栈示意图:
❓ 为什么栈的大小不能无限大?
- 内存限制:浏览器对调用栈的大小有严格限制,否则递归过深会导致 "栈溢出"(
RangeError)。 - 性能考量:过大的栈会占用过多内存,影响代码执行效率。
二、编译过程:代码的 "预处理工厂"
JavaScript 是 "先编译后执行" 的语言,引擎会分阶段处理代码:
1. 全局编译过程:为整个程序搭建框架
步骤解析:
- 创建全局执行上下文:生成一个用于存储全局变量的 "容器"。
- 扫描变量声明:将
var变量名存入容器,初始值设为undefined。 - 扫描函数声明:将函数名存入容器,值为函数体(注意:函数声明会覆盖同名变量)。
📝 代码示例:
console.log(a); // 输出 undefined(编译阶段已声明a,但未赋值)
console.log(fn); // 输出 [Function: fn](函数声明优先)
var a = 1;
function fn() {}
编译阶段全局上下文状态:
| 变量名 | 值 |
|---|---|
| a | undefined |
| fn | 函数体 |
2. 函数编译过程:为函数调用做准备
步骤解析:
- 创建函数执行上下文:专门用于该函数的 "临时容器"。
- 处理形参与变量:形参和
var变量初始化为undefined。 - 绑定实参:将调用时传入的实参值赋给形参。
- 处理函数声明:函数体内的函数声明会覆盖同名变量。
📝 代码示例:
function fn(a) {
console.log(a); // 输出 [Function: a](编译阶段函数声明覆盖形参)
var a = 2;
console.log(a); // 输出 2(赋值后)
function a() {}
var b = function() {};
console.log(b); // 输出 [Function: b]
function d() {};
var d = a;
console.log(d); // 输出 2(a此时已被赋值为2)
}
fn(1);
编译阶段函数上下文状态:
| 变量名 | 值 |
|---|---|
| a | 函数体 |
| b | undefined |
| d | 函数体 |
三、作用域链:变量查找的 "导航地图"
1. 作用域链的工作原理
当代码查找变量时,会从当前作用域开始,逐层向上查找父级作用域,直到全局作用域。这就像在多层公寓里找快递:先看自己房间,再问邻居,最后找管理员。
📝 代码示例:
function bar() {
console.log(a); // 1. 自身作用域无a → 2. 找父级foo → 3. 父级无→ 找全局
}
function foo() {
var a = 1;
bar(); // 输出1(在foo作用域找到a)
}
var a = 100;
bar(); // 输出100(在全局作用域找到a)
foo();
2. 经典陷阱:var 与 let 的作用域差异
var arr = [];
for (var i = 1; i <= 5; i++) {
arr.push(function() {
console.log(i); // 输出6,6,6,6,6(var的i是全局变量,循环结束后i=6)
});
}
arr.forEach(fn => fn());
// 修复方案(let形成块级作用域):
var arr = [];
for (let i = 1; i <= 5; i++) {
arr.push(function() {
console.log(i); // 输出1,2,3,4,5(每个i属于独立的块作用域)
});
}
arr.forEach(fn => fn());
四、闭包:变量的 "保鲜盒"
1. 什么是闭包?
闭包是一种特殊的作用域链现象:当内部函数被保存到外部时,它会连带保存所在的外部作用域变量,形成一个 "封闭的环境"。可以理解为:
- 外部函数 A 执行完本应销毁,但内部函数 B 被外部引用,导致 A 的变量被 "锁" 在 B 的环境中,无法被垃圾回收。
📝 代码示例:
function foo() {
var a = 1;
return function bar() {
console.log(a); // 闭包:bar记住了foo中的a
};
}
var c = foo(); // foo执行完毕本应销毁,但c引用了bar
c(); // 输出1(bar访问到了foo中的a)
2. 闭包的双刃剑:优点与缺点
| 优点 | 缺点 |
|---|---|
| 1. 实现 "私有变量" 封装 | 1. 变量无法释放,导致内存泄漏 |
| 2. 保存函数执行状态(如计数器) | 2. 过多闭包会占用内存 |
内存泄露:调用栈的可用空间越来越少
3. 闭包经典案例:循环绑定事件
// 错误写法(所有按钮点击都输出5):
for (var i = 0; i < 5; i++) {
btn[i].onclick = function() {
console.log(i); // 访问的是全局i,循环结束后i=5
};
}
// 正确写法(利用闭包保存每次循环的i):
for (var i = 0; i < 5; i++) {
(function(j) { // 立即执行函数创建独立作用域
btn[j].onclick = function() {
console.log(j); // 输出0-4
};
})(i);
}
五、核心机制总结
-
调用栈:管理函数执行顺序,后进先出,有内存限制。
-
执行上下文:记录代码执行环境,分变量环境和词法环境。
-
编译过程:先扫描声明(变量提升),再执行代码。
-
作用域链:变量查找的层级规则,let/const 形成块级作用域。
-
闭包:内部函数保存外部作用域变量,注意内存泄漏风险。
通过理解这些机制,能帮助你更好地调试代码、优化性能,并避免常见的 JS 陷阱~