JavaScript 作用域链与闭包:从运行原理到内存管理

0 阅读5分钟

一、引言

你是否遇到过这样的困惑:为什么一个函数内部可以“越过自己的领地”,访问到外部的变量?而当一个函数执行完毕后,本该被销毁的变量,有时却依然“活着”,甚至能被另一个函数使用?这两个看似矛盾的现象,背后正是 JavaScript 作用域链与闭包在起作用。本文将带大家从原理到代码实践,彻底掌握这对既熟悉又陌生的核心概念。

二、作用域链

作用域链的定义

首先,让云宝来给大家简单解释一下究竟什么是作用域链:

每一个执行上下文的变量环境中都存在一个outer指针,用来指向外部的执行上下文,当V8在查找一个变量时,在当前执行上下文中没有找到,就会顺着outer所指向的执行上下文查找,以此类推,直到找到全局为止,我们把这个查找的链条叫做作用域链。

作用域链的“演示”

举个“栗子”:

let global = "全局";
function outer() {
    let outerVar = "外";
    function inner() {
        let innerVar = "内";
        console.log(innerVar, outerVar, global);-------------输出结果:内 外 全局
    }
    inner();
}
outer();
屏幕截图 2026-05-23 230043.png

其实function属于引用变量,应该存放在堆里,调用栈中存放其地址,这里云宝偷个懒~

三、闭包

闭包的定义

很多小伙伴在这里就会有疑惑了,究竟什么是闭包呢?闭包到底是怎么产生的呢?我来给大家简单解释一下:

  1. 当一个函数执行完毕之后,他的执行上下文会被销毁
  2. 根据作用域的查找规则,内部函数一定可以访问外部函数中的变量,当一个外部函数中的内部函数被拿到外部函数之外来执行,哪怕外部函数执行完毕,被内部函数引用的那部分变量依然需要被保留,我们把这部分变量的集合称之为闭包

就比如下面这种情况:

function foo(){
    var myName = '云宝'
    var age = 18
    function bar(){
        console.log(myName)
    }
    return bar
}

var baz = foo()
baz()

执行过程分解

  1. 全局预编译与执行
  • 全局预编译:找到 foo 函数声明,提升;找到 baz 变量声明(var baz),初始为 undefined

  • 执行全局代码:

    • var baz = foo():调用 foo 函数。
    • baz():调用 baz(即 foo 返回的 bar 函数)。
  1. 调用 foo()
  • 创建 foo 的执行上下文,压入调用栈。

  • 在 foo 的预编译阶段:

    • 创建 AO(Activation Object):AO = {}
    • 找形参(无)、变量声明:myNameage → AO.myName = undefinedAO.age = undefined
    • 找函数声明:bar → AO.bar = function bar() {...}
  • 执行 foo 的函数体:

    • var myName = '云宝' → AO.myName = '云宝'
    • var age = 18 → AO.age = 18
    • return bar → 返回 bar 函数的引用(即 AO.bar)。
  • foo 执行完毕,按理说其执行上下文应从调用栈弹出并销毁。因为返回的 bar 函数使用了 foo 内部的变量 myName,V8 会把 bar 引用的这部分变量(myName 以及可能的 age)保留在堆中,形成一个 闭包(Closure)

  • foo 的执行上下文被销毁,但闭包对象依然存在,其中包含 myName: 'zls'(因为 bar 用到了 myName, age 未被引用,可以被回收删除)。

  1. 执行 baz()(即 bar 函数)
  • 此时便可以在闭包中访问到理应被清除的变量

闭包的优缺点

  • 优点:定义私有模块,防止全局变量被污染

    这样我们就可以在多人共同编写大型程序时,将变量封装在闭包内,避免全局变量过多堆成一坨

  • 缺点:内存泄漏

    闭包会一直保留被引用的变量,导致这些变量无法被正常垃圾回收。 如果滥用闭包,尤其在大数据量或高频创建的场景下,会造成内存膨胀。

四、经典面试题与总结

现在让我们活学活用,来看下面这道经典面试题目

var arr = []
for (var i = 1; i <= 5; i++) {
    arr.push(function() {
        console.log(i);
    })
}

for (let n = 0; n < arr.length; n++) {
    arr[n]()
}

输出为五次6,这是因为:

  1. var i 没有块级作用域
    循环中 var i 属于全局(或函数作用域),整个循环共用同一个 i
  2. 每次循环 arr.push(function() { console.log(i); }) 只是把函数放进数组,函数内部引用的 i 是同一个变量
  3. 循环结束后 i 的值是 6
    当 i <= 5 不成立时,i 最后一次自增变成了 6
  4. 调用函数时读取 i
    所有函数执行时,取到的都是已经变成 6 的 i,所以打印五次 6

那么如何将其输出1,2,3,4,5呢? 很显然便是用闭包来做到, 我们便可以利用好js作用域的查找规则,我们将代码改为:

var arr = []
for (var i = 1; i <= 5; i++) {
    function fn(j){
        arr.push(function() {
    console.log(j);
  })
    }
    fn(i)
}

for (let n = 0; n < arr.length; n++) {
  arr[n]() 
}

这样,fn每次调用时都会创建一个新的闭包,因为调用时fn已经编译完毕,其函数调用变量AO已经销毁,因此最后打印时都会读取到各自的闭包内的数值

最后小结一下!

  • 作用域链是变量查找的机制,闭包是这种机制带来的特殊现象
  • 闭包既有优点(封装、模块化),也有缺点(内存泄漏风险)。
  • 理解闭包的核心在于:函数定义时的词法作用域 + 函数被外部引用后导致变量存活
  • 我们要合理使用闭包,并在不需要时手动解除引用,写出更高效、更健壮的 JavaScript 代码。

学习结束!