从懵圈到通透:我是如何啃下 JS 闭包这块硬骨头的?

19 阅读9分钟

作为刚学 JS 三个月的新手,我最近被闭包折腾得够呛。刷面经时发现这是高频考点,查文档又被 “函数与词法环境的组合” 这种抽象描述绕晕。直到上周用闭包实现了一个小需求 ——“记住用户上一次输入的搜索词”,才突然打通任督二脉。今天就用最接地气的方式,分享我梳理的闭包知识体系。

一、闭包到底是个啥?先别急着看定义

刚学的时候,我总把闭包想象成什么 “高级结构”,直到翻到《你不知道的 JavaScript》里的一句话: “闭包是当函数可以记住并访问其词法作用域,即使函数是在当前词法作用域之外执行时” 。 翻译成人话就是:
一个函数在定义它的作用域之外被调用时,仍然能访问原作用域的变量,这对 “函数 + 变量” 的组合就是闭包。 举个最常见的例子:

function outer() {
    let count = 0; 
    function inner() {
        count++; 
        console.log(count); 
    }
    return inner; 
}

const counter = outer(); 
counter(); // 1
counter(); // 2

这里inner就是闭包。它被outer返回后,在全局作用域执行(脱离了定义时的作用域),但依然能访问outer里的count变量。每次调用countercount都会累加, 这说明count没有被销毁,而是被闭包 “保存” 了。

问题:为什么count没被销毁?

  1. JS 的垃圾回收机制
    当函数执行完毕,其作用域内的变量本应被回收(如outer中的count)。但闭包inner引用了count,导致 **outer的作用域对象无法被释放 **,形成一个「持久化的作用域链」。

    用形象的比喻:inner就像一个「背包客」,离开outer的「老家」时,把count装进了背包。即使走到全局作用域,背包里的count依然存在。

  2. 词法环境的结构
    每个函数创建时都会生成一个「词法环境」,包含:

    • 环境记录:保存变量(如count
    • 外部环境引用:指向外层作用域(outer的词法环境指向全局,inner的词法环境指向outer)。
      inner被返回时,它的词法环境被保留,形成闭包的核心 ——跨作用域的变量引用通道

划重点:闭包形成的三个条件

  1. 存在函数嵌套(外层函数包裹内层函数);
  2. 内层函数引用了外层函数的变量 / 函数;
  3. 内层函数逃逸到外部作用域(如被返回、赋值给全局变量、回调函数等)。

再看一个颠覆认知的例子

let x = 10;
function foo() {
  console.log(x); // 输出10,而非20
}
function bar() {
  let x = 20;
  foo(); // 为什么不输出20?
}
bar();

这里foo定义在全局作用域,它的词法作用域链固定为 全局作用域。即使在bar中调用,foo依然访问的是全局的x。这说明闭包的作用域链是静态绑定的,与调用位置无关。

解析

  1. foo的作用域链在定义时确定
    foo定义在全局作用域,其词法作用域链为:全局作用域 → null(顶层作用域)。无论在哪里调用foo(如bar内部),它永远只能访问定义时的作用域链。

  2. bar的作用域链与foo无关
    bar的作用域链为:bar函数作用域 → 全局作用域。 foobar中调用时,只会沿着自己的作用域链向上查找,不会进入调用者bar的作用域。

二、为什么需要闭包?它能解决什么问题?

我们可能会疑惑:JS 有作用域链,直接用全局变量不行吗?为啥非得用闭包?
举个真实需求:做一个搜索框,需要 “记住用户上一次输入的内容”。如果用全局变量:

let lastInput = ''; 
function saveInput(input) {
    lastInput = input; 
}
// 问题:lastInput暴露在全局,可能被其他代码意外修改

这时候闭包的优势就体现了 ——它能创建私有变量,避免污染全局

function createInputSaver() {
    let lastInput = ''; 
    return function saveInput(input) {
        lastInput = input; 
        console.log(lastInput)
        return lastInput; 
    };
}

const saver = createInputSaver(); 
saver('第一次输入'); // '第一次输入'
saver('第二次输入'); // '第二次输入'

这里lastInput是 “私有” 的,只有saveInput函数能修改它,完美解决了全局变量的隐患。

闭包的典型应用场景

  • 数据私有:如上面的输入记忆功能、模块模式(封装工具库);
  • 函数记忆:缓存计算结果(比如斐波那契数列的记忆化优化);
  • 事件绑定:在循环中为元素绑定事件时保留当前循环的值(经典面试题);

三、闭包的 “坑”:内存泄漏?其实是你用错了

刚学闭包时,总听人说 “闭包会导致内存泄漏”。后来查 MDN 才知道:闭包本身不会导致内存泄漏,不合理使用才会。 比如,如果你在全局作用域里创建了一个闭包,且这个闭包一直被引用(比如作为事件监听函数),那么它的作用域就不会被垃圾回收。如果这个作用域里有大量无用数据,才会导致内存占用过高。 举个反面案例:

function badClosure() {
    const bigData = new Array(10000).fill('数据'); // 大数组
    return function() {
        console.log(bigData.length); 
    };
}

const fn = badClosure(); 
console.log('结束')

正确做法是:当闭包不再使用时,解除对它的引用(比如将变量设为null),这样闭包的作用域就会被回收。

function badClosure() {
    const bigData = new Array(10000).fill('数据'); // 大数组
    return function() {
        console.log(bigData.length); 
    };
}

const fn = badClosure(); 
fn = null; // 手动解除引用,让垃圾回收机制回收

关键点分析:

  1. 闭包的作用域保留

    • badClosure 函数内部创建了一个大数组 bigData,并返回一个闭包函数。
    • 闭包会保留其外部词法环境(即 bigData 的引用),因此即使 badClosure 执行完毕,bigData 也不会被回收,只要闭包存在。
  2. 手动解除引用

    • 当执行 fn = null 时,原本由 fn 引用的闭包函数失去了所有引用。
    • 此时闭包本身成为垃圾回收的候选对象,闭包被回收后,其保留的 bigData 引用也会被释放,最终 bigData 被垃圾回收。

四、闭包与 this 指向:最容易懵的组合拳

学闭包时,我发现它经常和this混在一起考。比如下面这段代码:

const obj = {
    name: '对象',
    getClosure() {
        return function() {
            console.log(this.name); 
        };
    }
};

const closure = obj.getClosure(); 
closure(); // 输出undefined

这里closure是闭包吗?是的,它定义在getClosure的作用域里,被返回后在全局执行。但this.name输出undefined,是因为this的指向和闭包无关!

划重点:闭包保存的是词法作用域中的变量,而this是动态绑定的
上面的例子中,closure在全局执行时,this指向全局对象(浏览器里是window)。如果window没有name属性,就会输出undefined

那怎么让this指向obj?有两种常见方法:

  1. 箭头函数(继承定义时的this
const obj = {
    name: '对象',
    getClosure() {
        return () => { 
            console.log(this.name); // 输出'对象'
        };
    }
};

const closure = obj.getClosure();
closure();

箭头函数没有自己的this,它的this是定义时外层作用域的this(这里getClosurethis指向obj)。

  1. 普通函数手动绑定
const obj = {
    name: '对象',
    getClosure() {
        const self = this; 
        return function() {
            console.log(self.name); // 输出'对象'
        };
    }
};

const closure = obj.getClosure();
closure();

显式捕获this值,转化为闭包可访问的变量self

五、闭包面试题:从 “循环绑定事件” 到 “函数柯里化”

闭包是面试高频考点,常见题目有:

题目 1:循环中绑定事件,点击按钮输出当前索引
错误代码:

// HTML:5个按钮,class都是btn
const btns = document.querySelectorAll('.btn');
for (var i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function() {
        console.log(i); // 点击所有按钮都输出5
    });
}

原因:var声明的i是全局变量,循环体每次迭代修改的是同一个变量,循环结束后i的值是 5。点击事件触发时,闭包访问的i已经是 5 了。

正确解法(用闭包保存当前i的值):

方法1:立即执行函数创建闭包

for (var i = 0; i < btns.length; i++) {
    (function(j) { // j是当前循环的i值
        btns[i].addEventListener('click', function() {
            console.log(j); 
        });
    })(i); 
}

方法2:用let声明i

for (let i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function() {
        console.log(i); 
    });
}

let声明的i在每次循环中都是新的变量,相当于为每个事件处理函数创建了独立的闭包。

题目 2:实现一个加法函数,支持add(1)(2)(3)输出 6
这是典型的柯里化问题,核心是利用闭包保存参数:

function add(a) {
    return function(b) { 
        return function(c) { 
            return a + b + c; 
        };
    };
}
console.log(add(1)(2)(3)); // 6

执行流程拆解

  1. 第一次调用add(1)

    • 进入add函数,参数a=1存入当前作用域。
    • 返回内部函数function(b),该函数的闭包捕获a=1
    • 此时形成第一个闭包:{ a: 1, b: undefined }
  2. 第二次调用(2)

    • 进入function(b),参数b=2存入作用域。
    • 返回内部函数function(c),闭包捕获a=1b=2
    • 形成第二个闭包:{ a: 1, b: 2, c: undefined }
  3. 第三次调用(3)

    • 进入function(c),参数c=3存入作用域。
    • 计算a+b+c=6,闭包使命结束。

进阶柯里化(任意参数)

function curriedAdd(...initialArgs) {
    const args = [...initialArgs];
    function inner(...newArgs) {
        return newArgs.length === 0
            ? args.reduce((sum, num) => sum + num, 0)
            : curriedAdd(...args, ...newArgs);
    }
    return inner;
}
const add = curriedAdd();
console.log(add(1)(2)(3)()) //6

这里inner函数通过闭包保存了args数组,每次调用时将新参数存入数组,直到无参数时计算总和。

六、总结:闭包的正确打开方式

学完闭包,我总结了三个关键点:

  1. 闭包的本质:函数对其词法作用域的 “记忆”,即使函数在作用域外执行;
  2. 核心价值:创建私有变量、隔离作用域,避免全局污染;
  3. 注意事项:合理管理闭包的生命周期(不用时解除引用),避免内存占用过高。

最后我想说的是:刚开始理解闭包时,别被 “词法环境”“作用域链” 这些术语吓退。多写小 demo(比如计数器、输入记忆),观察变量的变化,慢慢就能找到感觉。毕竟,编程不是背定义,而是 “用代码和计算机对话”。闭包,不过是我们和 JS 沟通的一种方式而已。

互动话题:你学闭包时遇到过哪些坑?欢迎在评论区分享你的故事~(悄悄说:我第一次写闭包时,把return写错位置,导致函数没返回,调试了好长时间😂)