闭包
- 函数嵌套:一个内部函数定义在另一个外部函数中。
- 内部函数引用外部函数的变量。
- 外部函数返回或传递内部函数,使其能够在外部函数执行完毕后被调用。
当一个函数能够记住并访问它在被创建时所处的词法作用域(Lexical Scope),即使该函数在当前词法作用域之外执行,它也仍然可以访问那些变量。这种现象就叫做闭包。
闭包的常见使用场景
以下是闭包在日常开发中非常常见的应用场景。
1. 模拟私有变量(数据封装)
这是闭包最经典和重要的用途之一。在 JavaScript 的早期(ES6 class 语法和私有字段 # 出现之前),闭包是实现面向对象编程中“私有成员”的唯一方式。
function createPerson(name) { let _age = 25; // 这是一个“私有”变量,外部无法直接访问
return { getName: function() { return name; }, getAge: function() { return _age; }, growUp: function() { _age++; console.log(`${name} is now ${_age} years old.`); } };}
const person = createPerson("Alice");console.log(person.getName()); // "Alice"console.log(person.getAge()); // 25person.growUp(); // "Alice is now 26 years old."
// 无法直接访问或修改 _ageconsole.log(person._age); // undefinedperson._age = 100; // 这只是给 person 对象添加了一个新属性,并没有改变闭包内的 _ageconsole.log(person.getAge()); // 26 (仍然是闭包中安全的值)优点: 保护了内部数据(_age),只通过暴露的特定方法 (getAge, growUp) 来进行操作,避免了外部的随意篡改,使代码更健壮。这就是封装。
2. 创建特定状态的函数(函数工厂/柯里化)
闭包可以用来创建“预设”了某些参数的函数。
// 一个通用的加法器工厂function makeAdder(x) { // x 被保存在返回的函数闭包中 return function(y) { return x + y; };}
const add5 = makeAdder(5); // 创建一个“加5”的函数const add10 = makeAdder(10); // 创建一个“加10”的函数
console.log(add5(2)); // 7 (相当于 5 + 2)console.log(add10(2)); // 12 (相当于 10 + 2)这种技术在函数式编程中非常常见(称为柯里化 Currying),可以生成更专用、更灵活的函数。
3. 在异步操作中保存状态(回调函数)
在 setTimeout、Promise、事件监听器等异步场景中,闭包无处不在。回调函数需要访问定义它们时的上下文信息。
function waitAndSay(message, delay) { setTimeout(function() { // 这个回调函数是一个闭包 // 它记住了外层的 message 变量 console.log(message); }, delay);}
waitAndSay("Hello after 2 seconds", 2000);waitAndSay("Hi after 1 second", 1000);如果没有闭包,setTimeout 的回调函数在未来执行时,将无法知道当初调用 waitAndSay 时传入的 message 是什么。
4. 解决循环中的经典问题
这是一个非常经典的面试题,完美地展示了闭包的作用。
错误示例(使用 var):
for (var i = 1; i <= 3; i++) { setTimeout(function() { console.log(i); }, i * 1000);}// 预期输出: 1, 2, 3// 实际输出: 4, 4, 4原因: for 循环瞬间就执行完了,此时 i 的值变成了 4。由于 var 是函数作用域,所有 setTimeout 的回调函数都共享着同一个 i。当它们在 1、2、3 秒后执行时,它们去读取 i 的值,发现都是 4。
使用闭包修复(ES5 方法):
for (var i = 1; i <= 3; i++) { (function(j) { // 使用立即执行函数(IIFE)创建新的作用域 setTimeout(function() { console.log(j); // 回调函数引用的是 IIFE 的参数 j }, j * 1000); })(i); // 将每次循环的 i 的值“固定”住,传给 j}// 输出: 1, 2, 3每个 IIFE 都创建了一个新的作用域,把当时 i 的值(1, 2, 3)作为副本 j 保存了下来。setTimeout 的回调形成了对 j 的闭包。
现代解决方法(ES6 let):
ES6 的 let 关键字拥有块级作用域,它在 for 循环中有一个特殊的行为:为每次循环创建一个新的词法环境,从而自动地解决了这个问题。这实际上是语法层面的闭包应用。
for (let i = 1; i <= 3; i++) { // let 会为每次循环创建一个新的 i setTimeout(function() { console.log(i); }, i * 1000);}// 输出: 1, 2, 3有什么场景是“必须”要用闭包的?
这个问题的核心在于“必须”。在编程中,很多问题都有多种解法,但有些场景下,闭包是最自然、最直接、最符合语言设计意图的解决方案。可以说,不使用闭包(或其语法糖)就无法优雅地解决这些问题。
以下场景可以认为是“必须”使用闭包的:
1. 数据封装和私有状态(The Module Pattern)
场景: 你需要创建一个组件或模块,它有自己的内部状态,并且你希望这个状态是私有的,不能被外部直接访问,只能通过你提供的特定接口来操作。
为什么必须用闭包? 这是在 JavaScript 中实现真正信息隐藏(Information Hiding) 的基石。全局变量、对象属性都是公开的。只有函数作用域内的变量才是“私有”的。而闭包是唯一能让外部访问到这个“私有”作用域内部,同时又保持变量本身不被暴露的机制。
如果没有闭包, 你只能:
- 使用全局变量:污染全局命名空间,数据不安全。
- 使用普通对象属性:
person.age = -100,数据可以被随意修改,无法保证其有效性。 - 使用 ES6
class的私有字段 (#):这其实是闭包概念的“语法糖”实现,其底层机制和思想与闭包紧密相关。所以即便你用#,本质上还是在利用闭包的思想。
结论: 在需要创建拥有私有状态的、可复用的组件时,闭包(或其变体和语法糖)是不可或缺的。
2. 将函数与特定状态“绑定”后传递
场景: 当你需要将一个函数作为参数(例如回调函数、事件处理器)传递给另一个函数,并且这个函数在未来执行时需要访问到创建它时的特定上下文数据。
为什么必须用闭包? 函数在 JavaScript 中是“一等公民”,可以像值一样传来传去。但函数本身只是代码逻辑。当你需要“代码逻辑 + 当时的数据”这个组合时,闭包就是这个组合的载体。
- 异步回调:
fetch('/api/user/123').then(user => console.log(user.name)),这个箭头函数就是一个闭包,它能访问到外部的变量(比如一个userId)。 - 事件监听:
button.addEventListener('click', () => alert('Button ' + buttonId + ' was clicked!')),这个回调函数必须“记住”它被绑定时对应的buttonId。 - 函数式编程:像
makeAdder(5)这种,其核心目的就是创建一个“代码 + 数据”的函数包。
结论: 只要你需要在未来的某个时间点,在一个不同的执行上下文中,执行一个需要**“记忆”创建时上下文数据**的函数,你就正在使用闭包。这是 JavaScript 异步和事件驱动模型的根本。
闭包代码
✅ 1. 计数器(经典闭包例子)
function createCounter() { let count = 0; return function () { count++; return count; };}const counter = createCounter();console.log(counter()); // 1console.log(counter()); // 2console.log(counter()); // 3函数 counter 保留了对 count 的引用。
✅ 2. 私有变量封装
function User(name) { let _password = 'secret'; return { getName() { return name; }, checkPassword(pwd) { return pwd === _password; } };}const user = User("Alice");console.log(user.getName()); // "Alice"console.log(user.checkPassword("secret")); // trueconsole.log(user._password); // undefined ❌ 外部访问不到_password 是私有变量,通过闭包保持作用域。
✅ 3. 延迟执行 &
setTimeout
循环闭包问题
❌ 错误写法:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1000);}// 输出:3 3 3✅ 正确闭包方式:
for (var i = 0; i < 3; i++) { (function (j) { setTimeout(() => console.log(j), 1000); })(i);}// 输出:0 1 2或者用 let(块作用域)也可以:
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 1000);}✅ 4. 工厂函数
function multiplier(factor) { return function(x) { return x * factor; };}const double = multiplier(2);console.log(double(5)); // 10double 保留了 factor=2 的闭包。
✅ 5. 缓存函数(记忆函数)
function memoize(fn) { const cache = {}; return function(n) { if (n in cache) { return cache[n]; } else { const result = fn(n); cache[n] = result; return result; } };}const square = memoize(n => n * n);console.log(square(5)); // 25(计算)console.log(square(5)); // 25(读取缓存)✅ 6. 模拟私有方法(模块模式)
const Counter = (function() { let count = 0; return { increment() { count++; }, getCount() { return count; } };})();Counter.increment();Counter.increment();console.log(Counter.getCount()); // 2✅ 7. DOM 事件处理中的闭包
function setupButton(id) { let clicked = false; document.getElementById(id).addEventListener("click", function () { if (!clicked) { console.log("Button clicked!"); clicked = true; } else { console.log("Already clicked."); } });}clicked 被闭包捕获,记住每个按钮是否点过。
✅ 8. 实现 once 函数(只执行一次)
function once(fn) { let called = false; let result; return function(...args) { if (!called) { result = fn.apply(this, args); called = true; } return result; };}const init = once(() => { console.log("初始化执行!"); return 42;});init(); // 输出 "初始化执行!"init(); // 不再执行,只返回结果