作用域(Scope)与闭包(Closure)——深入底层的学习笔记(笔记风格、图文/步骤/记忆要点)

74 阅读7分钟

image.png

作用域与闭包 — 从入门到进阶(图文并茂、易记版)

目标:让你能理解 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

发生了什么(逐步)

  1. 调用 makeCounter():创建了 count 这个变量(在其词法环境里)。
  2. 返回的函数仍然持有对 count 的引用(通过闭包)。
  3. 即使 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 节点)。
  • 当不再需要时:removeEventListenerclearInterval、或把长时间不需要的闭包引用置为 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 限制变量可见范围,减少无意的全局/共享状态。

八、实战小案例(场景 + 解决)

  1. UI 计数器组件:使用闭包做组件的私有状态(避免污染全局)
  2. 节流/防抖:闭包保存定时器 id,实现函数节流/防抖
  3. 缓存(memoize) :使用闭包保存参数到结果的映射(注意内存上界)
  4. 清理事件监听:为元素注册监听时返回一个 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;

答案undefinedvar 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)
  • “小心大对象与长期回调” —— 泄漏常见来源:未移除的监听/定时器/全局引用。

十一、扩展阅读(权威资料)