为什么作用域与闭包是 JavaScript 的核心
作用域和闭包是 JavaScript 中最基础也最容易被误解的两个概念。几乎所有框架(React Hooks、Vue Composition API)的设计都依赖闭包机制。面试中,它们既是独立考点,也是理解 this 绑定、模块化、异步编程等高级话题的前置知识。
一、作用域(Scope)
1.1 什么是作用域
作用域是一组变量名到变量值的映射规则。它决定了在代码的某个位置,哪些标识符(identifier)是可访问的。
JavaScript 采用 词法作用域(Lexical Scope),也叫静态作用域——作用域在代码编写时就已经确定,而不是在运行时。
1.2 三种作用域层级
| 作用域类型 | 创建时机 | 典型载体 |
|---|---|---|
| 全局作用域(Global Scope) | 脚本加载时 | <script> 顶层、Node.js 模块外层 |
| 函数作用域(Function Scope) | 函数声明/表达式 | function 关键字 |
| 块级作用域(Block Scope) | {} 块(ES2015+) |
let、const、class |
注意:
var声明的变量不受块级作用域约束,只受函数作用域约束。这是许多经典面试题的根源。
1.3 词法作用域链(Scope Chain)
当引擎在当前作用域找不到某个变量时,会沿着词法嵌套关系逐层向外查找,直到全局作用域。这条查找路径就是 作用域链。
const global = 'g';
function outer() {
const outerVar = 'o';
function inner() {
const innerVar = 'i';
console.log(innerVar); // ✅ 当前作用域
console.log(outerVar); // ✅ 沿作用域链向上一层
console.log(global); // ✅ 沿作用域链到全局
}
inner();
}
ECMAScript 规范视角:每个执行上下文(Execution Context)都有一个关联的 Lexical Environment,其中包含一个 Environment Record(存储当前作用域的绑定)和一个 outer reference(指向外层 Lexical Environment)。作用域链就是由 outer reference 串联起来的链表。
1.4 变量提升(Hoisting)
Hoisting 是 JavaScript 引擎在编译阶段将声明提升到其所在作用域顶部的行为:
// var 提升:声明提升,赋值留在原地
console.log(a); // undefined(不是 ReferenceError)
var a = 1;
// function 声明提升:整个函数体都提升
foo(); // ✅ 可以调用
function foo() { console.log('foo'); }
// let/const 提升:存在 TDZ(Temporal Dead Zone)
console.log(b); // ReferenceError
let b = 2;
面试要点:let/const 实际上也会被提升(引擎知道它们的存在),但在声明语句执行之前处于 TDZ,访问会抛出 ReferenceError。这与 var 返回 undefined 的行为截然不同。
二、闭包(Closure)
2.1 定义
闭包是指一个函数能够"记住"并访问其词法作用域中的变量,即使该函数在其词法作用域之外执行。
— Kyle Simpson, You Don't Know JS: Scope & Closures
用更精确的表述:当一个函数被创建时,它会捕获一个对其外层 Lexical Environment 的引用。只要这个函数还存在,被引用的 Environment 就不会被垃圾回收。
2.2 闭包的形成条件
- 存在函数嵌套
- 内部函数引用了外部函数的变量
- 内部函数在外部函数执行完毕后仍然可达(通常是被返回或传递给其他地方)
function createCounter() {
let count = 0; // 被闭包捕获
return {
increment() { return ++count; },
getCount() { return count; }
};
}
const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
counter.getCount(); // 2
// count 变量在 createCounter 执行完毕后仍然存活
2.3 闭包的内存模型
在 V8 引擎中,闭包的实现涉及以下关键概念:
- Context 对象:当引擎检测到某个变量被内部函数引用时,该变量不会分配在栈上,而是分配在堆上的 Context 对象中
- 共享 Context:同一个外部函数中的所有内部函数共享同一个 Context——这意味着它们看到的是同一份变量
function shared() {
let x = 0;
function a() { x++; }
function b() { return x; }
a();
return b(); // 1 — a 和 b 共享同一个 x
}
V8 优化细节:V8 只会将确实被内部函数引用的变量提升到 Context 中,未被引用的变量仍然在栈上分配并在函数结束时销毁。这是 V8 的"变量分析(variable analysis)"优化。
三、经典工程场景
3.1 数据封装 / 模块模式
闭包是 JavaScript 实现私有变量的传统方式(在 # 私有字段出现之前):
const Module = (function () {
let _private = 0;
return {
increment() { _private++; },
getValue() { return _private; }
};
})();
Module.increment();
Module.getValue(); // 1
// _private 从外部完全不可访问
这就是经典的 Revealing Module Pattern,也是 ES Modules 出现之前最主流的模块化方案。
3.2 函数工厂(Function Factory)
function createMultiplier(factor) {
return (number) => number * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
double(5); // 10
triple(5); // 15
每次调用 createMultiplier 都会创建一个新的闭包,factor 被独立捕获。
3.3 防抖与节流(Debounce & Throttle)
function debounce(fn, delay) {
let timerId = null; // 闭包捕获 timerId
return function (...args) {
clearTimeout(timerId);
timerId = setTimeout(() => fn.apply(this, args), delay);
};
}
3.4 React Hooks 中的闭包
React 的 useState、useEffect 等 Hooks 本质上依赖闭包来"记住"每次渲染时的 state 和 props:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// 这个回调闭包捕获了当前渲染的 count 值
const id = setInterval(() => {
console.log(count); // 始终是这次渲染时的 count
}, 1000);
return () => clearInterval(id);
}, [count]);
}
四、经典陷阱与解决方案
4.1 循环中的 var(最经典面试题)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:3, 3, 3(不是 0, 1, 2)
原因:var 是函数作用域,三个回调共享同一个 i。当 setTimeout 回调执行时,循环已结束,i 的值为 3。
解决方案一:使用 let(推荐)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 输出:0, 1, 2 — 每次迭代 let 创建新的块级作用域绑定
解决方案二:IIFE 创建独立作用域
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
4.2 闭包导致的内存泄漏
function heavyProcess() {
const largeArray = new Array(1000000).fill('data');
return function leaker() {
// 即使 leaker 没有直接使用 largeArray,
// 如果同一作用域中还有其他闭包引用了该作用域的任何变量,
// largeArray 可能不会被回收(取决于引擎实现)
console.log('leaking');
};
}
最佳实践:
- 及时解除不再需要的引用(
= null) - 避免在闭包中无意捕获大对象
- 使用 Chrome DevTools 的 Heap Snapshot 检测闭包相关的内存泄漏
4.3 Stale Closure(过期闭包)
在 React Hooks 中尤为常见:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// ❌ Stale closure — 永远读到初始的 count = 0
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空依赖数组 → 闭包只捕获了初次渲染的 count
// ✅ 修复方案:使用函数式更新
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // 不依赖闭包中的 count
}, 1000);
return () => clearInterval(id);
}, []);
}
五、面试高频题型
题型 1:说出输出结果(考察作用域链 + 闭包)
function createFunctions() {
var result = [];
for (var i = 0; i < 3; i++) {
result.push(function () { return i; });
}
return result;
}
var fns = createFunctions();
console.log(fns[0]()); // ?
console.log(fns[1]()); // ?
console.log(fns[2]()); // ?
答案:全部输出 3。解决方案见 4.1 节。
题型 2:闭包与垃圾回收
"闭包会不会导致内存泄漏?如何排查?"
核心答题思路:
- 闭包本身不是内存泄漏,但会延长变量的生命周期
- 当闭包不再需要但仍被引用时(如 DOM 事件监听器未移除),才构成泄漏
- 使用 DevTools → Memory → Heap Snapshot → Retainers 面板排查
题型 3:实现一个 once 函数
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
const initialize = once(() => {
console.log('Init!');
return 42;
});
initialize(); // "Init!" → 42
initialize(); // 42(不再执行 fn)
六、深入理解:执行上下文与词法环境
6.1 ECMAScript 规范模型
每次函数调用时,引擎会创建一个 Execution Context,包含:
- LexicalEnvironment:存储
let/const/class声明 - VariableEnvironment:存储
var和function声明 - ThisBinding:
this的值
每个 Lexical Environment 由两部分组成:
- Environment Record:当前作用域的变量绑定
- Outer Environment Reference:指向父级 Lexical Environment(形成作用域链)
6.2 闭包在规范中的表达
ECMAScript 规范没有"闭包"这个术语。闭包的效果通过以下机制实现:
- 函数对象在创建时记录
[[Environment]]内部槽(Internal Slot),指向当前的 Lexical Environment - 函数被调用时,新 Execution Context 的 outer reference 设置为
[[Environment]] - 只要函数对象存活,
[[Environment]]引用的 Lexical Environment 就不会被 GC
七、与其他主题的关联
| 关联主题 | 关系 |
|---|---|
| this-binding | this 不受词法作用域控制(箭头函数除外),是独立的绑定机制 |
| es-modules | ESM 的模块作用域本质上是一个闭包 |
| async-programming | async 函数中的闭包行为与 stale closure 问题密切相关 |
| memory-management | 闭包是常见的内存泄漏源头之一 |
| functional-patterns | 柯里化、偏函数等函数式模式的底层机制就是闭包 |
参考资料
- Kyle Simpson, You Don't Know JS: Scope & Closures (2nd Edition) — 闭包概念最经典的讲解
- MDN Web Docs — Closures
- ECMA-262 — Lexical Environments
- Dmitry Soshnikov, JavaScript. The Core — 执行上下文与作用域链的可视化解释
- V8 Blog — Understanding V8's Bytecode — 理解 V8 如何处理闭包中的变量分配