闭包(Closure)是 JavaScript 中最具魅力的语言特性之一。
它不仅支撑了高阶函数、模块化、上下文绑定等高级编程技巧,更是前端开发中性能优化、状态管理、封装设计的核心工具。本文将从闭包的基本概念讲起,深入剖析其底层机制,并结合六个真实场景进行详细讲解,让你看完后能够:
✅ 理解闭包的本质
✅ 分清闭包和作用域链的关系
✅ 掌握 this 的绑定机制
✅ 看懂并写出防抖、节流、记忆函数等常见闭包应用
✅ 在实际项目中灵活运用闭包解决问题
📚 目录
- 什么是闭包?
- 闭包的本质是什么?它为什么存在?
- 闭包是如何形成的?执行上下文的作用
- 闭包的三大核心特点
- 闭包的六大经典应用场景详解
- 防抖(Debounce)
- 节流(Throttle)
- 上下文绑定(Context Binding)
- 事件监听器中的闭包(含完整 HTML 示例)
- 记忆函数(Memoization)
- 模块封装(IIFE)(含完整 HTML 示例)
- 关于 this 的深度剖析
- 闭包的注意事项与性能优化
- 总结:闭包到底是什么?我们为什么要用它?
一、什么是闭包?
✅ 最简单的定义:
闭包是一个函数能够访问并记住它的词法作用域,即使该函数在其作用域外执行。
🔍 举个例子:
function outer() {
let count = 0;
return function inner() {
console.log(++count);
};
}
const counter = outer();
counter(); // 输出 1
counter(); // 输出 2
在这个例子中,inner() 函数就是一个闭包。它虽然在 outer() 外部被调用,但依然可以访问 outer() 内部的变量 count。
二、闭包的本质是什么?它为什么存在?
🧩 我们先来想一个问题:
如果没有闭包,函数只能使用全局变量或参数传递数据。那如果我需要一个函数记住一些状态,又不想让这个状态暴露给全局呢?
这就是闭包存在的意义:为函数提供私有状态,形成独立的作用域空间。
📌 闭包的本质是:
一个函数 + 它创建时所处的词法作用域环境的组合体。
你可以把它想象成一个“盒子”,里面装着函数本身和它能访问的所有变量。
三、闭包是如何形成的?执行上下文的作用
🧠 执行上下文(Execution Context)
每次函数被调用时,都会创建一个执行上下文。它包括:
- 变量对象(Variable Object):存储函数内部声明的变量、函数、参数等
- 作用域链(Scope Chain):决定当前函数能访问哪些变量
this值
📌 举个例子说明闭包的形成过程:
function createCounter() {
let count = 0;
return function () {
return ++count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
执行流程如下:
createCounter()被调用 → 创建执行上下文 →count = 0- 返回了一个匿名函数,它引用了
count - 即使
createCounter()已经执行完毕,由于返回的函数仍然持有对count的引用,所以它不会被垃圾回收。 - 每次调用
counter(),都会修改count,因为它存在于闭包环境中。
四、闭包的三大核心特点
| 特点 | 描述 |
|---|---|
| 保持变量不被销毁 | 即使函数已经执行完毕,只要还有函数引用了它,就不会被垃圾回收 |
| 形成私有作用域 | 变量只对外部开放的函数可见,避免污染全局 |
| 实现状态封装 | 可以用来保存状态,实现类似类的私有属性 |
五、闭包的六大经典应用场景详解
1️⃣ 防抖(Debounce)
📌 场景描述:
防止用户频繁触发某个操作,比如输入框搜索建议、窗口大小调整等。
💡 示例代码:
function debounce(fn, delay) {
let timerId;
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delay);
};
}
🧾 应用示例:
input.addEventListener('input', debounce((e) => {
console.log('发送请求:', e.target.value);
}, 300));
🔍 核心机制:
- 每次触发都清除之前的定时器
- 只有当最后一次触发后经过一定时间不再触发,才真正执行回调
📌 运行结果说明:
当你在输入框快速连续输入时,例如输入 abc,控制台不会立即打印三次,而是等待300ms内没有新的输入后,才输出最终值 "发送请求: abc"。
2️⃣ 节流(Throttle)
📌 场景描述:
控制函数执行频率,确保单位时间内只执行一次,如滚动加载、动画帧控制。
💡 示例代码:
function throttle(fn, limit) {
let lastRan = 0;
return function (...args) {
const now = Date.now();
if (now - lastRan >= limit) {
fn.apply(this, args);
lastRan = now;
}
};
}
🧾 应用示例:
window.addEventListener('resize', throttle(() => {
console.log('窗口大小改变');
}, 1000));
🔍 核心机制:
- 判断上次执行时间和当前时间间隔是否满足条件
- 满足则执行,否则跳过
📌 运行结果说明:
当你拖动浏览器边框调整窗口大小时,每秒钟只会输出一次 "窗口大小改变",即使你持续调整窗口。
3️⃣ 上下文绑定(Context Binding)
📌 场景描述:
解决 this 指向错误的问题,特别是在异步回调中。
❗ 问题示例:
const obj = {
name: 'closure',
sayName: function () {
setTimeout(function () {
console.log(this.name); // undefined
}, 1000);
}
};
obj.sayName();
✅ 解决方案:
- 使用箭头函数(推荐)
setTimeout(() => {
console.log(this.name); // closure
}, 1000);
- 使用
.bind(this)
setTimeout(function () {
console.log(this.name);
}.bind(this), 1000);
- 使用中间变量
that = this
const that = this;
setTimeout(function () {
console.log(that.name);
}, 1000);
📌 运行结果说明:
如果不使用闭包或其他方式绑定 this,this.name 会是 undefined(因为 setTimeout 中的 this 指向全局)。使用上述任意一种方法后,都能正确输出 "closure"。
4️⃣ 事件监听器中的闭包
📌 场景描述:
在事件处理函数中保留状态而不污染全局变量。
💡 示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>闭包中的事件监听</title>
</head>
<body>
<button id="myButton">Click Me</button>
<script>
const obj = {
message: "Hello from object",
init: function(){
const button = document.getElementById('myButton');
const that = this;
button.addEventListener('click', function(){
console.log(that.message);
});
}
};
obj.init();
</script>
</body>
</html>
🔍 代码解析:
这段代码展示了如何在事件监听中利用闭包访问外部对象的状态:
obj是一个包含message属性和init方法的对象。init方法获取按钮并为其添加点击事件监听器。- 在监听器函数中,通过
that.message来访问obj.message。 - 这里之所以能访问到
obj.message,是因为that是一个自由变量,被监听器函数捕获形成了闭包。
📌 关键点:
- 使用
that = this缓存this的值,以便在事件回调中访问原始对象的属性。 - 事件回调函数形成了一个闭包,保留了对外部作用域变量的引用。
📌 运行结果说明:
当你点击页面上的按钮后,控制台会输出 "Hello from object",这说明闭包成功保留了对 obj.message 的引用。
5️⃣ 记忆函数(Memoization)
📌 场景描述:
缓存函数执行结果,提高性能,适用于递归、复杂计算等场景。
💡 示例代码:
function memoize(fn) {
const cache = {};
return function (...args) {
const key = JSON.stringify(args);
if (key in cache) return cache[key];
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}
🧾 应用示例:
function factorial(n) {
if (n === 0) return 1;
return n * factorial(n - 1);
}
const memoizedFactorial = memoize(factorial);
memoizedFactorial(5); // 第一次计算
memoizedFactorial(5); // 缓存命中
🔍 核心机制:
- 使用对象作为缓存池
- 将参数序列化为字符串作为键
- 第一次计算后缓存结果,后续直接读取
📌 运行结果说明:
第一次调用 memoizedFactorial(5) 会进行计算,返回 120;第二次调用相同参数时,函数会直接从缓存中取出结果,而不会再次计算,从而提升性能。
6️⃣ 模块封装(IIFE)
📌 场景描述:
使用立即执行函数表达式(IIFE)创建私有作用域,实现模块封装。
💡 示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>立即执行函数 IIFE</title>
</head>
<body>
<script>
const Counter = (function () {
let count = 0; // 私有变量
function increment() {
return ++count;
}
function reset() {
count = 0;
}
return function (){
return{
getCount:function(){
return count;
},
increment:function(){
return increment();
},
reset:function(){
reset();
}
}
}
})();
const counter1 = Counter();
const counter2 = Counter();
console.log(counter1.getCount()); // 0
counter1.increment();
console.log(counter2.getCount()); // 1
</script>
</body>
</html>
🔍 代码解析:
这段代码展示了一个基于 IIFE 的计数器模块封装:
Counter是一个由 IIFE 返回的工厂函数。- 在 IIFE 内部,定义了私有变量
count和两个操作方法increment、reset。 - 工厂函数返回一个新的对象,提供
getCount、increment、reset三个公开方法。 counter1和counter2是通过调用Counter()创建的实例。
📌 关键点:
count是闭包变量,被多个实例共享,因此它们的操作会互相影响。- 若希望每个实例拥有独立的
count,应在工厂函数中重新定义count,而不是共享 IIFE 中的变量。
📌 运行结果说明:
counter1.getCount() 初始返回 0,调用 counter1.increment() 后 count 增加为 1。接着调用 counter2.getCount(),发现也返回 1,说明两个实例共享同一个 count 变量。
六、关于 this 的深度剖析
📌 this 是什么?
this是 JavaScript 中一个特殊的运行时绑定,它的值取决于函数的调用方式,而不是定义方式。
🧩 四种常见的绑定规则:
| 绑定方式 | 描述 | 示例 |
|---|---|---|
| 默认绑定 | 非严格模式下指向 window,严格模式下为 undefined | fn() |
| 隐式绑定 | 方法调用,this 指向调用者 | obj.method() |
| 显式绑定 | 使用 call/apply/bind 显式指定 this | fn.call(obj) |
| new 绑定 | 构造函数调用,this 指向新对象 | new Person() |
🎯 箭头函数中的 this
箭头函数没有自己的 this,它的 this 是继承自外层作用域的 this。
const obj = {
name: 'closure',
sayName: () => {
console.log(this.name); // window.name 或 undefined
}
};
❌ 错误:箭头函数不能用于对象方法中绑定 this
✅ 正确:普通函数或使用 .bind(this) 更合适
七、闭包的注意事项与性能优化
⚠️ 注意事项:
- 内存泄漏风险:闭包会阻止垃圾回收,应手动清理不需要的状态
- 调试困难:闭包变量不容易查看,调试时需注意作用域链
- 影响性能:大量闭包可能导致内存占用过高
✅ 优化建议:
- 使用完闭包后,及时解除引用(如赋值为
null) - 避免在循环中创建过多闭包
- 对于高频函数可使用
requestIdleCallback替代setTimeout
八、总结:闭包到底是什么?我们为什么要用它?
闭包 = 函数 + 作用域链 + 私有状态管理
✅ 闭包的核心价值:
- 提供函数私有状态,避免全局污染
- 实现模块封装,构建健壮的组件
- 支撑防抖、节流、记忆函数等实用功能
- 是现代前端框架(React、Vue)中状态管理的基础
🧠 学习闭包的关键思路:
- 从作用域链理解闭包的形成
- 通过执行上下文分析闭包生命周期
- 结合实际案例理解闭包的应用场景
- 对比不同写法,理解闭包的优劣
- 最后回到语言本质,理解闭包为何存在
📖 一句话总结闭包:
闭包是 JavaScript 中最优雅的设计之一,它让我们可以在函数之外,安全地保存状态、隔离作用域、控制行为。