闭包为何能“记住”变量?揭秘 JS 内存模型与执行上下文的真相

46 阅读8分钟

🌟 本文将带你从零开始,深入理解 JavaScript 的内存机制、执行上下文、词法环境、闭包等核心概念。无论你是刚接触前端的新手,还是想夯实底层原理的进阶开发者,这篇文章都为你量身打造!


第一部分:JavaScript 是什么类型的语言?🧠

在深入内存之前,我们先要搞清楚 JavaScript 的语言特性。

✅ 动态弱类型语言

JavaScript 是一门 动态弱类型语言,这意味着:

  • 动态:变量的数据类型是在运行时确定的,而不是在声明时。
  • 弱类型:不同数据类型之间可以自动转换(隐式类型转换)。

来看一个例子:

let bar;
console.log(typeof bar); // "undefined"

bar = 12;
console.log(typeof bar); // "number"

bar = "极客时间";
console.log(typeof bar); // "string"

bar = true;
console.log(typeof bar); // "boolean"

bar = null;
console.log(typeof bar); // "object" ← 这是 JS 的一个历史 bug!

🔍 对比 C 语言(静态弱类型)

int a = 1;
char* b = "极客时间";
bool c = true;
c = a; // ❌ 编译器会报错或警告(除非强制转换)

在 C 中:

  • 变量类型一旦声明就不能随意改变(静态类型特征);
  • 但 C 允许大量隐式类型转换(比如 intfloatcharint),属于 弱类型语言

而在 JS 中:

  • 变量可以随时“变身”(动态类型);
  • 类型转换更宽松(弱类型)。

💡 关键点
JS 不需要你提前声明变量类型,引擎会在运行时自动判断和处理;而 C 必须声明类型(静态),但类型约束不如 Java/C# 等强类型语言严格(弱类型)。


❓ 答疑解惑 Q&A

  • Q1:为什么 typeof null 返回 "object"
    这是 JavaScript 创始人 Brendan Eich 在 1995 年设计时的一个历史性 bug。由于底层用 3 位二进制标识类型,null 的机器码全为 0,被误判为对象。虽然后来发现,但为了兼容性一直保留至今。
  • Q2:动态语言 vs 静态语言,哪个更好?
    没有绝对好坏。动态语言开发快、灵活;静态语言更安全、性能高。TypeScript 就是在 JS 基础上加了静态类型检查,兼顾两者优点。

第二部分:JavaScript 的内存模型 🏗️

JavaScript 引擎(如 V8)将内存分为两大区域:栈内存(Stack)堆内存(Heap)


📦 栈内存(Stack)—— 存放简单数据 & 执行上下文

  • 特点:速度快、空间小、连续分配、自动管理。

  • 存储内容

    • 原始类型(Primitive Types):number, string, boolean, undefined, null, symbol, bigint
    • 函数调用记录(即“执行上下文”)

示例:值拷贝(简单类型)

function foo() {
  var a = 1;
  var b = a; // 拷贝值
  a = 2;
  console.log(a); // 2
  console.log(b); // 1 ← 不受影响
}
foo();

这里 ab 是两个独立的栈变量,互不影响。


🗃️ 堆内存(Heap)—— 存放复杂对象

  • 特点:空间大、不连续、分配/回收慢、需垃圾回收(GC)。

  • 存储内容

    • 对象(Object)、数组(Array)、函数(Function)等引用类型

示例:引用拷贝(复杂类型)

function foo() {
  var a = { name: '极客时间' };
  var b = a; // 拷贝的是引用地址(指针)
  a.name = '极客邦';
  console.log(a); // { name: '极客邦' }
  console.log(b); // { name: '极客邦' } ← 被同步修改!
}
foo();

ab 都指向堆中同一个对象,所以修改一个会影响另一个。

🔍 内存图示:

栈内存:
a ──┐
    ├──→ [堆内存中的对象 { name: '极客邦' }]
b ──┘

❓ 答疑解惑 Q&A

  • Q1:为什么简单类型放栈,复杂类型放堆?
    因为简单类型体积小、生命周期短,适合快速分配/释放;而对象大小不确定、可能很大,放在堆中更灵活,避免栈溢出。
  • Q2:JS 能直接操作内存吗?
    不能!不像 C/C++ 可以用 malloc/free 手动管理内存。JS 的内存由引擎自动管理(通过垃圾回收机制),开发者无需关心。

第三部分:执行上下文(Execution Context)与调用栈 🧩

每当 JS 代码运行,引擎都会创建一个 执行上下文(Execution Context) ,它是代码执行的“舞台”。


🎭 执行上下文的组成

每个执行上下文包含两个核心环境:

  1. 变量环境(Variable Environment)

    • 存放 var 声明的变量、函数声明
    • 在代码执行前就完成初始化(变量提升
  2. 词法环境(Lexical Environment)

    • 存放 letconst、块级作用域变量
    • 更精确地反映代码的词法结构(即“写在哪里”)

📌 注意:现代 JS 引擎中,这两个环境在逻辑上是分开的,但实际实现可能合并。


📚 调用栈(Call Stack)—— 执行上下文的管理者

  • JS 是单线程语言,靠 调用栈 管理函数调用顺序。
  • 每调用一个函数,就压入一个执行上下文;函数执行完,就弹出。

示例:

function greet() {
  console.log("Hello");
}
function main() {
  greet();
  console.log("Done");
}
main();

调用栈变化:

  1. 全局上下文入栈
  2. main() 调用 → main 上下文入栈
  3. greet() 调用 → greet 上下文入栈
  4. greet 执行完 → 弹出
  5. main 执行完 → 弹出
  6. 回到全局上下文

⚠️ 如果递归太深,调用栈会溢出(Stack Overflow)!


❓ 答疑解惑 Q&A

  • Q1:变量提升是怎么回事?
    var 和函数声明会在代码执行前被“提升”到作用域顶部。例如:

    console.log(a); // undefined(不是报错!)
    var a = 1;
    

    实际执行顺序是:

    var a;          // 提升
    console.log(a); // undefined
    a = 1;
    
  • Q2:let 也会提升吗?
    会,但不会初始化!在声明前访问会报 暂时性死区(TDZ) 错误:

    console.log(b); // ReferenceError
    let b = 2;
    

第四部分:闭包(Closure)—— 自由变量的“捕获者” 🔒

闭包是 JS 最强大也最令人困惑的特性之一。


🧩 什么是闭包?

闭包 = 内部函数 + 外部作用域中被引用的变量(自由变量)

当一个内部函数引用了外部函数的变量,并且这个内部函数在外部被调用时,就会形成闭包。

示例:

function foo() {
  var myName = "极客时间";
  function getName() {
    return myName; // 引用了外部变量 myName
  }
  return getName;
}

const getName = foo();
console.log(getName()); // "极客时间"

即使 foo() 已经执行完毕,myName 本该被销毁,但由于 getName 引用了它,JS 引擎会将 myName 保存在 堆内存中的 closure 对象 里!


🔬 闭包的底层机制

  1. 编译阶段:JS 引擎扫描函数体,发现内部函数(如 getName)引用了外部变量(myName)。
  2. 判断闭包:引擎识别这是一个闭包场景。
  3. 内存分配:在堆内存中创建一个 closure(foo) 对象,将 myName 等自由变量存入其中。
  4. 函数绑定getName[[Scope]] 内部属性指向这个 closure(foo)

🌟 关键:闭包让变量“活”得更久,突破了正常作用域的生命周期限制。


🧪 闭包的实际用途

  • 模块模式(封装私有变量)
  • 回调函数(如事件处理、setTimeout
  • 函数工厂

示例:函数工厂

function createCounter() {
  let count = 0;
  return function() {
    return ++count;
  };
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2 ← count 被持久保存!

❓ 答疑解惑 Q&A

  • Q1:闭包会导致内存泄漏吗?
    可能会!如果闭包引用了大对象,且长时间不释放,就会占用堆内存。但现代 JS 引擎的 GC 很智能,只要没有引用,就会回收。
  • Q2:所有函数都是闭包吗?
    严格来说,所有函数都有闭包能力,但只有当它引用了外部自由变量时,才真正形成闭包。全局函数引用全局变量不算闭包。

第五部分:作用域链与 this(补充知识)🌐

虽然本文重点是内存,但理解 作用域链 对闭包至关重要。


🔗 作用域链(Scope Chain)

  • 每个执行上下文都有一个 outer 指针,指向其词法父级作用域。
  • 查找变量时,沿着作用域链向上搜索,直到全局。

示例:

var globalVar = "global";
function outer() {
  var outerVar = "outer";
  function inner() {
    console.log(globalVar, outerVar); // 依次在 innerouterglobal 中查找
  }
  inner();
}
outer();

📌 作用域链是词法作用域(Lexical Scope)的体现——由代码书写位置决定,而非调用位置。


🎯 关于 this

  • this 与作用域链无关!它由 函数调用方式 决定。
  • 不属于闭包机制,但常被混淆。

示例:

const obj = {
  name: "极客时间",
  getName() {
    return this.name; // this 指向 obj
  }
};
const fn = obj.getName;
console.log(fn()); // undefined(this 指向全局或 undefined)

记住:闭包捕获的是变量,不是 this


❓ 答疑解惑 Q&A

  • Q1:箭头函数有闭包吗?
    有!但箭头函数没有自己的 thisarguments,它的作用域链直接继承自外层函数。
  • Q2:如何查看闭包内容?
    在 Chrome DevTools 中,展开函数的 [[Scopes]] 属性,可以看到 Closure 项。

总结:一张图看懂 JS 内存与执行机制 🗺️

┌───────────────────────┐
│      全局代码         │
└──────────┬────────────┘
           ↓
┌───────────────────────┐
│   全局执行上下文       │ ← 栈内存
│  - 变量环境 (var)     │
│  - 词法环境 (let/const)│
│  - outer: null        │
└──────────┬────────────┘
           ↓
┌───────────────────────┐
│     堆内存             │
│  - 对象 { ... }        │
│  - closure(foo)        │ ← 闭包存储自由变量
└───────────────────────┘
  • :快、小、存原始值 + 执行上下文
  • :大、慢、存对象 + 闭包
  • 闭包:让外部变量“逃逸”到堆中,延长生命周期
  • 执行上下文:由调用栈管理,包含变量/词法环境
  • 作用域链:决定变量查找路径

结语 💫

JavaScript 的内存机制看似抽象,但一旦理解了 栈 vs 堆、值 vs 引用、执行上下文 和 闭包 的关系,你就能写出更高效、更可靠的代码。

🚀 建议:多用浏览器调试工具观察内存和作用域,实践是最好的老师!

希望这篇深度解析,能为你打开 JS 底层世界的大门。欢迎收藏、转发,也欢迎在评论区提出你的疑问!


你现在已经掌握了

  • JS 的动态弱类型本质
  • 栈内存与堆内存的区别
  • 执行上下文与调用栈的工作原理
  • 闭包的形成机制与内存表现
  • 常见误区与最佳实践

继续加油,未来的 JS 大神!💪✨