作用域与闭包是JavaScript最核心也最容易让人迷糊的机制。无数人背过红宝书、看过《你不知道的JavaScript》,但一到面试还是被追问到怀疑人生。
今天我们不玩玄学,直接把V8引擎的真实执行过程扒开给你看,用3个最经典、最容易错的例子,把词法作用域链、执行上下文、闭包产生的全流程讲得清清楚楚。
看完这篇,你将彻底明白“为什么明明调用了foo,bar里却拿不到foo的变量”,以及“函数都执行完了,为什么里面的变量还没被回收”。
经典错题1:到底打印什么?
JavaScript
function bar() {
console.log(myname);
}
function foo() {
var myname = 'foo';
bar();
}
var myname = 'global';
foo();
请问控制台打印什么?
90%的人第一眼答案:'foo'
正确答案:'global'
原因就是——JavaScript 使用的是词法作用域(Lexical Scope),也叫静态作用域。
函数的作用域在定义时就确定了,而不是调用时。
bar函数是在全局下定义的,它的[[Scopes]]里只有全局环境,根本不包含foo的变量环境。所以它往外找myname,只能找到全局的那个。
很多人错把“调用栈顺序”当成“作用域链顺序”,这是最常见的误区。
调用栈决定执行顺序,作用域链决定变量查找路径,二者完全不是一回事。
经典错题2:这次更阴
JavaScript
function bar() {
var myname = "bar";
let test1 = 100;
if (1) {
let myname = "Chrome 浏览器";
console.log(test);
}
}
function foo() {
var myname = "foo";
let test = 2;
{
let test = 3;
bar();
}
}
let test = 1;
foo();
打印什么?
答案:1
不是2,不是3,更不是undefined。
因为bar的词法作用域是全局,查找test时:
bar自己的词法环境 → 全局词法环境 → 找到 test = 1
foo里就算定义了十个test,也跟bar没半毛钱关系。
这就是词法作用域的残酷:你调用我,但我就是不认你。
经典错题3:闭包的终极形态
JavaScript
function foo() {
var myname = '阿宝哥';
let test1 = 1;
const test2 = 2;
var innerBar = {
getName() {
console.log(test1);
return myname;
},
setName(newname) {
myname = newname;
}
}
return innerBar;
}
var bar = foo(); // foo执行完出栈
bar.setName('新名字');
console.log(bar.getName()); // 1 "新名字"
foo都执行完出栈了,按理说它的执行上下文应该被销毁,myname、test1这些变量应该被垃圾回收才对。
为什么bar.getName还能访问到它们?
这就是闭包的真正威力。
V8引擎真实执行过程(核心干货)
V8执行JavaScript分为两个阶段:
-
编译阶段(Ignition解释器)
- 生成AST
- 分析作用域(Scope Analysis)
- 为每个函数生成[[Scopes]]属性,确定词法环境链
-
执行阶段(TurboFan可能接管热点代码)
执行时创建执行上下文(Execution Context),ES6+规范中包含:
- LexicalEnvironment(let/const/class/block)
- VariableEnvironment(var/function)
- ThisBinding
关键来了!
当V8在编译阶段发现某个内部函数引用了外部变量(即“自由变量”),就会标记这个函数会产生闭包。
在真正创建getName、setName函数对象时,V8会额外创建一个 Closure Context(闭包上下文),把它们需要的自由变量(myname、test1)打包存到堆内存中。
函数对象的内部属性[[Environment]]指向这个Closure Context。
于是就形成了传说中的“闭包专属背包”:
foo执行上下文虽然从调用栈弹出销毁了,但它给自己的“儿子”getName和setName留了一个专属背包,背包里装着myname和test1。
儿子无论被带到哪里(哪怕全局),只要背着这个包,就能随时取出里面的变量。
这才是闭包不被垃圾回收的根本原因!
闭包产生的三个必要条件(必背)
- 函数嵌套
- 内部函数引用了外部函数的变量(自由变量)
- 内部函数在外部可被访问(return、赋值给全局变量、作为回调传出等)
三者缺一不可。
经典闭包应用(直接可抄)
JavaScript
function createCounter() {
let count = 0;
return {
inc: () => ++count,
get: () => count
};
}
const c = createCounter();
c.inc(); c.inc();
c.get(); // 2
count永远无法从外部直接访问,完美实现私有变量。
再比如曾经最流行的模块模式:
JavaScript
const Module = (function() {
let secret = '保密';
return {
getSecret: () => secret,
setSecret: v => secret = v
};
})();
防抖、节流、once、bind、柯里化……所有高阶函数的底层都是闭包。
闭包的内存占用与“内存泄漏”
很多人一听到闭包就喊“内存泄漏”,这是片面的。
闭包确实会让变量长期驻留堆内存,但现代V8的Orinoco垃圾回收器非常聪明,只要没有引用就会回收。
真正容易出问题的是:
- 事件监听器未解绑(监听器闭包引用了大量DOM或数据)
- React useEffect依赖写错,导致旧闭包一直存在
- 老项目中大量IIFE模块未清理
面试高分回答模板
面试官:“说说闭包的底层实现?”
答:“V8在编译阶段进行作用域分析,当发现函数引用上层变量时,会为该函数创建闭包上下文(Closure Context),将自由变量打包存于堆中。函数的[[Environment]]指针指向该上下文,即使外层函数执行上下文销毁,内部函数仍可通过该上下文访问变量。这就是闭包的核心机制。”
面试官:“词法作用域和动态作用域区别?”
答:“词法作用域在函数定义时确定,动态作用域在函数调用时确定。JavaScript采用词法作用域(this除外),因此变量查找路径与定义位置相关,而非调用位置。Perl、Bash部分模式使用动态作用域。”
说完这两句,面试官基本就满意了。
总结
JavaScript的作用域是静态的(词法作用域),闭包是V8为了实现这种静态特性,在运行时搞出的“背包机制”。
一旦你真正理解了“闭包专属背包”模型,所有作用域、闭包题目就都迎刃而解。
不再是死记硬背,而是彻底明白“为什么”。
这篇文章把最容易错的3个题 + V8底层原理一次性讲透,希望能帮你在下次面试中稳稳拿下这部分。