JavaScript 执行机制:从调用栈到闭包

53 阅读5分钟

一、调用栈与执行上下文:代码的 "任务调度中心"

1. 调用栈:函数执行的 "后进先出" 队列

调用栈是 JavaScript 引擎管理函数执行顺序的核心机制,本质是一个后进先出(LIFO)的数据结构。可以想象成一叠盘子:最后放入栈的函数会最先被执行,执行完毕后才会 "弹出" 栈,让前面的函数继续执行。

🌰 生活类比:

  • 一次放一个的单口网球盒,你先放进去 A 网球(函数 A 入栈),再进去 B 网球(函数 B 入栈),此时调用栈里 B 网球在顶部。
  • 你必须先拿出 B 网球(函数 B 出栈),才会拿出 A 网球(函数 A 继续执行)。

🔍 调用栈示意图:

image.png

执行上下文 就是上面提到的 网球

关键规则:后入栈的黄色上下文必须先出栈,橙色上下文才能执行。

2. 执行上下文:代码运行的 "环境档案"

执行上下文 是 JavaScript 引擎为每行代码创建的 "执行环境档案",记录了当前代码能访问的变量、函数等信息。它分为两部分:

  • 变量环境:存储var声明的变量和函数声明(值为undefined或函数体)。
  • 词法环境:存储let/const声明的变量(存在暂时性死区)。

🔍 执行上下文栈示意图:

image.png

❓ 为什么栈的大小不能无限大?

  • 内存限制:浏览器对调用栈的大小有严格限制,否则递归过深会导致 "栈溢出"(RangeError)。
  • 性能考量:过大的栈会占用过多内存,影响代码执行效率。

二、编译过程:代码的 "预处理工厂"

JavaScript 是 "先编译后执行" 的语言,引擎会分阶段处理代码:

1. 全局编译过程:为整个程序搭建框架

步骤解析:

  1. 创建全局执行上下文:生成一个用于存储全局变量的 "容器"。
  2. 扫描变量声明:将var变量名存入容器,初始值设为undefined
  3. 扫描函数声明:将函数名存入容器,值为函数体(注意:函数声明会覆盖同名变量)。

📝 代码示例:

console.log(a);  // 输出 undefined(编译阶段已声明a,但未赋值)
console.log(fn); // 输出 [Function: fn](函数声明优先)
var a = 1;
function fn() {}

编译阶段全局上下文状态

变量名
aundefined
fn函数体

2. 函数编译过程:为函数调用做准备

步骤解析:

  1. 创建函数执行上下文:专门用于该函数的 "临时容器"。
  2. 处理形参与变量:形参和var变量初始化为undefined
  3. 绑定实参:将调用时传入的实参值赋给形参。
  4. 处理函数声明:函数体内的函数声明会覆盖同名变量。

📝 代码示例:

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函数体
bundefined
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);
}

五、核心机制总结

  1. 调用栈:管理函数执行顺序,后进先出,有内存限制。

  2. 执行上下文:记录代码执行环境,分变量环境和词法环境。

  3. 编译过程:先扫描声明(变量提升),再执行代码。

  4. 作用域链:变量查找的层级规则,let/const 形成块级作用域。

  5. 闭包:内部函数保存外部作用域变量,注意内存泄漏风险。

通过理解这些机制,能帮助你更好地调试代码、优化性能,并避免常见的 JS 陷阱~