Javascript篇 | 闭包与作用域
前言
如果说 this 是面试官最喜欢的”开胃菜”,那闭包绝对是”硬菜”——几乎每场前端面试都会考,而且考法千变万化。
你可能遇到过这些场景:
- 面试官让你解释什么是闭包,你说”函数访问外部变量”,然后面试官追问”从规范角度呢?”
- 一道经典的 for 循环 + setTimeout 题目,你知道答案但说不清原理
- React Hooks 里遇到 stale closure,debug 半天才发现是闭包的问题
闭包不是什么高深的概念,但如果你只停留在”背定义”的层面,面试中很容易被追问到哑口无言。今天我们就把闭包和作用域彻底讲透,从原理到实战,让你面试时胸有成竹。
诊断自测
在开始之前,试着回答以下问题,检测你当前对闭包和作用域的理解程度:
1. 以下代码输出什么?
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i);
}, 0);
}
2. 以下代码执行后,counter1 和 counter2 是否共享同一个 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题: 输出三次 3。var 声明的变量没有块级作用域,循环结束后 i 的值为 3,三个 setTimeout 回调共享同一个 i。
第2题: 不共享。每次调用 createCounter() 都会创建一个新的执行上下文和新的 count 变量。counter1.getCount() 输出 2,counter2.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]] 引用了外层的词法环境,导致即使外层函数已经执行完毕,其词法环境也不会被垃圾回收。
闭包的形成条件
- 存在函数嵌套
- 内部函数引用了外部函数的变量
- 内部函数被传递到外部函数的作用域之外(被返回、被赋值给外部变量、作为回调传递等)
// ✅ 这是闭包: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 都会创建一个新的执行上下文和新的词法环境。add5 和 add10 各自闭包中的 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 被修改了!
getter 和 setter 共享同一个 val 的引用。setter 修改了 val,getter 也能看到变化。
误区4:let 在 for 循环中不会形成闭包
有人认为用 let 替代 var 后就不存在闭包了。实际上闭包仍然存在,只是 let 在每次迭代中创建了新的绑定,所以每个回调捕获的是不同的变量。
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出 0, 1, 2 —— 仍然是闭包,只不过每次迭代的 i 是独立的
小结
闭包是 JavaScript 中最核心的概念之一,它建立在词法作用域的基础上。
核心要点
- 作用域:JavaScript 使用词法作用域,作用域在代码编写时就确定了
- 作用域链:变量查找沿着作用域链从内到外逐层查找
- 闭包的本质:函数的
[[Environment]]引用了外层的词法环境,使得外层变量不会被垃圾回收 - 闭包捕获的是引用:不是值的快照,多个闭包函数可以共享同一个变量
- 内存管理:闭包不等于内存泄漏,但需要注意及时清理不再需要的引用
- 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:
5—outer()内部先调用了 3 次increment(count 变为 3),返回后又调用了 2 次(count 变为 5)。 - B:
3—obj2是独立调用outer()创建的新闭包,内部只调用了 3 次increment,之后没有额外调用。
关键点:每次调用 outer() 创建独立的闭包环境;getCount 和 increment 共享同一个闭包中的 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
逐步分析:
var i没有块级作用域,循环结束后i = 4,三个函数共享同一个i。const m有块级作用域,每次迭代创建新的m:第一次m = 1*1 = 1,第二次m = 1*2 = 2,第三次m = 2*3 = 6。multiplier是用let声明在 for 外面的,三个函数共享同一个multiplier引用。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