🚀 闭包:JavaScript 里的 "时间胶囊",藏着你不知道的内存魔法
你是否曾在调试代码时困惑:明明函数已经执行完出栈了,为什么它内部的变量还能被外部访问?🤔 就像一个人离开家后,家里的东西却能被远方的朋友随意取用 —— 这背后,藏着 JavaScript 中最 "神秘" 却又最常用的概念:闭包。
今天我们就一层层剥开闭包的面纱,从词法作用域的 "前世" 讲到闭包的 "今生",再到实际开发中的 "妙用",让你彻底搞懂这个面试官最爱问的知识点。
📌 先懂 "词法作用域":闭包的 "出生地"
要理解闭包,必须先搞懂词法作用域—— 这是闭包的 "户口本",决定了它从哪里来。
词法作用域的核心规则很简单:函数的作用域在它被声明时就确定了,和它什么时候被调用、在哪里被调用毫无关系。就像一个人出生时的籍贯,不会因为长大后去了其他城市而改变。
看个例子(来自 scope_chain/1.js):
javascript
运行
function bar() {
console.log(myName); // 输出 "geektime"
}
function foo() {
var myName = 'geek';
bar(); // 调用bar,但bar的作用域和foo无关
}
var myName = 'geektime';
foo();
为什么 bar 打印的是全局的myName而不是 foo 里的?因为 bar 在声明时,它的作用域链里就没有 foo—— 它 "出生" 时周围只有全局作用域,所以查找变量时只会往全局找。
这就是词法作用域的 "静态性":声明时定终身,调用时不改变。而闭包,就是基于这个特性产生的 "特殊现象"。
✨ 闭包是什么:带着 "家乡记忆" 的函数
闭包的本质,可以用一句话概括:当一个内部函数引用了外部函数的变量,并且这个内部函数被外部访问时,就形成了闭包。就像一个在外打拼的人,随身带着家乡的特产,即使离开了家乡,也能让别人尝到家乡的味道。
看这个经典例子(来自 scope_chain/3.js):
javascript
运行
function foo() {
var myName = "geektime"; // 外部函数的变量
let test1 = 1;
// 内部函数(对象的方法本质是函数)
var innerBar = {
getName: function() {
console.log(test1); // 引用外部变量test1
return myName; // 引用外部变量myName
},
setName: function(newName) {
myName = newName; // 修改外部变量myName
}
}
return innerBar; // 内部函数被外部获取
}
var bar = foo(); // foo执行完出栈,但innerBar被保存到全局
bar.setName("geekbang");
bar.getName(); // 输出 1,返回 "geekbang"
当 foo 执行完后,理论上它的执行上下文会被销毁,内部变量myName和test1也该被回收。但实际情况是:
bar.setName和bar.getName作为 innerBar 的方法,在声明时引用了 foo 里的myName和test1- 这两个方法被返回到全局并赋值给
bar,相当于 "带出了 foo 的作用域" - 为了让这些方法能正常访问
myName和test1,JavaScript 引擎会保留 foo 的作用域,形成一个 "封闭的环境"—— 这就是闭包
📝 闭包形成的 3 个关键条件(缺一不可)
从上面的例子能总结出闭包形成的核心条件,记好这 3 点,再也不会判断错:
- 函数嵌套:存在内部函数(可以是对象方法、匿名函数等)嵌套在外部函数中;
- 变量引用:内部函数引用了外部函数的变量(或参数);
- 外部访问:内部函数被外部函数以外的作用域访问(比如被返回、被赋值给外部变量等)。
用一张图形象表示:
plaintext
外部函数foo → 内部函数(getName/setName)→ 引用foo的变量
↑ ↓
└───────────────── 被外部bar引用 ───────────┘
(形成闭环,闭包诞生)
💡 闭包的 "超能力":这些场景非它不可
闭包不是花架子,实际开发中到处都是它的身影。掌握这些场景,才算真正会用闭包:
1. 模块化:实现私有变量
JavaScript 没有原生的 "私有变量",但闭包可以模拟:
javascript
运行
function createCounter() {
let count = 0; // 私有变量,外部无法直接访问
return {
add: () => count++,
get: () => count
}
}
const counter = createCounter();
counter.add();
console.log(counter.get()); // 1(只能通过暴露的方法操作count)
这里的count就像被闭包 "保护" 起来的秘密,只有返回的方法能接触到。
2. 防抖节流:控制函数执行频率
处理 resize、scroll 等高频事件时,闭包能帮我们 "记住" 上次执行时间:
javascript
运行
// 防抖:事件触发后延迟n秒执行,重复触发则重新计时
function debounce(fn, delay) {
let timer = null; // 闭包保存timer状态
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
}
// 监听窗口 resize,100ms内只执行一次
window.addEventListener('resize', debounce(handleResize, 100));
3. 柯里化:把多参数函数拆分成单参数函数
javascript
运行
function add(a) {
return function(b) { // 闭包保存a的值
return a + b;
}
}
const add5 = add(5);
console.log(add5(3)); // 8(a=5被闭包记住了)
⚠️ 闭包的 "坑":内存泄漏要注意
闭包会保留外部函数的作用域,这意味着:如果闭包被长期持有(比如挂载在 window 上),它引用的变量会一直占用内存,无法被垃圾回收。
避免内存泄漏的小技巧:
- 不需要使用闭包时,手动解除引用(比如赋值为 null);
- 尽量少在闭包里引用大对象,必要时拆分对象只保留需要的属性。
🎯 总结:闭包是 "限制" 也是 "自由"
闭包就像 JavaScript 给函数的一份 "特殊授权"—— 让函数即使离开 "出生地",也能带着家乡的 "记忆" 工作。它基于词法作用域而生,却突破了函数执行上下文的生命周期限制。
记住:理解词法作用域是看懂闭包的前提,掌握闭包的形成条件是用好它的关键。下次再遇到闭包相关的问题,不妨想想那个 "带着家乡特产的游子",或许就豁然开朗了~
最后问问大家:你在项目中用闭包解决过什么棘手问题?欢迎在评论区分享你的经验!👇