【每日一面】深入理解闭包

2026-09-08 16 min 5687 字 -- 次阅读
摘要

闭包是作用域链查找与垃圾回收共同作用的结果:内部函数让外部变量持续存活,用于数据私有、状态保持与防抖节流,但也要提防意外的内存泄漏。

基础问答

:什么是闭包?闭包有什么作用?

:闭包(Closure)是指有权访问另一个函数作用域中变量的函数。简单来说,当一个内部函数引用了外部函数的变量时,即使外部函数已经执行完毕并出栈,这些被引用的变量仍然会存活在内存中,不会被垃圾回收机制回收,内部函数和这些变量共同构成了闭包。

闭包的主要作用:

  • 数据私有化:封装变量,避免全局污染,实现模块化

  • 延长变量生命周期:让函数执行完毕后,变量仍然存活

  • 保存状态:在异步操作、回调函数中保留上下文

  • 函数柯里化:利用闭包实现参数复用

扩展延伸

闭包是 JavaScript 中最核心的概念之一,也是面试中必问的高频题。要真正理解闭包,需要先搞清楚作用域、作用域链和执行上下文这几个前置概念。

概念铺垫

作用域

作用域是变量和函数的可访问范围,决定了代码中变量的可见性和生命周期。JavaScript 中有三种作用域:

  • 全局作用域:最外层的作用域,在代码任何位置都能访问

  • 函数作用域:函数内部声明的变量和参数,仅在函数内部可访问

  • 块级作用域:ES6 引入,let / const 声明的变量在 {} 内形成独立作用域

执行上下文与作用域链

每当 JavaScript 引擎执行一段代码时,都会创建对应的执行上下文(Execution Context)。每个执行上下文都包含:

  • 变量对象(Variable Object):存储变量、函数声明、函数参数

  • 作用域链(Scope Chain):用于变量查找的链式结构

  • this 指向

当函数被调用时,会创建新的执行上下文并压入执行栈;函数执行完毕后,执行上下文出栈。但闭包是个例外——如果内部函数仍然引用着外部函数的变量,那么外部函数的变量对象不会被销毁。

核心机制

闭包的形成本质上是作用域链查找垃圾回收机制共同作用的结果。

下面的流程图展示了闭包的形成过程:

flowchart TD
    A[外部函数 outer 被调用] --> B[创建 outer 执行上下文]
    B --> C[outer 变量对象 VO 创建,包含变量 x 和内部函数 inner]
    C --> D[inner 函数定义时,保存对 outer VO 的引用]
    D --> E[outer 执行完毕,执行上下文出栈]
    E --> F{inner 是否被外部引用?}
    F -->|是| G[outer 的变量对象不被回收<br>形成闭包]
    F -->|否| H[outer 的变量对象被垃圾回收]
    G --> I[调用 inner 时,沿作用域链<br>仍可访问 outer 的变量]

闭包的内存模型

graph LR
    subgraph 栈内存
        A[全局执行上下文<br>Global EC]
        B[inner 函数引用]
    end

    subgraph 堆内存
        C[inner 函数对象]
        D[outer 变量对象 VO<br>x = 10]
        E[全局变量对象 GO]
    end

    A --> E
    B --> C
    C -->|"[[Scope]]"| D
    D -->|"parent scope"| E

    style D fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#9f9,stroke:#333,stroke-width:2px

从内存模型图可以看出:inner 函数对象内部的 [[Scope]] 属性保存了对 outer 函数变量对象的引用,这条引用导致垃圾回收器无法回收 outer 的变量对象,从而形成了闭包。

代码示例

最简单的闭包示例:

javascript
function outer() {
  let x = 10;
  function inner() {
    console.log(x); // 内部函数引用外部变量 x
  }
  return inner;
}

const fn = outer(); // outer 执行完毕,按理应销毁 x
fn(); // 输出:10 —— 但 x 仍然存在,这就是闭包

一个更实用的例子——计数器:

javascript
function createCounter() {
  let count = 0; // 私有变量,外部无法直接访问

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

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount());  // 2
console.log(counter.count);       // undefined —— 外部无法直接访问 count

上面的代码中,count 变量被封装在闭包中,只能通过暴露的三个方法操作,实现了数据私有化。

对比表格

对比维度普通函数调用闭包
变量生命周期函数执行完,局部变量随即被回收被引用的外部变量持续存活,直到闭包被销毁
变量访问方式只能访问自身作用域和全局作用域的变量可以访问外部函数作用域的变量,即使外部函数已执行完毕
内存占用函数执行完即释放,内存开销小保留外部函数的变量对象,内存开销较大
数据封装性无封装,变量容易被外部修改可实现私有变量,数据安全性高
适用场景普通计算、一次性操作模块化封装、状态保持、防抖节流、函数柯里化
常见陷阱内存泄漏、循环中引用同一变量导致意外结果

闭包常见应用场景决策

flowchart TD
    Q[需要使用闭包吗?] --> Q1{是否需要封装私有变量?}
    Q1 -->|是| A1[模块化 / 立即执行函数 IIFE]
    Q1 -->|否| Q2{是否需要保持函数状态?}
    Q2 -->|是| Q3{是延迟执行还是连续调用?}
    Q3 -->|延迟执行| A2[防抖 / 节流]
    Q3 -->|连续调用| A3[函数柯里化 / 偏函数]
    Q2 -->|否| Q4{是否需要在回调中保留上下文?}
    Q4 -->|是| A4[事件处理器 / 异步回调]
    Q4 -->|否| A5[不需要闭包,直接使用普通函数]

面试追问

闭包会导致内存泄漏吗?如何避免?

不一定。闭包本身不会必然导致内存泄漏,它只是让变量的生命周期延长了。内存泄漏指的是不再需要的变量仍然占用内存,无法被回收

闭包导致内存泄漏通常发生在以下场景:

  • 闭包引用了大量数据,且闭包被长期持有(如挂载到全局变量、DOM 事件上)

  • 循环引用:闭包引用 DOM 元素,DOM 元素又通过属性引用闭包中的函数

避免方式:

  • 及时解除引用:将闭包函数置为 null,断开引用链

  • 减少不必要的变量捕获:只捕获真正需要的变量

  • 使用 WeakMap / WeakSet 存储数据,让垃圾回收更主动

javascript
// 错误示范:闭包持有大对象,且 fn 被全局引用
let fn;
function badExample() {
  const bigData = new Array(1000000).fill('*'); // 大数组
  fn = function() {
    console.log(bigData.length);
  };
}
badExample();
// bigData 永远无法被回收,因为全局 fn 引用着它

// 正确做法:用完后解除引用
fn = null; // 断开引用,bigData 可被回收

下面这段代码输出什么?如何修改让它输出 0、1、2、3、4?

javascript
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}

输出结果: 1 秒后连续输出 5 个 5

原因: var 声明的 i 是全局变量,没有块级作用域。循环结束后 i 的值已经是 5,而 setTimeout 的回调函数形成闭包,它们共享同一个 i。当 1 秒后回调执行时,沿作用域链找到的 i 已经是 5 了。

修改方式一:使用 let 声明(ES6 块级作用域)

javascript
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}

let 在每次循环中都会创建一个新的绑定,每个回调捕获的是当前迭代的 i,所以输出 0、1、2、3、4。

修改方式二:使用立即执行函数(IIFE)创建闭包

javascript
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 1000);
  })(i);
}

每次循环立即执行 IIFE,将当前的 i 作为参数 j 传入,每个 IIFE 都有自己的 j 变量,形成独立的闭包。

什么是自由变量?自由变量的查找规则是什么?

自由变量是指在当前作用域中使用、但不是在当前作用域中声明的变量。例如:

javascript
const a = 10;
function fn() {
  const b = 20;
  console.log(a + b); // a 是自由变量,b 是局部变量
}

自由变量的查找遵循作用域链规则:

  1. 先在当前作用域的变量对象中查找

  2. 如果找不到,沿着作用域链到父级作用域查找

  3. 一直追溯到全局作用域

  4. 如果全局作用域也找不到,抛出 ReferenceError

注意:自由变量的查找是在函数定义时的作用域中进行的,而不是调用时的作用域。这就是 JavaScript 的词法作用域(静态作用域)特性。

javascript
const x = 10;
function fn1() {
  console.log(x);
}
function fn2() {
  const x = 20;
  fn1(); // fn1 定义在全局,所以找的是全局的 x = 10,而非 fn2 中的 x = 20
}
fn2(); // 输出:10

手写一个使用闭包实现的防抖函数(debounce)

防抖的核心思想:在事件触发后延迟 n 秒执行回调,如果在这 n 秒内事件又被触发,则重新计时。常用于搜索框输入联想、窗口 resize 等场景。

javascript
function debounce(fn, delay) {
  let timer = null; // timer 被闭包保存,每次调用共享同一个 timer

  return function(...args) {
    // 如果已经有定时器了,清除掉,重新计时
    if (timer) {
      clearTimeout(timer);
    }
    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(this, args); // 修正 this 指向,透传参数
      timer = null; // 执行完毕后重置
    }, delay);
  };
}

// 使用
const handleSearch = debounce(function(keyword) {
  console.log('搜索:', keyword);
}, 300);

// 连续调用时,只有最后一次会在 300ms 后执行
handleSearch('a');
handleSearch('ab');
handleSearch('abc'); // 300ms 后输出:搜索:abc

延伸思考:如果面试官追问「怎么实现立即执行版的防抖?」,可以在上述基础上加一个 immediate 参数,第一次触发时立即执行,之后进入防抖逻辑。

下面代码中,两个 counter 的 count 是共享的还是独立的?为什么?

javascript
function createCounter() {
  let count = 0;
  return function() {
    count++;
    return count;
  };
}

const counter1 = createCounter();
const counter2 = createCounter();

console.log(counter1()); // ?
console.log(counter1()); // ?
console.log(counter2()); // ?
console.log(counter2()); // ?

输出结果:

plaintext
1
2
1
2

两个 counter 的 count独立的,互不影响。

原因: 每次调用 createCounter() 都会创建一个新的执行上下文,产生一个新的 count 变量和一个新的闭包函数。counter1counter2 分别引用了两次不同调用产生的闭包,它们各自的作用域链中保存的是不同的 count 变量,因此计数互不干扰。

这也是闭包实现模块化和实例化的基础——每次调用工厂函数,都会生成一份独立的私有状态。

系列文章

【每日一面】事件循环机制

【每日一面】重绘与回流

【每日一面】深浅拷贝

【每日一面】async/await 的原理

【每日一面】你怎么理解 Proxy 的

评论