Appearance
作用域、作用域链与闭包
难度等级:🟢 初级 分类:JavaScript 归档日期:2026-10-09
什么是作用域、作用域链?聊一聊什么是闭包,你对闭包的理解。
难度与考察点
难度: 基础题,但三个概念是层层递进的:作用域决定变量的可见范围 → 作用域链决定变量怎么查找 → 闭包是作用域链在函数"逃逸"后依然生效的结果。
面试官希望看到:
- 能把三者串成一条线讲,而不是分别背三个定义
- 能从规范的词法环境 / 环境记录层面解释,而不只是"里面能访问外面"
- 了解
var / let / const的作用域差异、TDZ、V8 中闭包变量的存储 - 有真实工程经验: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没有块级作用域,存在变量提升,提升后初始值为undefinedlet/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。