🔥还读不懂JavaScript 闭包?看完这篇就够了🔥

95 阅读11分钟

闭包 (Closure) 是什么?

或许有人这么对你说过:JavaScript中最难理解的就是闭包了! 但是嘞,没关系,接下来我将带你一步步打开闭包的各种小秘密~

So, 闭包是个什么?

闭包是由一个函数及其相关的引用环境组合而成的一个整体。这个引用环境包含了在函数创建时能够访问的所有局部变量、参数和声明。闭包使得一个函数可以“记住”并访问它被定义时所在的作用域中的变量

image.png

从这个例子可以看到,里面的函数记住了外面的变量,外面的变量成为了里面函数引用环境的一部分,它们一起组成了一个闭包。

按理说,当我们return之后,垃圾回收机制会将createCounter的内容全部销毁只留下return的内容才对,而闭包就是这么神奇,它将内部函数要用的东西拿回来了,不销毁了。

换一个说法就是:你有一个很好的女朋友,她对你的照顾无微不至,但是有一天,由于一些不可抗力的因素,她离开了你的生活,但你仍然记得她的方方面面,她虽然从你的生活里消失了,但你仍然记着她的一切~

ChatGPT Image 2025年8月16日 15_59_40.png

如何创造一个闭包?

闭包的关键要素

  1. 函数嵌套:通常情况下,闭包涉及一个内部函数(内层函数)和一个外部函数(外层函数)。内层函数可以访问外层函数的局部变量。
  2. 外部变量引用:内层函数必须直接或间接地引用了外层函数中的至少一个局部变量。
  3. 返回内部函数:为了形成有效的闭包,外层函数通常会返回这个内部函数作为结果。这样,即使外层函数已经执行完毕,通过返回的内部函数仍然可以访问那些原本应该是临时存在的局部变量。

OK,记住这3点,我们就可以开始了:

function f1() {
    var n = 999;
    function f2() {
        console.log(n);
    }
    return f2; 
}
var result = f1();
result();

在这个例子中,我们里面的函数f2引用了它定义时所在作用域的变量n,它们共同形成了闭包

image.png

f2记住了n,并且可以对n进行各种操作,n就成为了自由变量

接下来,我将交给你一个任务,你需要利用闭包,创造一个函数nAdd,作用为n+1,该怎么做呢?

首先,它需要记住n这个变量,形成闭包,那么它就需要定义在n所在的作用域上,我们如何能在外界访问这个函数呢?我相信大家大多都卡在这里了,实际上我们直接在里面声明nAdd即可,不需要在其前面加varletconst.....这样直接写函数名的,会默认为全局变量声明。

function f1() {
    var n = 999;
    nAdd = function(){
        n+=1;
    }
    function f2() {
        console.log(n);
    }
    return f2; 
}
var result = f1();
result(); // 999
nAdd();
result(); // 1000

OK,这就是一个闭包的例子啦!

闭包能够干神魔呢?

封装私有变量

闭包可以用来创建私有变量和方法,从而实现数据封装,防止被外界修改和全局变量污染,可以将一些变量仅仅用于函数内部,大大提高数据的私密性和安全性

function createCounter() {
    let count = 0; // 私有变量

    return {
        increment: function() {
            count++;
        },
        decrement: function() {
            count--;
        },
        getCount: function() {
            return count;
        }
    };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.getCount()); // 输出: 2
counter.decrement();
console.log(counter.getCount()); // 输出: 1

在这个例子中,count 是一个私有变量,只能通过 incrementdecrementgetCount 方法来访问和修改.

保存数据状态

闭包让函数可以“记住”之前的调用状态,适用于需要记忆的场景。这也是函数柯里化的基础。

function make_adder(x) {
    function adder(y) {
        return x + y
    }
    return adder
}
add5 = make_adder(5)
console.log(add5(3)); // 输出 8 

模块化编程

闭包是模块模式(Module Pattern)的基础,用于组织代码和隔离作用域。

const myModule = (function() {
  let privateVar = 0;
  return {
    getVar: () => privateVar,
    setVar: (v) => { privateVar = v }
  };
})();
myModule.setVar(42);
console.log(myModule.getVar()); // 42

回调函数/异步编程

在异步操作(如 setTimeout、Promise)中,闭包保留上下文变量。

const person = {
            name: 'Allen',

            sayHello: function () {
                var that = this;
                setTimeout(function () {
                    console.log(`${that.name} says hi!`);
                }, 1000) // 记住了 person 的 this
            }
        }
        person.sayHello();

闭包的实际应用

闭包能够做的事情有很多了,可以做防抖、节流、函数柯里化、偏函数......

在这里我们介绍一下函数柯里化吧!

函数柯里化

函数柯里化(Currying)是一种将 多参数函数 转换为 一系列单参数函数 的编程技术。它由数学家 Haskell Curry 提出,是函数式编程的重要概念之一。

柯里化的核心思想是:

  • 将一个接受 多个参数的函数,分解成 多个嵌套的单参数函数
  • 每次调用时只传递一个参数,并返回一个新函数,直到所有参数传递完毕,最终返回计算结果。

也就是:

// 假设add接收3个参数
add(3,5,6);
// 变为
add(3)(5)(6) // 一次只接收1个参数

所以,我们该如何手写一个函数柯里化呢?

现实中,函数的参数是逐步收集的,就像集齐六颗无限宝石才能打一个响指。

而参数的收集过程都是相似的,所以我们可以使用递归。

我们可以用curry函数将add函数柯里化,让它的参数变为一个个收集的模式,首先我们接收add函数作为参数,才能对其进行操作。

function add(a,b,c){
    return a+b+c;
}

function curry(fn){

}

我们先考虑这个问题:当参数收集到什么时候,我们就可以执行add函数了呢?

当我们收集的参数的长度 = add函数参数的长度的时候,就代表参数收集完毕了吧,这个时候我们就能够执行函数了。

由此,我们需要一个函数来接收传递的参数:

function add(a,b,c){
    return a+b+c;
}

function curry(fn){ 
    let judge = (...args)=>{   // rest运算符,负责接收传入的参数
        if(args.length == fn.length) {  // 当 收集的参数的长度 = add函数参数的长度,返回add函数并执行
             return fn(...args);
        }
    }
}

接下来我们该考虑一下,当参数的数量不够的时候我们该怎么办呢?

Design006_20211210141524708.png

这个时候我们当然要继续接收参数了,而这个过程是不是和之前是一样的?那么我们之前利用judge函数来接收参数,现在继续用就行了。

function add(a,b,c){
    return a+b+c;
}

function curry(fn){ 
    let judge = (...args)=>{   
        if(args.length == fn.length) {  
             return fn(...args);
        }
        return (...newArgs)=> judge(...args,...newArgs); // 参数不够,就继续收集,
        //...newArgs收集下一次传来的参数,再用递归,...args,...newArgs负责将新旧参数合并,递归到下一个judge
    }
}

为什么利用 rest 运算符?

因为我们收集参数的时候,不一定是每次只收集一个,也可能一次收集好几个,利用rest运算符就可以很好的将它们全部收集。

OK,基本逻辑都完成了,接下来该如何调用呢?那就是在curry函数中,将judge return就可以了

function add(a,b,c){
    return a+b+c;
}

function curry(fn){ 
    let judge = (...args)=>{   
        if(args.length == fn.length) {  
             return fn(...args);
        }
        return (...newArgs)=> judge(...args,...newArgs); 
    }
    return judge;
}

const addCurry = curry(add);

console.log(addCurry(1)(2)(3)); // 6
console.log(addCurry(1)(2,3)); // 6

这就是函数柯里化的手写实现了,我相信这个时候你要问:“主播主播,你这个闭包体现在哪里啊?”

闭包就体现在:

  • judge 函数是在 curry 函数内部定义的,并且它使用了 curry 函数的参数 fn
  • judge 函数在每次递归调用时都会保留 args 的状态,这意味着它可以累积之前传入的参数。

也就是说,我们调用curry函数时,返回了judge,这个时候传递的参数fn应该被销毁的,但是judge函数仍然能够使用它,这里就产生了闭包。

而在judge函数中,我们每次递归,都会创造一个新的judge实例,之前的judge连同所有内容应当被销毁掉了,但是新的judge中,仍然可以使用上一次的args作为参数与newArgs合并,这里就产生了闭包。

闭包的缺点

闭包这么好,能做这么多事情,但闭包也是有缺点的,它会占用内存,有可能引起内存泄露。

我们既然用闭包记住了某些变量,那么这些变量一定会存储在一个地方,它肯定会存储在内存之中,如果我们不主动销毁它,它就会一直占用内存。

一般浏览器环境或者node.js的执行环境内存大概这么大:

  • 浏览器环境

    • Chrome / Firefox / Edge:一般限制 1.4GB~4GB(取决于设备内存)。
    • 移动端(Android/iOS):通常限制更严格(如 512MB~1GB)。
  • Node.js 环境

    • 默认 1.4GB~2GB(64位系统),可手动提高(--max-old-space-size=4096 设为 4GB)。

因为我们平时练习使用的闭包数据是很小的,不会引起内存泄露,但是如果数据量级变大,就有可能引起内存泄露了。

数据量级影响典型场景
< 1MB几乎不影响小型变量、普通闭包
1MB ~ 100MB可能影响低端设备较大的缓存、图片、JSON 数据
> 100MB明显内存泄露(浏览器可能崩溃)大数组、视频/音频 Blob、未释放的 Canvas 数据
let hugeArray = null;

function createHugeLeak() {
    const bigData = new Array(10_000_000).fill("📊"); // ~100MB
    hugeArray = bigData; // 全局变量引用
}

createHugeLeak(); // 内存直接暴涨 100MB!

引起内存泄露的某些场景

1.DOM 事件 + 闭包(常见内存泄露)**

function setupButton() {
    const button = document.getElementById("myButton");
    button.addEventListener("click", function() {
        console.log(button.id); // 闭包引用 button,导致 button 无法释放
    });
}

问题

  • button 被事件回调引用,回调又被 button 持有(循环引用)。
  • 即使移除 button,内存仍无法释放(除非手动解除事件监听)。

修复方法

function setupButton() {
    const button = document.getElementById("myButton");
    const onClick = function() {
        console.log(button.id);
    };
    button.addEventListener("click", onClick);
    // 需要移除时:
    button.removeEventListener("click", onClick);
    button = null; // 手动解除引用
}

2.全局变量 + 闭包(长期持有数据)

let cache = null;
function fetchData() {
    const bigData = new Array(1_000_000).fill("📊"); // 1MB 数据
    cache = function() { // 全局变量 cache 持有闭包
        console.log(bigData.length);
    };
}
fetchData(); // cache 持有 bigData,即使不再需要也无法 GC

问题

  • bigData 本应在 fetchData 执行完后被回收,但 cache 引用闭包,导致 内存无法释放

修复方法

let cache = null;
function fetchData() {
    const bigData = new Array(1_000_000).fill("📊");
    cache = function() {
        console.log(bigData.length);
    };
    fetchData();
    // 如果不再需要 cache,手动清理
    setTimeout(() => {
        cache = null; // 解除引用,允许 GC 回收 bigData
    }, 5000); // 5秒后释放
}

3.定时器 + 闭包(未清理)

function startInterval() {
    const intervalData = new Array(1000).fill("⏳");
    setInterval(() => {
        console.log(intervalData.length); // 闭包引用 intervalData
    }, 1000);
}
startInterval(); // 即使函数执行完毕,定时器仍持有闭包

问题

  • intervalData 被定时器的回调引用,永远不会释放

修复方法

let intervalId;
function startInterval() {
    const intervalData = new Array(1000).fill("⏳");
    intervalId = setInterval(() => {
        console.log(intervalData.length);
    }, 1000);
}
startInterval();
// 需要停止时:
clearInterval(intervalId); // 停止Interval
intervalId = null; // 解除闭包与定时器的绑定

解除普通闭包

function createClosure() {
  const data = "重要数据";
  return function() {
    console.log(data);
  };
}

let myClosure = createClosure(); // 创建闭包
myClosure(); // 使用闭包

// 解除闭包
myClosure = null; // 打破引用,允许GC回收闭包和data

总结

闭包是一个函数及其相关的引用环境的组合体,它使得函数可以"记住"并访问其定义时所处作用域中的变量,即使该函数在其原始作用域之外执行。

关键特性:

  • 函数嵌套(内层函数访问外层函数变量)
  • 变量引用持久化
  • 通常通过返回内部函数实现

闭包的核心价值

  1. 数据封装:创建私有变量,避免全局污染
  2. 状态保持:函数可以记住之前的调用状态
  3. 模块化编程:实现模块模式的基础
  4. 函数式编程:支持柯里化、高阶函数等特性

使用不当,也许会造成内存泄露。

OK,这一期就是这样啦,如果大家有看不懂的,或者我讲错了,欢迎大家提出!下一期防抖/节流

微信图片_202505120746481.jpg