跳到主要内容
预计阅读 36 分钟

Javascript篇 | 闭包与作用域

前言

如果说 this 是面试官最喜欢的”开胃菜”,那闭包绝对是”硬菜”——几乎每场前端面试都会考,而且考法千变万化。

你可能遇到过这些场景:

  • 面试官让你解释什么是闭包,你说”函数访问外部变量”,然后面试官追问”从规范角度呢?”
  • 一道经典的 for 循环 + setTimeout 题目,你知道答案但说不清原理
  • React Hooks 里遇到 stale closure,debug 半天才发现是闭包的问题

闭包不是什么高深的概念,但如果你只停留在”背定义”的层面,面试中很容易被追问到哑口无言。今天我们就把闭包和作用域彻底讲透,从原理到实战,让你面试时胸有成竹。

诊断自测

在开始之前,试着回答以下问题,检测你当前对闭包和作用域的理解程度:

1. 以下代码输出什么?

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

2. 以下代码执行后,counter1counter2 是否共享同一个 count 变量?

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    getCount: () => count
  };
}

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

console.log(counter1.getCount());
console.log(counter2.getCount());

3. 以下代码有什么问题?

function useDebounce(fn, delay) {
  const [timer, setTimer] = useState(null);
  return (...args) => {
    clearTimeout(timer);
    setTimer(setTimeout(() => fn(...args), delay));
  };
}
点击查看答案

第1题: 输出三次 3var 声明的变量没有块级作用域,循环结束后 i 的值为 3,三个 setTimeout 回调共享同一个 i

第2题: 不共享。每次调用 createCounter() 都会创建一个新的执行上下文和新的 count 变量。counter1.getCount() 输出 2counter2.getCount() 输出 1

第3题: 存在 stale closure 问题。每次组件重新渲染时,useDebounce 会返回新的函数,但旧的 timer 值已经被闭包捕获了,clearTimeout 清除的可能不是最新的 timer。应该用 useRef 来存储 timer。

如果三道题都答对了,说明你基础不错!继续阅读可以查漏补缺。如果有答错的,别担心,读完本文你就能彻底搞懂。

作用域:闭包的基石

要理解闭包,我们得先搞清楚作用域。

什么是作用域?

作用域(Scope)决定了变量的可访问范围。简单来说,就是”在哪里能用这个变量”。

JavaScript 有三种作用域:

  • 全局作用域:在任何函数外部声明的变量
  • 函数作用域:在函数内部声明的变量(var
  • 块级作用域:在 {} 内部用 let/const 声明的变量(ES6+)
var globalVar = 'global';       // 全局作用域

function foo() {
  var functionVar = 'function'; // 函数作用域

  if (true) {
    let blockVar = 'block';     // 块级作用域
    var noBlockVar = 'no block'; // var 不受块级作用域限制
  }

  console.log(noBlockVar);  // 'no block' ✅
  console.log(blockVar);    // ReferenceError ❌
}

词法作用域 vs 动态作用域

这是一个面试高频考点。JavaScript 使用的是词法作用域(也叫静态作用域),这意味着函数的作用域在定义时就确定了,而不是在调用时。

var name = 'global';

function printName() {
  console.log(name);
}

function wrapper() {
  var name = 'wrapper';
  printName();
}

wrapper(); // 输出什么?

如果是词法作用域(JavaScript 的实际行为):输出 'global'。因为 printName 定义在全局,它的外层作用域是全局作用域。

如果是动态作用域(假设):会输出 'wrapper'。因为 printName 是在 wrapper 中被调用的,动态作用域会沿着调用栈查找。

💡 记住:JavaScript 的作用域是在代码写下来的那一刻就确定了的(词法 = 编写时),跟函数在哪里被调用无关。

作用域链

当代码访问一个变量时,JavaScript 引擎会沿着作用域链从内到外逐层查找:

var a = 'global a';

function outer() {
  var b = 'outer b';

  function inner() {
    var c = 'inner c';
    console.log(a); // 沿着作用域链找到全局的 a
    console.log(b); // 沿着作用域链找到 outer 的 b
    console.log(c); // 当前作用域直接找到
  }

  inner();
}

outer();

作用域链的查找过程:inner 作用域 → outer 作用域 → 全局作用域。如果到全局都没找到,就报 ReferenceError

闭包:到底是什么?

通俗定义

闭包是指一个函数能够”记住”并访问它定义时所在的词法作用域,即使这个函数在其词法作用域之外执行。

function outer() {
  let count = 0;

  function inner() {
    count++;
    console.log(count);
  }

  return inner;
}

const fn = outer(); // outer 执行完了,按理说 count 应该被销毁
fn(); // 1 —— 但 count 还在!
fn(); // 2 —— 还能继续累加!

outer() 执行完毕后,正常来说它的局部变量 count 应该被垃圾回收。但因为 inner 函数引用了 count,而 inner 被返回到外部并保存在 fn 中,所以 count 无法被回收——这就是闭包。

从规范角度理解

在 ECMAScript 规范中,每个函数在创建时都会保存一个内部属性 [[Environment]],它指向函数定义时所在的词法环境(Lexical Environment)。

当函数执行时,会创建一个新的执行上下文,其外部环境引用(outer)就指向 [[Environment]]。这就形成了作用域链。

闭包的本质: 函数的 [[Environment]] 引用了外层的词法环境,导致即使外层函数已经执行完毕,其词法环境也不会被垃圾回收。

闭包的形成条件

  1. 存在函数嵌套
  2. 内部函数引用了外部函数的变量
  3. 内部函数被传递到外部函数的作用域之外(被返回、被赋值给外部变量、作为回调传递等)
// ✅ 这是闭包:inner 引用了外部变量且被返回
function outer() {
  let x = 10;
  return function inner() {
    return x;
  };
}

// ❌ 这不是典型闭包:inner 没有被传到外部
function outer() {
  let x = 10;
  function inner() {
    console.log(x);
  }
  inner(); // 在 outer 内部直接调用,outer 的变量本来就可见
}

严格来说,第二种情况在技术上也形成了闭包(inner 的 [[Environment]] 引用了 outer 的词法环境),但这种闭包没有实际意义,面试中一般不算。

经典闭包场景

场景一:计数器 / 私有变量

闭包最经典的用途——模拟私有变量:

function createCounter(initialValue = 0) {
  let count = initialValue;

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

const counter = createCounter(10);
counter.increment(); // 11
counter.increment(); // 12
counter.decrement(); // 11
counter.getCount();  // 11

// 外部无法直接访问或修改 count
console.log(counter.count); // undefined

count 只能通过返回的方法操作,外部完全无法直接访问——这就是”封装”。

场景二:模块模式

在 ES Module 出现之前,闭包是实现模块化的主要手段:

const UserModule = (function() {
  // 私有变量
  let users = [];
  let nextId = 1;

  // 私有函数
  function generateId() {
    return nextId++;
  }

  // 公共 API
  return {
    addUser(name) {
      const user = { id: generateId(), name };
      users.push(user);
      return user;
    },
    getUsers() {
      return [...users]; // 返回副本,防止外部修改
    },
    getUserCount() {
      return users.length;
    }
  };
})();

UserModule.addUser('Alice');
UserModule.addUser('Bob');
console.log(UserModule.getUserCount()); // 2
console.log(UserModule.users);          // undefined,无法访问

场景三:for 循环问题(面试必考)

这道题几乎是面试闭包的”标配”:

for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// 输出:3, 3, 3

为什么? var 没有块级作用域,三个 setTimeout 回调共享同一个 i。当回调执行时,循环已经结束,i 的值是 3。

解法一:IIFE 创建独立作用域

for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);
    }, 1000);
  })(i);
}
// 输出:0, 1, 2

解法二:用 let 创建块级作用域(推荐)

for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);
  }, 1000);
}
// 输出:0, 1, 2

let 在每次循环迭代时会创建一个新的绑定,每个回调捕获的是不同的 i

解法三:setTimeout 的第三个参数

for (var i = 0; i < 3; i++) {
  setTimeout(function(j) {
    console.log(j);
  }, 1000, i);
}
// 输出:0, 1, 2

闭包与内存

闭包会导致内存泄漏吗?

严格来说,闭包本身不是内存泄漏——它是设计如此。但如果使用不当,确实会导致内存无法释放。

function createHeavyClosure() {
  const hugeArray = new Array(1000000).fill('data');

  return function() {
    // 虽然只用了 hugeArray 的长度
    // 但整个 hugeArray 都被闭包引用,无法被回收
    return hugeArray.length;
  };
}

const getLength = createHeavyClosure();
// hugeArray 一直在内存中,直到 getLength 被释放

优化方式: 只保留需要的数据:

function createOptimizedClosure() {
  const hugeArray = new Array(1000000).fill('data');
  const length = hugeArray.length; // 只保留需要的值

  return function() {
    return length;
  };
  // hugeArray 不被闭包引用,可以被回收
}

常见的内存泄漏场景

// ❌ 事件监听器中的闭包没有清理
function setupHandler() {
  const data = fetchHugeData();

  document.getElementById('btn').addEventListener('click', function() {
    process(data);
  });
  // 如果不 removeEventListener,data 永远不会被回收
}

// ✅ 正确做法:提供清理机制
function setupHandler() {
  const data = fetchHugeData();

  const handler = function() {
    process(data);
  };

  const btn = document.getElementById('btn');
  btn.addEventListener('click', handler);

  return function cleanup() {
    btn.removeEventListener('click', handler);
  };
}

闭包在 React Hooks 中的应用

React Hooks 的实现底层大量依赖闭包,这也带来了一个经典问题——stale closure(过期闭包)

什么是 stale closure?

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count:', count); // 永远是 0!
      setCount(count + 1);              // 永远设置为 1!
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 空依赖数组

  return <div>{count}</div>;
}

为什么? useEffect 的回调在组件首次渲染时创建,它通过闭包捕获了当时的 count(值为 0)。由于依赖数组为空,这个 effect 不会重新执行,所以回调中的 count 永远是 0。

解决方案

方案一:用函数式更新

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1); // 用函数式更新,不依赖闭包中的 count
  }, 1000);

  return () => clearInterval(timer);
}, []);

方案二:用 useRef 保存最新值

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
  countRef.current = count; // 每次渲染都更新 ref

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('当前 count:', countRef.current); // 始终是最新值
      setCount(countRef.current + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

方案三:正确设置依赖数组

useEffect(() => {
  const timer = setInterval(() => {
    console.log('当前 count:', count);
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(timer);
}, [count]); // count 变化时重新创建 effect

⚠️ 方案三虽然能工作,但每次 count 变化都会清除并重新创建定时器,性能不是最优。面试时推荐说方案一或方案二。

常见误区

误区1:闭包就是”函数嵌套函数”

很多人把”闭包”等同于”函数里面定义函数”。但函数嵌套只是闭包的必要条件之一,不是充分条件。

function outer() {
  function inner() {
    console.log('hello');
  }
  inner(); // 只是在内部调用,没有形成实际意义上的闭包
}

闭包的关键在于:内部函数引用了外部变量,并且被传递到外部作用域使用

误区2:每次调用外部函数都复用同一个闭包

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);

console.log(add5(3));  // 8
console.log(add10(3)); // 13

每次调用 makeAdder 都会创建一个新的执行上下文和新的词法环境。add5add10 各自闭包中的 x 是独立的,互不影响。

误区3:闭包中的变量是值的快照

闭包捕获的是变量的引用,不是值的拷贝。

function createFunctions() {
  let val = 1;

  function getter() { return val; }
  function setter(newVal) { val = newVal; }

  return { getter, setter };
}

const { getter, setter } = createFunctions();
console.log(getter()); // 1
setter(42);
console.log(getter()); // 42 —— val 被修改了!

gettersetter 共享同一个 val 的引用。setter 修改了 valgetter 也能看到变化。

误区4:let 在 for 循环中不会形成闭包

有人认为用 let 替代 var 后就不存在闭包了。实际上闭包仍然存在,只是 let 在每次迭代中创建了新的绑定,所以每个回调捕获的是不同的变量。

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 输出 0, 1, 2 —— 仍然是闭包,只不过每次迭代的 i 是独立的

小结

闭包是 JavaScript 中最核心的概念之一,它建立在词法作用域的基础上。

核心要点

  1. 作用域:JavaScript 使用词法作用域,作用域在代码编写时就确定了
  2. 作用域链:变量查找沿着作用域链从内到外逐层查找
  3. 闭包的本质:函数的 [[Environment]] 引用了外层的词法环境,使得外层变量不会被垃圾回收
  4. 闭包捕获的是引用:不是值的快照,多个闭包函数可以共享同一个变量
  5. 内存管理:闭包不等于内存泄漏,但需要注意及时清理不再需要的引用
  6. React Hooks:stale closure 是常见问题,可以用函数式更新或 useRef 解决

本章思维导图

闭包与作用域
  • 作用域
    • 全局作用域
    • 函数作用域(var)
    • 块级作用域(let/const)
    • 词法作用域(定义时确定)
    • 作用域链(内 → 外逐层查找)
  • 闭包
    • 定义:函数记住并访问词法作用域
    • 规范:[[Environment]] 引用词法环境
    • 形成条件
      • 函数嵌套
      • 引用外部变量
      • 传递到外部使用
    • 捕获的是引用,不是值
  • 经典场景
    • 计数器 / 私有变量
    • 模块模式(IIFE)
    • for 循环问题(var vs let)
  • 内存管理
    • 闭包导致变量无法回收
    • 只保留需要的数据
    • 清理事件监听器
  • React Hooks 中的闭包
    • stale closure 问题
    • 函数式更新
    • useRef 保存最新值
    • 正确设置依赖数组

练习挑战

挑战一:基础(⭐)

以下代码输出什么?

function createGreeter(greeting) {
  return function(name) {
    console.log(`${greeting}, ${name}!`);
  };
}

const hello = createGreeter('Hello');
const hi = createGreeter('Hi');

hello('Alice');
hi('Bob');
hello('Charlie');
答案与解析
Hello, Alice!
Hi, Bob!
Hello, Charlie!

每次调用 createGreeter 都会创建一个新的闭包,hello 的闭包中 greeting'Hello'hi 的闭包中 greeting'Hi',它们互不影响。

挑战二:进阶(⭐⭐)

以下代码输出什么?解释原因。

function outer() {
  let count = 0;

  function increment() {
    count++;
  }

  function getCount() {
    return count;
  }

  increment();
  increment();
  increment();

  return { getCount, increment };
}

const obj1 = outer();
const obj2 = outer();

obj1.increment();
obj1.increment();

console.log(obj1.getCount()); // A
console.log(obj2.getCount()); // B
答案与解析
  • A: 5outer() 内部先调用了 3 次 increment(count 变为 3),返回后又调用了 2 次(count 变为 5)。
  • B: 3obj2 是独立调用 outer() 创建的新闭包,内部只调用了 3 次 increment,之后没有额外调用。

关键点:每次调用 outer() 创建独立的闭包环境;getCountincrement 共享同一个闭包中的 count

挑战三:综合(⭐⭐⭐)

以下代码输出什么?完整分析每一步。

function createMultiplier() {
  let multiplier = 1;
  const fns = [];

  for (var i = 1; i <= 3; i++) {
    const m = multiplier * i;
    fns.push(function() {
      console.log(`i=${i}, m=${m}, multiplier=${multiplier}`);
    });
    multiplier = m;
  }

  return { fns, setMultiplier: (v) => { multiplier = v; } };
}

const { fns, setMultiplier } = createMultiplier();
setMultiplier(100);

fns[0]();
fns[1]();
fns[2]();
答案与解析
i=4, m=1, multiplier=100
i=4, m=2, multiplier=100
i=4, m=6, multiplier=100

逐步分析:

  1. var i 没有块级作用域,循环结束后 i = 4,三个函数共享同一个 i
  2. const m 有块级作用域,每次迭代创建新的 m:第一次 m = 1*1 = 1,第二次 m = 1*2 = 2,第三次 m = 2*3 = 6
  3. multiplier 是用 let 声明在 for 外面的,三个函数共享同一个 multiplier 引用。
  4. setMultiplier(100) 将共享的 multiplier 改为 100。

所以 i 都是 4(var 共享),m 分别是 1、2、6(const 独立),multiplier 都是 100(共享引用,被外部修改)。

这道题综合考察了 var vs const 的作用域差异、闭包捕获引用而非值、以及共享闭包变量被外部修改的情况。

自我检测

读完本章后,确认你能回答以下问题:

  • 能说出 JavaScript 三种作用域(全局、函数、块级)的区别
  • 能解释词法作用域和动态作用域的区别,以及 JavaScript 使用哪种
  • 能从规范角度解释闭包([[Environment]] 和词法环境)
  • 能说出闭包的三个形成条件
  • 能解释 for 循环 + var + setTimeout 的经典问题,并给出至少两种解法
  • 能解释闭包捕获的是引用而不是值的快照
  • 理解闭包与内存泄漏的关系,并知道如何优化
  • 能解释 React Hooks 中的 stale closure 问题及解决方案
  • 能说出模块模式(IIFE)是如何利用闭包实现私有变量的

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90