作用域与闭包

深入解析 JavaScript 词法作用域链与闭包的内存模型,涵盖工程场景中的经典应用与常见陷阱,帮助你在面试中展现对执行上下文机制的底层理解。

为什么作用域与闭包是 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+) letconstclass

注意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 闭包的形成条件

  1. 存在函数嵌套
  2. 内部函数引用了外部函数的变量
  3. 内部函数在外部函数执行完毕后仍然可达(通常是被返回或传递给其他地方)
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 的 useStateuseEffect 等 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:闭包与垃圾回收

"闭包会不会导致内存泄漏?如何排查?"

核心答题思路:

  1. 闭包本身不是内存泄漏,但会延长变量的生命周期
  2. 当闭包不再需要但仍被引用时(如 DOM 事件监听器未移除),才构成泄漏
  3. 使用 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,包含:

  1. LexicalEnvironment:存储 let/const/class 声明
  2. VariableEnvironment:存储 varfunction 声明
  3. ThisBindingthis 的值

每个 Lexical Environment 由两部分组成:

  • Environment Record:当前作用域的变量绑定
  • Outer Environment Reference:指向父级 Lexical Environment(形成作用域链)

6.2 闭包在规范中的表达

ECMAScript 规范没有"闭包"这个术语。闭包的效果通过以下机制实现:

  1. 函数对象在创建时记录 [[Environment]] 内部槽(Internal Slot),指向当前的 Lexical Environment
  2. 函数被调用时,新 Execution Context 的 outer reference 设置为 [[Environment]]
  3. 只要函数对象存活,[[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 如何处理闭包中的变量分配

延展阅读