彻底搞懂JavaScript作用域与闭包:V8引擎底层机制 + 3个最容易错的经典题目

96 阅读5分钟

作用域与闭包是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分为两个阶段:

  1. 编译阶段(Ignition解释器)

    • 生成AST
    • 分析作用域(Scope Analysis)
    • 为每个函数生成[[Scopes]]属性,确定词法环境链
  2. 执行阶段(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。

儿子无论被带到哪里(哪怕全局),只要背着这个包,就能随时取出里面的变量。

这才是闭包不被垃圾回收的根本原因!

闭包产生的三个必要条件(必背)

  1. 函数嵌套
  2. 内部函数引用了外部函数的变量(自由变量)
  3. 内部函数在外部可被访问(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底层原理一次性讲透,希望能帮你在下次面试中稳稳拿下这部分。