JavaScript · 闭包与词法作用域

函数记住的不是"调用时在哪", 而是"定义时在哪" — 作用域链在写代码那一刻就定型, 闭包是被函数抓住不放的环境

inner 被返回 → 抓住环境 全局作用域 (模块顶层) makeCounter 作用域 inner 的环境 count : 0 inner 里找不到的变量 沿这层嵌套向上找 词法作用域: 嵌套关系由"代码写在哪"决定, 与谁调用无关 inner 函数对象 自带出生环境引用 [[环境]] makeCounter 本次调用的环境 (AO) count: 0 → 1 → 2 ... 被 inner 引用 → GC 不敢回收 → 状态存活 对照: 普通函数调用结束 无引用 → 环境整体回收 闭包 = 函数 + 它出生时抓住的环境, 两者一起活一起死 var: 三个回调共享一个 i setTimeout ×3 ──引用──▶ i : 3 循环早已跑完, 读到最终值 输出 3 3 3 let: 每轮循环一个新 i 回调0▶i:0 回调1▶i:1 回调2▶i:2 let 每轮创建全新绑定, 各抓各的 输出 0 1 2 function makeCounter() { let count = 0; return function () { return ++count; }; } const c = makeCounter(); c(); c(); // count 不在任何可见变量里, 却一直活着 —— 它在 c 的闭包里 // 每次 makeCounter() 产生独立环境: c2 = makeCounter() 互不干扰 Legend 作用域层级 函数调用环境 (AO) 被闭包抓住的环境 var 共享绑定陷阱 泄漏/危险路径 作用域在"写"时定型(词法), this 在"调"时定型(执行) — 别混

作用域在"写"时定型

  • • 词法作用域: 看函数定义在哪, 不看在哪调用
  • • 查找沿定义链向上, 一层找不到再到全局
  • • 动态改作用域的只有 eval/with(都是禁术)
  • • this 才是"调用时"决定, 别和作用域混

闭包 = 函数 + 环境

  • • 内层函数被带到外面, 环境跟着一起走
  • • 每次外层调用产生独立环境, 互不干扰
  • • 被引用的变量活着, 没被引用的早回收了
  • • 代价: 环境里的大对象会被整个拽住

生产的两大用法

  • • 封装状态: 防抖/缓存/单次守卫/私有数据
  • • 工厂: 每个实例一套配置与状态
  • • 陷阱源头: 循环 var、stale closure、泄漏
  • • 用完记得解除引用, 别让环境永生

💡 一句话理解

作用域是"户口": 函数在哪出生, 户口就落在哪一层, 查变量永远沿着出生地往上找 — 这就是词法作用域。而闭包是: 内层函数被返回到外地工作, 但它把出生地那个房间(变量环境)整个打包带走。房间里它用得着的变量count不会被回收, 函数每次回来都能接着上次继续。

一句话: 闭包让函数拥有私有、持久的记忆。防抖的 timer、缓存的上次结果、计数器的 count、模块的私有状态, 全是同一件事的不同马甲。代价也要记牢: 函数活着, 它抓住的整个环境就活着 — 内存泄漏的高发区。

🧠 必知必会 必考 & 必会

词法作用域
作用域嵌套由源码结构决定(写在哪个函数里), 编译期就定型; 与运行时谁调用它无关。
let x = '全局';
function read() { return x; }   // 定义处看到的 x 是全局这个
function wrap() { let x = '局部'; return read(); }
wrap();  // → '全局', 不是 '局部'
作用域链
查变量沿"当前函数 → 外层函数 → ... → 全局"逐层向上, 找到即停; 每层是函数定义时锁定的环境引用。
const a = 1;
function outer() { const b = 2;
  function inner() { return a + b; }  // a 从全局, b 从 outer
  return inner;
}
outer()();  // → 3
闭包的定义
函数 + 它定义时所处词法环境的引用。哪怕外层函数已返回, 环境随函数存活。
function make() { const secret = 42;
  return () => secret;   // 箭头函数抓住 secret
}
const read = make(); read();  // → 42, make 早已返回
每次调用新环境
外层函数每调用一次就造一个全新环境; 两个闭包各抓各的, 天然隔离。
const a = makeCounter(), b = makeCounter();
a(); a();  // a: count → 2
b();       // b: count → 1  互不影响
var / let / const
var 函数作用域+提升(undefined); let/const 块级作用域+TDZ; 循环里 let 每轮新绑定。
console.log(a); // → undefined (var 提升)
var a = 1;
console.log(b); // → ReferenceError (TDZ, 关键差异)
let b = 2;
循环与闭包
var 在循环外共享一个绑定; let 每轮迭代创建新绑定 — 3 个回调各抓各的 i。
for (var i = 0; i < 3; i++) setTimeout(() => log(i)); // 3 3 3
for (let i = 0; i < 3; i++) setTimeout(() => log(i)); // 0 1 2
IIFE
立即执行函数表达式: 造一个一次性作用域, 把变量锁在里面, 不污染外部。
(function () {
  var private = 1;      // 外界不可见
})();                    // 关键: 括号把"声明"变"表达式"再调用
模块模式
闭包版私有字段: 返回的公开方法持有内部状态, 外部只能通过接口读写。
const wallet = (() => {
  let balance = 100;                    // 真私有
  return { deposit: n => balance += n,
           get: () => balance };
})();
wallet.deposit(50); wallet.get();  // → 150
内存代价
环境是整体存活: 抓了一个小变量, 同环境里的大数组也一起被拽住。
function process(big) {        // big 是 100MB 数组
  const n = big.length;        // 只需要长度
  return () => n;              // 关键: 现代引擎可优化, 但别赌
}
// 稳妥: 先提取标量, 别让闭包直接引用大对象
解除引用
闭包不死, 环境不灭。长期存活的监听器/定时器里持着大闭包, 必须主动断开。
const onUpdate = () => render(cache.data);
window.addEventListener('resize', onUpdate);
// 组件销毁时:
window.removeEventListener('resize', onUpdate);  // 关键
stale closure
闭包记住的是当时的变量值引用; 异步回调和 React hooks 里读到的常是"过期快照"。
let count = 0;
btn.onclick = () => setTimeout(() => log(count));
count = 99; btn.click();
// → 99: 读的是同一变量; 但 React useState 的值是不可变快照
// hooks 场景: setCount(c => c + 1) 函数式更新避免 stale
TDZ
let/const 从块开头到声明行之间存在"暂时性死区", 访问即 ReferenceError — 比 var 的 undefined 更早暴露 bug。
{
  // f(); // → ReferenceError: Cannot access 'f' before init
  const f = () => 1;   // 关键: 死区到这一行才结束
}

🏭 生产实战 real world

场景 1 · 防抖: 搜索框 300ms 合并请求

输入联想每敲一键发一次请求, 服务器被打爆还闪屏。闭包保存 timer 与上下文, 静默期后才真正触发:

function debounce(fn, wait = 300) {
  let timer = null;                    // 闭包状态: 藏在返回函数里
  return function (...args) {
    clearTimeout(timer);               // 重置上一轮
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}
input.addEventListener('input', debounce(e => search(e.target.value)));

请求量从"每键一次"降到"停手一次", QPS 降 90%+; apply(this, args) 保住调用方上下文。

场景 2 · memoize: 参数不变就不重算

价格计算函数每次都要拉汇率、跑 200ms, 列表页一次调 500 次。闭包字典做结果缓存:

function memoize(fn) {
  const cache = new Map();             // 闭包持有, 外部摸不到
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);             // 关键: 同参直接命中
  };
}
const quote = memoize(calcPrice);

同参命中率 80%+, 页面计算耗时从 10s 降到 2s; 注意缓存键要含全部影响结果的入参。

场景 3 · once: 防重复提交与幂等初始化

"提交"按钮连点 3 次生成 3 笔订单; SDK init 被多处调用重复连接。闭包守卫只放行第一次:

function once(fn) {
  let called = false, result;
  return (...args) => {
    if (called) return result;         // 关键: 之后全返回首跑结果
    called = true;
    return (result = fn(...args));
  };
}
const submit = once(async (form) => createOrder(form));

场景 4 · React stale closure: 计数按钮点快了丢次数

批量导入进度用 setCount(count + 1), 连续触发时读到的都是旧快照, 100 次只 +37:

// 错: count 是本次渲染的快照, 批量更新时已过期
onChange={() => setCount(count + 1)}
// 对: 函数式更新, 拿到队列里最新的值
onChange={() => setCount(c => c + 1)}
// 跨渲染读最新值(如定时器里), 用 ref:
const latest = useRef(count);
latest.current = count;
useEffect(() => {
  const id = setInterval(() => report(latest.current), 1000);
  return () => clearInterval(id);
}, []);

场景 5 · 动态列表事件绑定: 闭包带上下文

1000 行表格每行删除按钮要带自己的 id, 传统做法 dataset 存值再解析, 闭包直接捕获行对象更干净:

rows.forEach(row => {
  const btn = el('button', '删除');
  btn.onclick = () => removeRow(row.id);   // 关键: 每轮闭包捕获自己的 row
  tr.append(btn);
});
// 注意: 重建列表前先解绑, 否则旧 row 全被拽在闭包里不释放

场景 6 · 柯里化: 给埋点函数预置公共参数

埋点接口 20 个参数, 业务侧只想传事件名与属性。闭包固定公共参数, 生成专用函数:

const track = (common) => (event, props = {}) =>
  beacon({ ...common, event, ...props });

const trackApp = track({ app: 'mall', ver: '2.3.0' });
trackApp('add_cart', { sku: 'A1' });   // 公共字段自动带上

场景 7 · 单测隔离: 闭包状态污染

模块级缓存闭包让测试互相污染: 用例 A 写入的缓存被用例 B 命中, 单跑绿合跑红。工厂化让每例拿到全新实例:

// 错: 模块顶层创建, 所有测试共享同一个 cache 闭包
export const store = createStore();
// 对: 导出工厂, 测试 setup 里各造各的
export const createStore = () => { let cache = new Map();
  return { get: k => cache.get(k), set: (k, v) => cache.set(k, v) };
};
// 每个用例: const store = createStore();  隔离干净

场景 8 · 闭包泄漏: 图表页内存只涨不降

切换 20 个报表后页面上 GB。heap snapshot 显示大量 Retained by "refreshChart" 闭包 — 监听器没解绑, 大数据被拽住:

// 泄漏形态: 全局事件持有含 50MB 数据的闭包
window.addEventListener('resize', () => chart.refresh(bigData));
// 修复: 命名函数 + 生命周期对称解绑
const onResize = () => chart.refresh(bigData);
window.addEventListener('resize', onResize);
onUnmount(() => window.removeEventListener('resize', onResize));

切换 50 次内存曲线平稳; 排查路径: Memory 面板拍快照 → 按 Retainers 找闭包 → 对应解绑。

场景 9 · 私有状态选型: 闭包 vs #field

类字段 #count 与闭包都能私有, 选型看需求: 闭包适合少量实例/函数式, #field 适合类体系且调试器可见:

// 闭包版: 状态在构造器作用域里, 调试面板看 Closure
class RateLimiter1 {
  constructor(max) { let used = 0;
    this.tryAcquire = () => used < max && (used++, true);
  }
}
// #field 版: 真·对象私有, 子类不可见但 DevTools 直接可查
class RateLimiter2 { #used = 0; #max;
  constructor(max) { this.#max = max; }
  tryAcquire() { return this.#used < this.#max && (this.#used++, true); }
}

场景 10 · SSR 下的模块级闭包: 用户 A 看到用户 B 的数据

Node 服务里模块级 let currentUser 被所有请求共享, 高并发下串号。请求级状态必须每请求新建:

// 错: 模块级闭包状态, 全进程共享
let currentUser = null;
app.use((req, res, next) => { currentUser = req.user; next(); });
// 对: 状态挂到请求对象/AsyncLocalStorage, 每请求独立
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
app.use((req, res, next) => als.run({ user: req.user }, next));
// 任意深度: als.getStore().user  —— 跟踪异步链且请求间隔离

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 循环 var 捕获最终值 — 回调共享一个 var 绑定, 拿到循环结束值. 正解: let 每轮新绑定。
// 错: for (var i = 0; i < 3; i++) setTimeout(() => log(i)); // 3 3 3
// 对: for (let i = 0; i < 3; i++) setTimeout(() => log(i)); // 0 1 2
坑 2 · 闭包拽住大对象 — 只要引用环境里任意一个变量, 整个环境(含大数组)一起存活. 正解: 先提取标量再返回闭包。
// 错: () => rows.length + total(rows)   // 整个 100MB rows 被拽住
// 对: const n = rows.length; () => n;    // 只留数字
坑 3 · 忘记解绑监听器 — 闭包+监听器是泄漏双人组, 组件卸载后环境永生. 正解: removeEventListener 或 AbortSignal。
// 错: 匿名函数无法解绑
window.addEventListener('resize', () => render());
// 对: const h = () => render(); addEventListener('resize', h);
onUnmount(() => removeEventListener('resize', h));
坑 4 · stale closure 读旧快照 — hooks/事件里读到上次渲染的值, 批量更新下数据丢. 正解: 函数式更新或 useRef 镜像最新值。
// 错: setCount(count + 1)      // count 是过期快照
// 对: setCount(c => c + 1)      // 队列里最新值
坑 5 · TDZ 误判 — let/const 声明前访问抛 ReferenceError 而非 undefined, 提前调用的函数会炸. 正解: 声明置顶或先定义后用。
// 错: helper(); const helper = () => {};  // → ReferenceError
// 对: const helper = () => {}; helper();
坑 6 · IIFE 忘了调用括号 — 只定义没执行, 里面的初始化逻辑全没跑. 正解: (function(){})() 两对括号缺一不可。
// 错: (function () { init(); });     // 没执行!
// 对: (function () { init(); })();   // 立即执行
坑 7 · 隐式全局泄漏 — 忘写声明变成全局变量, 严格模式下才报错. 正解: 文件头 'use strict' 或统一 ESM(自带严格模式)。
// 错: function f() { total = 1; }  // window.total = 1 泄漏
// 对: function f() { let total = 1; }
坑 8 · 以为每次回调新环境 — 同一次外层调用产生的多个闭包共享同一环境, 一个改全员可见. 正解: 需要独立就再调一次外层工厂。
const inc1 = make(), inc2 = make(); // 两个独立环境
const [a, b] = [make(), make()];     // 同上, 别共享
坑 9 · eval/with 破坏优化 — 动态作用域让引擎放弃词法分析优化, 严格模式已禁 with. 正解: 彻底不用, 动态键用 Map。
// 错: eval('cfg.' + key);      // 慢+不安全+作用域混乱
// 对: cfg[key]  // 或 new Map([[key, val]])
坑 10 · 异步闭包竞态 — 多个异步闭包交错读写同一环境变量, 顺序不可控. 正解: 状态收敛到单一所有者(队列/状态机)。
// 错: 两个 async 回调同时 read-modify-write seq
// 对: 串行化
let chain = Promise.resolve();
const run = task => (chain = chain.then(task));
坑 11 · 闭包读"最终值"而非"注册时值" — setTimeout 0 里读到的也是执行时的当前值. 正解: 需要定格就先存局部再捕获。
// 错: setTimeout(() => log(cfg), 0); cfg = '改了'; // 读到'改了'
// 对: const snap = cfg; setTimeout(() => log(snap), 0);
坑 12 · 私有靠约定不靠机制 — _prop 前缀只是君子协定, 外部照样能改. 正解: 闭包或 #field 真私有。
// 错: this._secret = 1;   // 外部 obj._secret = 2 直接改掉
// 对: #secret = 1;        // 外部访问直接语法错误
坑 13 · 循环内建函数对象 — 每轮新建函数, 大列表下浪费且难以统一解绑. 正解: 事件委托, 一个监听器 + dataset 读参数。
// 错: 1000 行 1000 个闭包
// 对: 委托
table.onclick = e => { const id = e.target.dataset.id; if (id) removeRow(id); };
坑 14 · 防抖丢 this 与参数 — 返回箭头函数拿不到调用方 this. 正解: function(...args) + fn.apply(this, args)。
// 错: return (...a) => fn(...a);        // this 丢失
// 对: return function (...a) { fn.apply(this, a); };
坑 15 · 闭包内直接改参数对象 — 回调悄悄改了调用方的入参, 副作用扩散. 正解: 解构拷贝再改。
// 错: (opts) => { opts.ready = true; }  // 调用方的对象被改
// 对: (opts) => { const o = { ...opts, ready: true };
坑 16 · SSR 模块级闭包串号 — 服务端模块单例被所有请求共享, 高并发下用户数据串号. 正解: 请求级状态(AsyncLocalStorage/req 对象)。
// 错: 模块顶层 let user 全进程共享
// 对: als.run({ user }, handler) 请求级隔离
坑 17 · 循环体内 vs 体外 let — let 写在 for 头部每轮新绑定, 写在循环体内每轮也新建, 但写在循环外就共享一个. 正解: 绑定位置与期望一致。
let x; for (x = 0; x < 3; x++) setTimeout(() => log(x)); // 3 3 3
for (let x = 0; x < 3; x++) setTimeout(() => log(x));   // 0 1 2
坑 18 · 调试闭包找不到变量 — 未被内层引用的变量不进闭包环境, 断点 Scope 面板里看不到. 正解: 调试时临时加个引用或改在外层打日志。
// 断点在 inner: Scope → Closure 只有被引用的
// outer 的 localVar 没被 inner 用 → 不在, 不是丢了
坑 19 · 缓存闭包无淘汰 — memoize 的 Map 无限增长, 长会话页面内存爆炸. 正解: 上限 + LRU 淘汰。
// 错: cache.set 无限累积
// 对: if (cache.size > 500) cache.delete(cache.keys().next().value);
坑 20 · 定时器回调持有组件树 — 轮询定时器闭包引用整个组件状态, 组件销毁仍在跑. 正解: 生命周期里 clearInterval。
// 错: setInterval(() => setState(data), 1000) 没清理
// 对: useEffect(() => { const id = setInterval(...);
  return () => clearInterval(id); }, []);