B01 作用域与闭包

2026-10-31 17:190 阅读约 11 分钟JavaScript

B01 作用域与闭包

摘要:作用域决定标识符在哪里可见,闭包是函数连同其词法环境的组合。变量查找沿作用域链由内向外,函数在哪定义而非在哪调用决定它能看到什么。闭包让私有状态与工厂函数成为可能,代价是延长变量生命周期,循环中误捕获与内存滞留由此产生。本文给出三类作用域、闭包的三处实用写法与两条泄漏判定方法。
关键词:作用域,作用域链,闭包,词法环境,暂时性死区

所属:frontend/js / B-进阶 | 上一篇:A09-实战待办清单 | 下一篇:B02-this与原型链

1 引言

同一行 console.log(count) 在函数内能取到值,在函数外报 count is not defined;循环里注册的十个回调打印出同一个数字;本应销毁的大数组占据内存不释放。三者的共同原因是作用域与闭包。

本文回答三个问题:变量在何处可见、闭包捕获了什么、什么时候闭包会造成滞留。范围见表 1-1。

项 本文覆盖 本文不覆盖
作用域 全局、函数、块级、模块 模块加载顺序(B05)
闭包 捕获机制、工厂、私有状态、柯里化 内存回收细节(C03)
声明 let/const 的暂时性死区、提升 属性描述符(C02)

2 预备知识

项 要求 核对方式
环境 Node.js 20+ 或浏览器控制台 能执行多行脚本
前置 A05 能写函数与默认参数
文件 js-practice/closures.js 编码 UTF-8

3 原理与实现

3.1 词法作用域与作用域链

函数在定义处确定查找链,与调用位置无关,如图 3-1 所示。

flowchart LR
    A["全局作用域<br/>global"] --> B["模块作用域<br/>module"]
    B --> C["函数作用域 outer"]
    C --> D["块级作用域 { let }"]
    D --> E["函数作用域 inner"]
    E -->|"查找 total"| D
    D -->|"未命中"| C
    C -->|"未命中"| B
    B -->|"未命中"| A
    A -->|"仍未命中"| F["ReferenceError"]

图 3-1 标识符按由内向外顺序逐层查找

图上可见:查找是单向的,内层能读外层,外层读不到内层;某一层一旦命中即停止,不会继续向上。

3.2 闭包的四种用法与两处陷阱

// js-practice/closures.js;运行环境:Node.js 20+
// 1 私有状态:外部只能通过方法读写
function createCounter(initial = 0) {
  let count = initial;
  return {
    inc: () => ++count,
    dec: () => --count,
    get value() {
      return count;
    },
  };
}

const counter = createCounter(10);
counter.inc();
console.log(counter.value);        // 11
console.log('count' in counter);   // false,无法直接访问

// 2 工厂:把配置固化进闭包,避免重复传参
function createFormatter(prefix) {
  return (text) => `${prefix}${text}`;
}
const warn = createFormatter('[warn] ');
console.log(warn('磁盘空间不足'));   // [warn] 磁盘空间不足

// 3 一次性初始化:立即执行函数限制作用域
const config = (() => {
  const base = 'https://api.example.com';
  return { base, timeout: 5000 };
})();

// 4 循环中捕获:let 每轮新建绑定
const fns = [];
for (let i = 0; i < 3; i += 1) {
  fns.push(() => i);
}
console.log(fns.map((f) => f()));  // [0, 1, 2]

const bad = [];
for (var j = 0; j < 3; j += 1) {
  bad.push(() => j);
}
console.log(bad.map((f) => f()));  // [3, 3, 3]

// 5 暂时性死区:声明前访问直接报错
try {
  console.log(early);
  let early = 1;
} catch (err) {
  console.log(err.name);           // ReferenceError
}

// 6 柯里化:把多参数函数拆成逐参数调用,配置固化在闭包里
const withBase = (base) => (value) => base + value;
const addTax = withBase(0.06);
const addFee = withBase(10);

console.log(addTax(100), addFee(100));   // 106 110

// 函数组合:把多个单参数函数串成一条流水线
const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const normalize = pipe(
  (s) => s.trim(),
  (s) => s.replace(/\s+/g, '-'),
  (s) => s.toLowerCase(),
);
console.log(normalize('  Hello  World '));   // hello-world

三类作用域的差异见表 3-1。

作用域 创建位置 可见范围 典型问题
全局 脚本顶层 全脚本 命名冲突、被意外改写
模块 type="module" 脚本 仅本文件 需要在出口显式 export
函数 函数体 函数内部 var 提升到函数顶
块级 {} 内 块内 var 不受块约束

闭包的两类风险见表 3-2。

风险 表现 处理
循环误捕获 所有回调读到同一个终值 循环变量用 let,或传参固化
内存滞留 大对象被长期持有,堆快照可见 用完后置 null,或缩短闭包生命周期

4 验证与讨论

  1. 执行脚本,确认输出 11、false、[warn] 磁盘空间不足、[0, 1, 2]、[3, 3, 3]、ReferenceError、106 110、hello-world。
  2. 把第三个演示的 let i 改为 var i,确认输出变为 [3, 3, 3]。
  3. 在返回对象上执行 counter.count = 999,再读 counter.value,确认私有变量不受影响。
  4. 用 Chrome 开发者工具的 Memory 面板拍两次堆快照,确认调用 counter 后计数器变量仍被持有。
检查项 预期结果 说明
私有变量隔离 外部赋值不改变内部值 闭包内变量不在返回对象上
let 循环 每个回调拿到独立值 每轮新建绑定
var 循环 全部回调拿到终值 函数级作用域共享一个变量
暂时性死区 声明前访问抛 ReferenceError let/const 提升但不初始化

讨论与局限。 闭包不会复制变量,捕获的是同一个绑定,因此多个方法共享状态。另,模块顶层变量本就不进入全局对象,试图用 window.x 访问模块内变量会得到 undefined,这不是闭包特性而是模块特性。

5 常见问题

现象 原因 处理
循环回调输出全是最后一个值 var 共享函数级作用域 改用 let 或用 forEach
给 const 声明前赋值抛错 暂时性死区 把声明移到使用之前
事件回调读到旧值 闭包捕获了旧绑定 每次重建回调,或改用参数传入新值
内存持续增长 闭包持有大对象且长期存活 清理引用,避免在闭包中缓存大数组
模块内变量在控制台取不到 模块作用域不挂到 window 显式挂载调试入口或加日志

6 结论

序号 结论 依据
1 查找沿词法作用域由内向外,单向不可逆 图 3-1
2 闭包捕获绑定而非值的拷贝 3.2 节
3 循环变量用 let 避免共享一个绑定 表 3-2
4 闭包延长变量生命周期,需主动清理引用 表 3-2

参考文献

[1] MDN Web Docs · 闭包,https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures(访问于 2026-10-31)
[2] ECMA-262 · 9 Executable Code and Execution Contexts,https://tc39.es/ecma262/#sec-executable-code-and-execution-contexts(访问于 2026-10-31)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~