🧠 小Dora 的 JavaScript 修炼日记 · Day 4
“闭包是 JS 世界的虫洞——让变量穿越时空、脱离栈帧依然活蹦乱跳。”
——一位调不通缓存函数的高级前端候选人
昨天我们爬出了 this 的万花筒,今天我们跳进一个比 it 更抽象的坑:闭包。
你或许会问:
“我返回了一个函数,结果外面的变量竟然还在!?你不是说 JS 是函数调用完就清理的吗?”
没错,这就是闭包的魔力,但你若只停留在“闭包就是返回函数还能访问外部变量”,那你离高级前端还差一大截。
🎯 本章关键词:词法环境、作用域链、闭包创建、堆栈内存、引用计数、V8 内存逃逸、应用实战
🧩 一、闭包到底是什么?一句话版本太敷衍!
📌 “闭包是函数和其定义时所在的词法环境的组合。” ——《ECMA-262》
这句话简单粗暴,但你得看清楚两个重点词:
函数:一个活着的函数实例;词法环境:定义时的变量抽屉,也就是作用域链。
闭包是 JS 中变量生命周期能超越函数生命周期的唯一渠道。
换句话说:函数本该执行完就销毁,但闭包让你“偷偷揣走”了变量抽屉。
🧪 二、闭包的必要条件是什么?
✅ 条件一:函数嵌套
function outer() {
let a = 1;
return function inner() {
console.log(a); // “抓住”了 a
};
}
✅ 条件二:内部函数被外部引用
const fn = outer(); // outer 本该销毁,但 a 被 inner 捕获
fn(); // 输出 1
❗ 如果你只是调用内部函数不返回出去,闭包不会持续存在。
🔬 三、闭包背后的内存魔术 ——从栈逃逸到堆保活
🧱 没有闭包时:变量生命周期
function foo() {
let x = 1;
return x + 1;
}
- 执行上下文入栈 → 创建词法环境
- 执行完后上下文出栈,变量环境随之销毁
x变量随栈帧一同释放
🧬 有闭包时:V8 触发环境“逃逸”
function foo() {
let x = 1;
return function () {
return x + 1;
};
}
const fn = foo(); // x 逃逸
✅ 发生了什么?
x原本在 foo 的词法环境中- 因为
function返回了一个引用了x的内部函数,V8 判定:x 被“引用逃逸” - 👉 V8 将这块本应栈上的变量环境转移到堆上保留,避免 GC 回收
🧠 V8 层面术语
| 概念 | 描述 |
|---|---|
| 内存逃逸(Escape) | 局部变量因闭包作用逃离原本作用域 |
| 活跃对象 | 当前仍有引用持有的对象,GC 不会清除 |
| 闭包引用计数 | V8 会追踪闭包变量被谁引用、何时释放 |
💡 因此:闭包不仅延长变量生命周期,还会改变其存储位置 → 从栈转堆!
V8 执行过程视角:闭包变量的生命周期详解 🔍
1 函数执行上下文的创建
当执行一个函数时,V8 引擎会为该函数创建一个执行上下文(Execution Context) ,包括:
- 变量环境(Variable Environment) :用于存储函数内的变量和参数
- 词法环境(Lexical Environment) :包含环境记录器和对外层环境的引用
- this绑定和作用域链
执行上下文被压入执行栈(Call Stack) ,确保当前执行顺序。
2 变量存储:栈与堆的协同工作
- 基本数据类型(如 Number、Boolean、null 等)存放于栈内存,访问快且有序
- 对象和函数存放于堆内存,栈中保存对应的引用指针
举例:外层函数中声明的对象变量,实际数据存在堆里,变量名在栈中指向堆地址。
3 外层函数执行完毕,变量何去何从?
正常情况下,函数执行完毕,其执行上下文从执行栈弹出,相关的栈内存即刻释放,变量销毁。
但是,如果函数返回了闭包函数,这个闭包函数引用了外层函数的变量,情况就变得不同:
- 由于闭包函数仍持有外层变量的引用,这些变量无法销毁
- V8 引擎会延长这些变量的生命周期,从栈内存“搬家”到堆内存,确保闭包持续访问它们
4 闭包变量的“搬家”过程
sql
复制编辑
执行 outer():
栈内存分配:outer 执行上下文、count 变量存于栈
outer() 返回 inner 函数后:
outer 执行上下文弹栈,理论上 count 应该释放
但 inner 持有对 count 的引用
V8 将 count 变量“提升”至堆内存,关联到 inner 的词法环境中
此时:
- 栈内没有 count 变量
- 词法环境中保存着对堆内 count 的引用
- 只要闭包 inner 被引用,count 变量就不会被 GC 回收
5 词法环境与执行上下文的区别
- 执行上下文是运行时的栈帧,用于代码执行流程控制
- 词法环境是闭包持有的环境记录器,保存变量绑定(可能位于堆内存)
执行上下文弹栈后,词法环境可能依然存活于堆,确保闭包变量访问无误
📦 四、闭包 + 应用场景实战宝典
🛡️ 场景 1:模拟私有变量(模块封装)
function createCounter() {
let count = 0;
return {
inc: () => ++count,
dec: () => --count,
get: () => count
};
}
const counter = createCounter();
counter.inc(); // 1
counter.inc(); // 2
counter.get(); // 2
✅ 封装变量、限制外部访问,是模块化封装的基础。
🧠 场景 2:记忆函数(Memoization)
function memoize(fn) {
const cache = {};
return function (key) {
if (cache[key] !== undefined) return cache[key];
const result = fn(key);
cache[key] = result;
return result;
};
}
const fib = memoize(n => (n <= 1 ? 1 : fib(n - 1) + fib(n - 2)));
✅ 缓存数据,避免重复计算。面试爆款。
🧪 场景 3:函数柯里化(Currying)
function add(a) {
return function (b) {
return a + b;
};
}
add(3)(4); // 7
✅ 闭包 + 函数式编程结合,灵活传参、可组合性更强。
🔧 场景 4:监听器绑定、计时器陷阱(for循环 + setTimeout)
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i); // 全是 3
}, 0);
}
解决方式:闭包 or let
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0);
})(i);
}
🧠 五、闭包 vs 内存泄漏(你以为的“保留”,其实是“负担”)
闭包强大,但不是免费的。用不好,它就是内存泄漏的毒瘤。
💣 常见踩坑
function bindEvent() {
let huge = new Array(1000000).fill("*");
document.body.onclick = function () {
console.log(huge.length);
};
}
huge被闭包引用,永远不会被释放- DOM 元素与 JS 闭包互相引用,导致整个上下文无法 GC
✅ 最佳实践
- 及时清理引用(如
null) - 慎用闭包持有大对象
- 拆解回调,释放内存(特别在 React / Vue 生命周期内)
🧠 六、闭包与作用域链——再看一遍关系链图
function outer() {
let a = 10;
return function inner() {
console.log(a); // 找到 outer 的 a
};
}
const f = outer();
f();
🧱 变量查找路径:
inner → outer → global
✅ 闭包是作用域链的产物,而不是执行上下文的副作用。
变量查找机制依赖于词法作用域(定义位置),不是调用位置!
🧪 七、自测题 & 面试题精选(深度)
✅ 快问快答
- 闭包是基于哪种作用域模型形成的?
✅ 答案:词法作用域 - 变量被闭包引用后,在哪个内存中保留?
✅ 答案:堆内存 - 哪些情况容易造成闭包内存泄漏?
✅ 答案:闭包引用了大对象或 DOM 且未释放
🧠 面试实战题
题 1:输出结果是什么?为什么?
function foo() {
let a = 1;
return function () {
a++;
console.log(a);
};
}
const f = foo();
f(); // ?
f(); // ?
✅ 答案:2 和 3
✅ 解析:a 存在于闭包中,未被销毁,每次调用都会累加
题 2:如何实现只执行一次的函数?
function once(fn) {
let called = false;
return function (...args) {
if (called) return;
called = true;
return fn(...args);
};
}
题 3:闭包会增加 GC 压力吗?为什么?
✅ 答案:会。闭包使函数执行完后其作用域仍保留于堆中,如果引用链未释放,会导致 GC 无法回收,增加内存占用。
📋 闭包专项面试自检 Checklist(加强版)
- 我能用图表示出闭包与词法作用域的绑定关系?
- 我理解闭包变量从栈“逃逸”到堆的原因?
- 我知道闭包是如何避免变量被 GC 回收的?
- 我能手写闭包实现私有变量和缓存函数?
- 我能结合实际项目解释闭包的用途和风险?
- 我知道闭包和内存泄漏之间的风险点和优化方式?
- 我了解 V8 是如何追踪闭包引用的?(Mark-Sweep、引用计数等)
✅ Day 4 打卡总结(硬核内化)
| 概念 | 精炼理解 |
|---|---|
| 闭包 | 函数 + 定义时的词法环境的组合 |
| 内存逃逸 | 闭包变量逃出栈帧,被提升至堆,避免被 GC 回收 |
| 应用场景 | 数据封装、缓存函数、柯里化、定时器陷阱、私有变量实现 |
| 注意点 | 谨防闭包造成内存泄漏(DOM 引用、大对象) |
| 关键原理 | 闭包来自词法作用域链,非调用顺序,与 this、上下文栈无关 |
📌 如果你能在脑海中构建出“词法环境 → 闭包引用 → 逃逸到堆 → 生命周期延长”的链路图,那么你已经跨进高级前端的大门。
🚀 下一篇:Day 5 | JavaScript 异步机制揭秘:Event Loop、微任务、宏任务、消息队列的天梯图谱
如需我配图 / 出题 / 生成图谱请告诉我。是否需要同步写下一篇?