作用域与闭包 — 从入门到进阶(图文并茂、易记版)
目标:让你能理解 JS 中作用域(Scope)和闭包(Closure)的“为什么/怎么发生/如何用/如何避免坑”,并配合大量实例、执行流程与记忆小技巧,能在实际开发中马上应用。
一、为什么会有作用域?(背景与直觉)
- 目的:为了解决“名字冲突”和“可见性管理”。语言需要规则告诉引擎“某个名字在哪些地方可读/可写”。
- 历史:早期 JS(ES3/ES5)只有函数作用域;后来 ES6 引入块级作用域(
let/const)、模块化等,以便写更安全、更模块化的代码。 - 直觉图:把作用域想成“套娃”——内层可以看到外层(向外查找),但外层看不到内层。这个查找链称为 scope chain。(参考 MDN 关于词法/词法分析说明)。(MDN Web Docs)
二、核心术语速查(先记住这些词)
- 词法作用域(Lexical Scope) :函数的可见性由定义位置决定(不是调用位置)。(MDN Web Docs)
- 动态作用域(Dynamic Scope) :可见性由调用链决定(JS 不是这种)。
- 提升(Hoisting) :声明在解析阶段被“处理”,表现为能在声明之前访问(不同声明行为不同)。(MDN Web Docs)
- 暂时性死区(TDZ) :
let/const被绑定到块作用域,但在声明前访问会报错(不是undefined)。(MDN Web Docs) - 闭包(Closure) :函数 + 它创建时能访问的外部词法环境(因此内函数可在外部被调用而仍然访问外部变量)。(MDN Web Docs)
三、词法作用域 vs 动态作用域(看代码学分辨)
词法(JS 的规则)
function outer() {
const x = 1;
function inner() {
console.log(x); // 访问 outer 的 x —— 因为 inner 定义在 outer 内
}
return inner;
}
const fn = outer();
fn(); // 输出 1 —— 与调用点无关,只看定义位置
要点:inner 能访问 x,因为它定义在 outer 内(词法作用域)。(MDN Web Docs)
如果是动态作用域(伪代码说明) :函数在被调用时去查调用栈上的变量(JS 不做这种查找)。
四、变量提升(Hoisting)与 TDZ(演示与区别)
var 的“提升”行为(function-scoped)
console.log(a); // -> undefined (因为声明被“提升”了,但赋值尚未发生)
var a = 2;
解释:var a 在解析阶段已被处理(声明被“放到”函数/全局顶部),但赋值仍在原处执行。注意 var 是函数作用域(非块)。(MDN Web Docs)
let/const 与 TDZ(会抛出 ReferenceError)
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 3;
解释:let/const 在块开始时绑定名字,但在到达声明之前处于“暂时性死区(TDZ)”,不能访问。TDZ 是 ES6 引入以避免 var 带来的怪异行为。(MDN Web Docs)
小结记忆点
var:声明提前(hoisted),值为undefined直到执行赋值。let/const:存在绑定,但在声明前不可访问(TDZ)。- 用
let/const可以避免var的很多陷阱(推荐日常使用)。
五、闭包(Closure)详解:为什么会有、引擎如何实现、实际应用
定义与“创建时机”
闭包 = 函数 + 它的词法环境(inner 函数形成时,会记录对外部变量的引用)。在 JS 中,每次函数创建时就会创建闭包环境。(MDN Web Docs)
简单示例(计数器工厂)
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
}
}
const c = makeCounter();
console.log(c()); // 1
console.log(c()); // 2
发生了什么(逐步) :
- 调用
makeCounter():创建了count这个变量(在其词法环境里)。 - 返回的函数仍然持有对
count的引用(通过闭包)。 - 即使
makeCounter已返回,count因为被闭包引用而仍保留在内存中。
闭包的实现原理(高阶理解)
- 引擎在创建函数时会给它一个内部槽([[Environment]]),指向当前词法环境。
- 只要存在对内部函数的可达引用,外部的变量就不会被回收(GC 会保留可达对象)。(因此闭包会延长某些变量的生命周期)。
常见用途(实战)
- 模拟私有变量(模块模式/工厂)。
- 柯里化(currying)与偏函数(partial application)。
- 记忆化(memoization)缓存计算结果。
- 保持异步回调的状态(事件处理器、定时器)。
闭包容易踩的坑(并给出解决)
问题 A:循环中的 var 问题
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10); // 输出 3,3,3
}
原因:var 是函数作用域,3 次回调共享同一个 i,循环结束时 i==3。
解决:用 let(块作用域)或 IIFE:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 10); // 0,1,2
}
// 或
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 10);
})(i);
}
六、闭包与内存泄漏(为什么会“占内存”?如何排查/避免)
常见导致“泄漏”的场景
- 闭包不小心持有大型对象(如大数组、DOM 节点),且这些引用没有及时释放。
- 长期运行的定时器/回调(
setInterval)内的闭包引用。 - 未移除的事件监听器,其回调闭包引用外部资源。
规避策略
- 避免在闭包中保存不必要的“大”引用(尤其是 DOM 节点)。
- 当不再需要时:
removeEventListener、clearInterval、或把长时间不需要的闭包引用置为null。 - 对于缓存场景,考虑用
WeakMap/WeakRef(允许 GC 回收键/值)。 - 用 Chrome DevTools 的 Memory 面板做堆快照、查找泄漏对象(这是排查首选工具)。
提示:现代 GC 很聪明,真正的泄漏往往来自“外部可达”引用(比如全局变量、未卸载的监听器)而非闭包本身。
七、模块模式、IIFE 与块级作用域(实战与进化)
IIFE(立即执行函数表达式)——早期模块化手段
const myModule = (function() {
let privateVar = 0;
function privateFn() { return ++privateVar; }
return {
next: function() { return privateFn(); }
};
})();
console.log(myModule.next()); // 1
IIFE 利用闭包把 privateVar 隐藏在作用域内,暴露需要的 API(在 ES6 模块之前非常常见)。
ES6 模块(更现代)
- ES6 模块(
import/export)在语言层面提供文件级作用域与导出接口,优先使用模块而不是 IIFE(可读性更好、工具链更健壮)。
块级作用域(let/const)
- 使用
let/const限制变量可见范围,减少无意的全局/共享状态。
八、实战小案例(场景 + 解决)
- UI 计数器组件:使用闭包做组件的私有状态(避免污染全局)
- 节流/防抖:闭包保存定时器 id,实现函数节流/防抖
- 缓存(memoize) :使用闭包保存参数到结果的映射(注意内存上界)
- 清理事件监听:为元素注册监听时返回一个
dispose函数以便解绑
示例:返回可 dispose 的事件绑定
function bind(el, type, handler) {
el.addEventListener(type, handler);
return function dispose() {
el.removeEventListener(type, handler);
}
}
const dispose = bind(btn, 'click', () => console.log('clicked'));
// later
dispose(); // 解除绑定,避免泄漏
九、练习题(带答案,自己动手跑)
题 1:下面代码会输出什么?为什么?
function f() {
console.log(a);
let a = 1;
}
f();
答案:抛出 ReferenceError —— a 在 TDZ 内,不能在声明前访问。(MDN Web Docs)
题 2:下面代码输出什么?(解释 hoisting)
console.log(x);
var x = 5;
答案:undefined。var x 在解析时已声明(被提升),但赋值在运行阶段才发生。(MDN Web Docs)
题 3:利用闭包实现 makeAdder(n),返回函数使其把参数加上 n。
function makeAdder(n) {
return function (x) { return x + n; };
}
const add5 = makeAdder(5);
console.log(add5(2)); // ?
答案:输出 7(闭包保存了 n=5)。
十、记忆口诀 / 脑图逻辑(便于面试与快速回忆)
- “定义在哪,看哪儿” —— 词法作用域(定义决定可见)。(MDN Web Docs)
- “var 提前,let/const 不早” —— hoisting vs TDZ。(MDN Web Docs)
- “闭包 = 函数 + 环境” —— 每次创建函数都会带上环境引用。(MDN Web Docs)
- “小心大对象与长期回调” —— 泄漏常见来源:未移除的监听/定时器/全局引用。
十一、扩展阅读(权威资料)
- MDN — Closures(闭包):解释与示例。(MDN Web Docs)
- MDN —
let(TDZ 说明)。(MDN Web Docs) - MDN — Hoisting(术语与行为)。(MDN Web Docs)
- MDN — Lexical Grammar(词法/词法分析)。(MDN Web Docs)
- MDN —
var(具体行为)。(MDN Web Docs)