把「作用域链」讲透:6 道面试题背后的编译期/执行期 + 一次讲清 JS 垃圾回收(GC)
你会发现:大多数作用域链题,不是在考你“会不会算输出” ,而是在考你能不能把 JavaScript 运行时拆成两句话讲清楚:
-
编译期:声明(
var/function)先“挂上去”(提升),但赋值不提升 - 执行期:读/写变量都沿着作用域链找(找不到时的行为决定了坑有多深)
下面我用一套“固定解题模板”把 6 道题讲到可迁移,然后把 GC 也用同样的思路讲成体系。内容与题目、图片全部保留并强化讲解。
一套通用解题模板:作用域链题别背答案
以后看到任何“打印输出题”,你就按这个模板走,十题九稳:
Step 0:先画作用域(只要三件事)
- 变量/函数分别声明在哪一层作用域
- 函数定义位置(决定它的父级作用域,也就是“词法作用域”)
- 执行时从“当前作用域 → 父级 → … → 全局”查找
Step 1:拆成编译期 & 执行期
-
编译期:
var声明提升,初始值是undefined;函数声明也提升(更强) -
执行期:
- 读变量(RHS) :我要“取值”,沿作用域链找
- 写变量(LHS) :我要“赋值”,也沿作用域链找,找不到会触发“隐式全局”(非严格模式)
你能把“这题是在编译期埋坑,还是执行期沿链找到/找不到导致的”说清楚,基本就过关了。
题 1:函数里改全局变量,为什么立刻生效?
var n = 100
function foo(){
n = 200
}
foo()
console.log(n) // 200
结论:输出 200。
按模板拆解
-
作用域定位:
n声明在全局(GO),foo的父级作用域也是全局。 -
执行期(LHS 赋值) :在
foo内执行n = 200,引擎会沿作用域链找n:当前作用域没有 → 去全局找到n→ 直接改掉全局n。
更“面试官爱听”的一句话
这是一次 LHS 引用:赋值操作会沿作用域链定位到“最近的同名变量”,这里最近的是全局的
n,所以全局被改写。
图示(保留原图)![]()
题 2:同名变量 + var 提升:为什么先 undefined 再正常?
function foo(){
console.log(m)
var m = "吴"
console.log(m);
}
var m = "why"
foo()
// undefined
// 小吴
结论:第一次打印 undefined,第二次打印 "小吴"。
关键点只有一个:遮蔽 + var 提升
-
foo里也声明了var m,它会在 编译期提升到foo作用域顶部,初始为undefined - 因为“就近原则”,
foo内部对m的读写都优先命中 函数自己的m,外层的m="why"被遮蔽了。
等价于:
var m
function foo(){
var m
console.log(m) // undefined(提升后的默认值)
m = "小吴"
console.log(m) // "小吴"
}
m = "why"
foo()
常见追问怎么答
- 问:为啥不是打印外层
"why"?
答:因为foo作用域里存在同名m,查找在命中第一个同名标识符时停止,形成遮蔽。
题 3:父级作用域看“函数写在哪”,不看“在哪调用”
var n = 100
function foo1(){
console.log("这是foo1内部",n);
}
function foo2(){
var n = 200
console.log("这是foo2内部",n);
foo1()
}
foo2()
console.log("这是最外层",n);
结论输出顺序:
-
foo2内部:200 -
foo1内部:100 - 最外层:100
核心原因:词法作用域(lexical scope)
-
foo1定义在全局,它的父级作用域就是全局 - 即使它在
foo2里被调用,它也不会“认foo2当爹”
一句话总结:
作用域链 = 写代码时就确定的链(函数写在哪决定父级作用域),不是运行时调用栈决定的。
题 4:return 前的 var 也会提升:为什么拿不到全局的 a?
var a = 100
function foo(){
console.log(a)
return
var a = 200
}
foo() // undefined
结论:打印 undefined。
为什么这题特别“阴”?
- 很多人以为
return之后代码不执行,所以var a不存在 - 但
var是编译期处理:var a依然会被提升到函数顶部,初始值undefined
等价于:
var a = 100
function foo(){
var a
console.log(a) // undefined(命中的是函数内 a)
return
a = 200
}
foo()
一句话加分
这题不是在考 return,而是在考 “var 提升会提前制造一个同名变量,从而遮蔽外层变量” 。
题 5:var a = b = 10:谁是全局变量?
function foo(){
var a = b = 10
}
foo()
console.log(a);
console.log(b);
结论:a 报错(未定义),b 是 10(非严格模式下)。
拆解(从右往左)var a = b = 10 实际上是:
b = 10 // 注意:没有声明!
var a = b
-
a:被var声明在foo作用域内,函数外拿不到 -
b:没有var/let/const,在非严格模式下会变成隐式全局变量(挂到全局对象上)
面试官很爱追问:严格模式呢?
- 严格模式下:
b = 10会直接抛ReferenceError,因为禁止隐式全局。
补充:隐式全局变量到底有多危险
function foo(){
m = 200
}
foo()
console.log(m); // 200
这段之所以能跑,是因为非严格模式下 m 被“偷偷”挂到全局上了。
真实项目里它会带来:
- 污染全局命名空间(更容易冲突)
- 更难定位数据来源(调试成本爆炸)
- 更容易产生“意外长生命周期对象”(和内存问题强相关)
建议:业务代码默认开启严格模式 / 使用 ESM(天然严格)+ ESLint(no-undef / no-global-assign)。
垃圾回收 GC:从堆/栈到可达性
如果说“作用域链”是在解释变量怎么找,那 GC 就是在解释对象什么时候死。
先记住一句话:
内存有限,所以不再需要的对象必须被回收;关键问题是:GC 怎么判断“你不需要了”?
堆 vs 栈:到底谁存什么?
- 栈(Stack) :放基础类型的值、以及引用类型的“地址/指针”
- 堆(Heap) :放对象实例本体(数组、对象、函数对象等)
文中这部分总结得很清楚:基础类型偏栈,复杂类型偏堆,栈里保存指向堆的引用。
图示(保留原图)![]()
两大核心算法:引用计数 vs 标记清除
1)引用计数(Reference Counting)
思路很直观:对象记录自己被引用了几次(retain count)。
- 引用 +1,断开 -1
- 计数变 0 → 可以回收
致命缺陷:循环引用
var obj1 = {friend:obj2}
var obj2 = {friend:obj1}
两个对象互相引用,计数永远不为 0 → 回收不了 → 内存泄漏。
图示(保留原图):![]()
2)标记清除(Mark-Sweep):JS 最主流的“可达性”路线
V8 等 JS 引擎主流使用“可达性”(Reachability):从“根对象”出发,能走到的对象就是活的。
过程
- 从 root 出发遍历引用图,能到达的标记为“可达”
- 没被标记的就是“不可达” → 清除回收
它为什么能解决循环引用?
- 循环引用本身不重要,重要的是:这坨循环是否还能从 root 到达
- 到不了,就是垃圾,一样清。
图示(保留原图):![]()
V8 为啥更快:分代、增量、闲时、整理
现实世界里,“一次性全量标记清除”会带来 STW(暂停)和碎片问题,所以引擎会做工程级优化:
- 标记整理(Mark-Compact) :回收时把存活对象往一边搬,减少碎片
- 分代收集(Generational) :新对象死得快(新生代频繁收),老对象活得久(老生代低频收)
- 增量收集(Incremental) :把一次长暂停拆成多段小暂停
- 闲时收集(Idle-time) :尽量在 CPU 空闲时做 GC,降低卡顿感
V8 的堆内存分区(保留原图):![]()
面试加分:如何从代码层面避免内存问题
GC 是“清理工”,但你写代码时决定了垃圾是“可达”还是“不可达”。下面这些回答,既能落地又能加分:
- 不要制造意外长生命周期引用
- 全局变量、单例缓存、模块级 Map/Array:如果只增不删,对象就一直可达
- 解决:设计“上限 + 淘汰策略”(LRU / TTL),或者主动
delete/clear
- 事件监听与定时器要能解除
-
addEventListener/setInterval如果不移除,会让回调闭包一直可达 - 解决:组件卸载/页面销毁时
removeEventListener、clearInterval
- 避免隐式全局
-
m = 200这种写法会把对象挂到全局,生命周期直接拉满 - 解决:严格模式 + ESLint
- 理解“可达性”的调试方式
-
当你怀疑泄漏:不是问“GC 为什么不回收”,而是问
“是谁还在引用它?从 root 到它的引用链是什么?”
结尾:把知识变成“可迁移能力”
你会发现,题目怎么变都逃不掉这两条主线:
- 作用域链题:编译期提升 + 执行期沿链查找(读/写)
- 内存题:对象是否还可达(谁还在引用它)
后续如果你要接着写“闭包”那一章,这篇其实已经把最关键的地基铺好了:闭包的本质,就是“让某些变量在函数执行完后依然可达”,从而延长它的生命周期。