Skip to content

作用域、作用域链与闭包 ​

难度等级:🟢 初级 分类:JavaScript 归档日期:2026-10-09


什么是作用域、作用域链?聊一聊什么是闭包,你对闭包的理解。

难度与考察点 ​

难度: 基础题,但三个概念是层层递进的:作用域决定变量的可见范围 → 作用域链决定变量怎么查找 → 闭包是作用域链在函数"逃逸"后依然生效的结果。

面试官希望看到:

  1. 能把三者串成一条线讲,而不是分别背三个定义
  2. 能从规范的词法环境 / 环境记录层面解释,而不只是"里面能访问外面"
  3. 了解 var / let / const 的作用域差异、TDZ、V8 中闭包变量的存储
  4. 有真实工程经验:React 过期闭包、内存泄漏排查等

核心原理 ​

一、作用域 ​

作用域是变量和函数的可访问范围,决定了一个标识符在哪里能被访问。

JS 采用的是词法作用域(静态作用域):作用域在代码书写时就确定,与函数在哪里调用无关。

js
const x = 'global';
function foo() { console.log(x); }
function bar() {
  const x = 'bar';
  foo();
}
bar(); // 'global' —— foo 的作用域由定义位置决定,而不是调用位置

作用域的类型:

类型说明
全局作用域var 声明和函数声明会挂到 window 上;let/const 在全局词法环境中,但不会挂到 window
函数作用域每次函数调用都会创建新的作用域,var 只认函数边界
块级作用域ES6 起 let / const / class 以 {} 为边界
模块作用域ESM 中每个模块有独立的顶层作用域,顶层变量不污染全局

var 与 let/const 的关键差异:

  • var 没有块级作用域,存在变量提升,提升后初始值为 undefined
  • let/const 也会"提升"(在进入作用域时就创建绑定),但在声明语句执行前处于暂时性死区(TDZ),访问会抛出 ReferenceError
js
console.log(a); // undefined
var a = 1;

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 2;

二、作用域链 ​

规范中,每个作用域对应一个环境记录(Environment Record),每个环境记录都有一个 outer 指针,指向外层环境。这些环境通过 outer 串起来,就是作用域链。

查找规则: 访问变量时,从当前环境开始,沿 outer 逐级向上查找,找到即停止;直到全局仍未找到,就抛出 ReferenceError(非严格模式下给未声明变量赋值会意外创建全局变量)。

js
const a = 1;
function outer() {
  const b = 2;
  function inner() {
    const c = 3;
    console.log(a, b, c); // inner → outer → global
  }
  inner();
}

关键点:outer 指向哪里,取决于函数在哪里定义。 函数对象创建时,会把当前词法环境保存到内部槽 [[Environment]] 中;函数调用时,新环境的 outer 就指向这个 [[Environment]]。这正是"词法作用域"在规范中的落地方式,也是闭包产生的根本原因。

补充两点:

  • 变量遮蔽(shadowing):内层同名变量会挡住外层变量,查找到内层就停止
  • 作用域链和原型链的区别:作用域链用于查找变量标识符,找不到会报 ReferenceError;原型链用于查找对象属性,找不到返回 undefined

三、闭包 ​

闭包是函数和它创建时所在词法环境的组合。 即使外层函数已经执行完毕,内层函数依然可以通过作用域链访问外层的变量。

js
function createCounter() {
  let count = 0;
  return {
    inc: () => ++count,
    get: () => count,
  };
}

const c = createCounter(); // createCounter 执行完,执行上下文出栈
c.inc(); c.inc();
c.get(); // 2

形成原因: createCounter 执行完后,它的执行上下文[1]出栈,但它的环境记录仍然被 inc、get 的 [[Environment]] 引用,因此不会被 GC 回收。

从规范角度讲,JS 中所有函数都是闭包[2];日常所说的闭包,通常特指函数被返回或传出外层作用域后,仍然访问外层变量的情况。

闭包捕获的是"绑定",不是"值的快照":

js
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));
// 3 3 3:var 只有一个函数级绑定,三个回调共享同一个 i

for (let i = 0; i < 3; i++) setTimeout(() => console.log(i));
// 0 1 2:let 在每次迭代都会创建新的绑定

引擎层面(V8,加分点):

  • 解析阶段会做作用域分析,只有被内层函数引用的变量才会放到堆上的 Context 对象中,其余变量仍在栈上,函数返回后即释放
  • 同一个外层函数中的多个闭包共享同一个 Context,一个闭包引用了大对象,其他闭包存活时也会让它无法释放
  • 使用 eval 时引擎无法静态分析,会保留整个作用域

工程中的典型用途: 模块封装和私有状态、函数工厂和柯里化、防抖 / 节流 / memoize、React Hooks、Vue 组合式函数。


口语化答案(约 3 分钟) ​

我按作用域、作用域链、闭包这个顺序来讲,因为它们其实是一条线上的东西。

作用域就是变量的可访问范围。JS 是词法作用域,也就是作用域在代码书写的时候就确定了,跟函数在哪调用没关系。作用域主要有全局、函数、块级,还有 ESM 的模块作用域。var 只有函数作用域,会提升成 undefined;let、const 有块级作用域,声明之前处在暂时性死区,访问会直接报错。

作用域链是变量的查找机制。规范里每个作用域对应一个环境记录,它有一个 outer 指针指向外层环境,一层层串起来就是作用域链。访问变量时从当前环境开始沿着 outer 往上找,找到就停,到全局都没找到就报 ReferenceError。这里关键在于 outer 指向哪里:函数在创建的时候会把当前的词法环境存到它的 [[Environment]] 里,调用时新环境的 outer 就指向它。所以作用域链是由定义位置决定的。

闭包其实就是这个机制自然产生的结果。函数被创建时记住了外层环境,那么即使外层函数执行完、执行上下文出栈了,只要内层函数还被引用,外层的环境记录就不会被回收,内层函数依然能访问外层变量。所以严格讲 JS 里每个函数都是闭包,平时说的闭包一般是指函数传出去之后还在用外层变量的情况。

有一点我觉得比较重要:闭包捕获的是变量的绑定,不是当时的值。经典的 for 循环 var 加 setTimeout 输出三个 3,就是因为回调共享同一个 i;换成 let 每次迭代都有新的绑定,就是 0、1、2。

实现上,V8 只会把被闭包引用的变量放到堆上的 Context 对象里,而且同一个函数里的多个闭包共享这个 Context,所以有时候一个闭包引用了大对象,会连带着释放不掉。

工程里闭包用得很多,比如防抖节流靠闭包保存 timer,模块私有状态,以及 React Hooks。我遇到最多的问题是 React 的过期闭包:比如 useEffect 里开了 setInterval,依赖写成空数组,回调里拿到的一直是第一次渲染的 state。一般用补全依赖、函数式更新或者 useRef 存最新值来解决。另外闭包本身不会造成内存泄漏,问题在于闭包活得比预期久,比如事件监听、定时器没清理,这时我会用 DevTools 的 Memory 面板对比堆快照,通过 Retainers 定位是哪个 Context 在持有。


常见错误 ​

  • "作用域是在函数调用时确定的":那是动态作用域。JS 是词法作用域,只有 this 是动态绑定的。
  • "let/const 不会提升":会提升(进入作用域时创建绑定),只是没有初始化,处于 TDZ。
  • "闭包就是函数里套函数":嵌套只是常见形式,本质是函数对定义时词法环境的引用。
  • "闭包会导致内存泄漏,要少用":泄漏是因为引用链没有按预期断开,不是闭包本身的问题。
  • "闭包保存的是变量当时的值":捕获的是绑定。React 中看起来像快照,是因为每次渲染的 state 都是新的 const 绑定。

面试官可能的追问 ​

1. 下面代码输出什么?

js
var a = 1;
function foo() {
  console.log(a);
  var a = 2;
}
foo();

输出 undefined。函数内部的 var a 被提升到函数作用域顶部,遮蔽了全局的 a,执行到 console.log 时还没赋值。如果改成 let a = 2,则会抛出 ReferenceError(TDZ)。

2. let 在 for 循环里为什么每次迭代都是新的绑定? 规范中 for (let ...) 每次迭代都会创建新的环境记录(CreatePerIterationEnvironment),并把上一轮的值复制过来,所以每个回调捕获的都是独立的 i。

3. 闭包和 this 有什么区别? 闭包基于词法作用域,定义时确定;this 是动态绑定,取决于调用方式。箭头函数没有自己的 this,它像访问闭包变量一样从外层词法环境中获取 this。

4. React 的过期闭包怎么产生,怎么解决? 每次渲染都是一次独立的函数调用,回调捕获的是那次渲染的 state。依赖不全时执行的就是旧函数。解决方式:补全依赖(配合 eslint exhaustive-deps)、函数式更新 setCount(c => c + 1)、useRef 存最新值,新版 React 可以用 useEffectEvent 把"读最新值但不触发 effect 重跑"的逻辑抽出来。

5. 怎么排查闭包导致的内存泄漏? 复现操作 → Memory 面板拍堆快照 → 重复操作后再拍 → Comparison 视图看增长对象 → 在 Retainers 中找到 system / Context 或具体闭包函数。常见原因:addEventListener 未移除、setInterval 未清除、无上限的缓存 Map、被闭包引用的脱离文档的 DOM。

6. 手写防抖,说明闭包的作用。

js
function debounce(fn, wait) {
  let timer = null;               // 闭包持有,多次调用共享
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

timer 存在闭包中,每次调用都能访问同一个 timer 并清除上一次的定时器;内层用箭头函数是为了保留外层调用时的 this。


  1. 执行上下文是 JavaScript 执行代码时的运行环境,包含词法环境、变量环境以及当前函数和执行状态等信息。浏览器里可以通过断点查看调用栈和作用域,观察它的部分信息,但不能直接查看一个完整的执行上下文对象。 ↩︎

    1. 广义:函数及其关联的词法环境;普通 JS 函数都具备这种机制。2. 日常讨论中的典型闭包:内层函数被保留下来,外层函数返回后,它仍能访问外层变量,例如上面的 createCounter。
    ↩︎

个人面试题归档,持续更新中