《作用域大冒险:从闭包到内存泄漏的终极探索》
“爱自有天意,天有道自不会让有情人分离”
大家好,关于闭包问题其实实际上是js作用域的问题,那么js有几种作用域呢?
作用域类型 | 关键字/场景 | 作用域范围 | 示例 |
---|---|---|---|
全局作用域 | var (无声明) | 整个程序 | var x = 10; |
函数作用域 | var 在函数内 | 函数内部 | function foo() { var x; } |
块级作用域 | let 、const | {} 代码块内 | if (true) { let x; } |
模块作用域 | ES6 模块 | 单个模块文件 | export const x = 1; |
词法作用域 | 函数定义时 | 定义时的外层作用域链 | 闭包 |
我们常见的就是 全局作用域,函数作用域和块级作用域了。
闭包叫做词法作用域,我没听说过这个词,总而言之,闭包是一个作用域问题
什么是闭包?
闭包(Closure)是 JavaScript 中的一个核心概念,它指的是 函数能够记住并访问其定义时的作用域(词法环境),即使该函数在其作用域之外执行。
用人话来讲就是:闭包是可以访问到另一个函数作用域中变量的函数
在循环嵌套的函数结构中,闭包就很容易理解了。内部函数可以访问到外部函数中的变量,但是外部函数不能访问到内部函数中的变量。
我来举一个例子:
function outerFunction(outerParam) {// 外部函数的变量let outerVar = "我是外部变量";const outerConst = "我是外部常量";function innerFunction(innerParam) {// 内部函数的变量let innerVar = "我是内部变量";// 内部函数可以访问:// 1. 自己的变量console.log("内部函数访问自己的变量:", innerVar);console.log("内部函数访问自己的参数:", innerParam);// 2. 外部函数的变量和参数console.log("内部函数访问外部变量:", outerVar);console.log("内部函数访问外部常量:", outerConst);console.log("内部函数访问外部参数:", outerParam);return innerVar;}console.log("\n----- 分割线 -----\n");// 外部函数尝试访问内部函数的变量(会失败)console.log("外部函数可以访问自己的变量:", outerVar);console.log("外部函数可以访问自己的参数:", outerParam);// 下面这行如果取消注释会报错// console.log("外部函数无法访问内部变量:", innerVar); // ReferenceError: innerVar is not defined// 调用内部函数const result = innerFunction("内部参数");console.log("只能通过内部函数的返回值来获取内部变量:", result);return innerFunction;
}// 测试
const innerFn = outerFunction("外部参数");
console.log("\n----- 分割线 -----\n");
innerFn("新的内部参数");
输出结果:
----- 分割线 -----外部函数可以访问自己的变量: 我是外部变量
外部函数可以访问自己的参数: 外部参数
内部函数访问自己的变量: 我是内部变量
内部函数访问自己的参数: 内部参数
内部函数访问外部变量: 我是外部变量
内部函数访问外部常量: 我是外部常量
内部函数访问外部参数: 外部参数
只能通过内部函数的返回值来获取内部变量: 我是内部变量----- 分割线 -----内部函数访问自己的变量: 我是内部变量
内部函数访问自己的参数: 新的内部参数
内部函数访问外部变量: 我是外部变量
内部函数访问外部常量: 我是外部常量
内部函数访问外部参数: 外部参数
这个代码展示的是:
-
内部函数可以访问:
- 自己的变量(innerVar)和参数(innerParam)
- 外部函数的变量(outerVar)、常量(outerConst)和参数(outerParam)
-
外部函数只能访问:
- 自己的变量(outerVar)和参数(outerParam)
- 无法直接访问内部函数的变量(innerVar)
- 只能通过内部函数的返回值来间接获取内部变量的值
这就是所谓的"作用域链",内部函数可以向上访问外部作用域的变量,但外部作用域不能访问内部作用域的变量。
闭包能干什么?
闭包能干的事情有:变量私有化、回调函数、函数柯里化。
变量私有化
什么是变量私有化?
变量私有化是一种编程技术,目的是限制变量的访问范围,使其只能在特定的作用域或模块内被访问和修改,外部代码无法直接操作。这样可以提高代码的安全性、可维护性,并减少命名冲突的风险。
通过闭包实现一下变量私有化
我们来做一个计数器案例,外部不能修改count,只能通过 increment()
和 getCount()
操作。
function createCounter() {let count = 0; // 私有变量,外部无法直接访问return {increment() {count++;},getCount() {return count;},};
}const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 1
console.log(count); // 报错:count is not defined(无法直接访问私有变量)
我们利用闭包创建了一个私有变量count,无法在外部访问,只有通过我们的increment()
和 getCount()
操作才能操作和访问。
回调函数
回调函数想必就不用介绍了,在任何语言中都有出现和应用。
闭包可以让回调函数记住并访问其定义时的作用域变量,即使回调在异步操作(如
setTimeout
、fetch
、事件监听)中被调用。
介绍一个例子:
setTimeout
回调
问题:直接使用循环变量 i
会导致所有回调输出相同的值(var
没有块级作用域)。
解决:用闭包保存每次循环的 i
值。
// ❌ 错误写法(输出 3 个 3)
for (var i = 0; i < 3; i++) {setTimeout(function() {console.log(i); // 输出 3, 3, 3}, 100);
}// ✅ 正确写法(闭包保存 i 的值)
for (var i = 0; i < 3; i++) {(function(j) { // 立即执行函数(IIFE)创建闭包setTimeout(function() {console.log(j); // 输出 0, 1, 2}, 100);})(i); // 传入当前 i 的值
}
监听事件中的闭包
function setupButtons() {const buttons = document.querySelectorAll('button');for (var i = 0; i < buttons.length; i++) {(function(index) { // 闭包保存当前按钮的索引let count = 0; // 每个按钮独立的计数器buttons[index].addEventListener('click', function() {count++;console.log(`按钮 ${index} 被点击了 ${count} 次`);});})(i);}
}setupButtons();
我们发现在回调函数场景中闭包的作用很多是帮我们留下或者说是记住作用域变量,可以让我们的逻辑更加简单。
函数柯里化
wow,好高级的词!
什么是函数柯里化
函数柯里化(Currying)是一种将 多参数函数 转换为 一系列单参数函数 的技术。
它的核心思想是:每次只接受一个参数,并返回一个新函数,直到所有参数收集完毕,才执行最终计算。
总而言之就是:分布传参。
刚才我们在回调函数中了解到:“我们发现在回调函数场景中闭包的作用很多是帮我们留下或者说是记住作用域变量,可以让我们的逻辑更加简单。”
那么:函数柯里化是指将一个多参数函数转换为一系列单参数函数的过程。那么闭包刚好利用它能记住函数定义时的作用域这一特点就可以实现柯里化;
用闭包做函数柯里化
简单例子:
// 普通函数(3个参数)
function sum(a, b, c) {return a + b + c;
}// 手动柯里化(闭包实现)
function curriedSum(a) {return function(b) {return function(c) {return a + b + c;};};
}// 调用方式
console.log(curriedSum(1)(2)(3)); // 6
闭包带来的危害
1. 内存泄漏(Memory Leaks)
问题描述
闭包会长期持有外部函数的变量,阻止垃圾回收(GC),导致内存无法释放。
示例
function createHeavyObject() {const bigData = new Array(1000000).fill("X"); // 占用大量内存的变量return function() {console.log(bigData.length); // 闭包引用 bigData,即使外部函数执行完毕};
}const holdClosure = createHeavyObject(); // bigData 无法被回收!
解决方法
- 在不需要闭包时手动解除引用:
holdClosure = null; // 释放闭包持有的内存
- 避免在闭包中保存不必要的变量(如 DOM 元素、大对象)。
2. 性能损耗(Performance Overhead)
问题描述
- 闭包会创建额外的作用域链,访问外部变量比访问局部变量稍慢。
- 在频繁调用的函数(如动画、滚动事件)中使用闭包可能导致性能下降。
示例
// 每次触发 scroll 都会访问闭包变量
window.addEventListener("scroll", function() {const cached = heavyCompute(); // 闭包可能持有 heavyCompute 的结果console.log(cached);
});
解决方法
- 对于高频操作,尽量使用局部变量而非闭包变量。
- 用
debounce
/throttle
限制触发频率。
3. 意外的变量共享(Unexpected Shared State)
问题描述
循环中创建的闭包可能共享同一个变量(尤其是用 var
时)。
示例
// ❌ 错误写法:所有按钮都输出 3
for (var i = 0; i < 3; i++) {setTimeout(function() {console.log(i); // 输出 3, 3, 3(i 是共享的)}, 100);
}// ✅ 正确写法:用 IIFE 或 let 隔离变量
for (let i = 0; i < 3; i++) {setTimeout(function() {console.log(i); // 输出 0, 1, 2}, 100);
}
解决方法
- 使用
let
/const
替代var
(块级作用域)。 - 用 IIFE(立即执行函数)隔离变量:
for (var i = 0; i < 3; i++) {(function(j) {setTimeout(function() {console.log(j); // 正确输出 0, 1, 2}, 100);})(i); }
4. 调试困难(Debugging Challenges)
问题描述
闭包的作用域链可能让变量来源难以追踪,增加调试复杂度。
示例
function outer() {const secret = 42;return function inner() {debugger; // 在这里查看作用域链,可能有多层闭包console.log(secret);};
}
const mystery = outer();
mystery();
解决方法
- 在 Chrome DevTools 中使用 Scope 面板查看闭包变量。
- 避免过度嵌套闭包,保持函数简洁。
5. 闭包与 this
的混淆
问题描述
闭包中的 this
可能丢失预期指向(尤其是嵌套函数中)。
示例
const obj = {name: "Alice",greet: function() {return function() {console.log(this.name); // ❌ 输出 undefined(this 指向全局或 undefined)};}
};
obj.greet()(); // 调用内部函数
解决方法
- 使用箭头函数(继承外层
this
):greet: function() {return () => console.log(this.name); // ✅ 正确输出 "Alice" }
- 提前绑定
this
:greet: function() {const self = this;return function() {console.log(self.name); // ✅ 正确输出 "Alice"}; }
闭包是一把双刃剑,它既可以:创建私有变量,避免全局变量污染 也会:闭包会导致内存泄漏,如果不销毁闭包,他引用的外部变量就会一直保存在内存当中,无法被释放,从而导致内存泄漏 。
就像她对你一样,既能在恋爱中让你开心幸福,也会在吵架时让你痛苦不堪
但是,只要我们珍惜这些幸福,勇敢面对好好处理这些痛苦就能让我们的感情历久弥新。闭包也是一样啊,只要我们利用好它的优点,规避全局变量污染就能让我们变成大佬。
所以,面对再多的困难,再多的误会也要拉紧她的手,会幸福的!