JavaScript 词法作用域、作用域链与闭包:从代码看机制

在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为"变量在哪调用,就在哪找",但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置 决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。


一、什么是执行上下文?调用栈是如何工作的?

在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:

  • 变量环境(Variable Environment) :存储用 var 声明的变量和函数声明。
  • 词法环境(Lexical Environment) :存储用 let、const 声明的块级作用域变量。

此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。

✅ 简单说:outer 指针决定了作用域查找路径,即"作用域链"

我们来看第一个例子(1.js):

javascript 复制代码
function bar(){
  console.log(myName)
}

function foo(){
  var myName = '极客邦'
  bar()
}

var myName = '极客时间'
foo() // 输出: 极客时间

🤔 为什么输出的是 "极客时间" 而不是 "极客邦"?

很多人会误以为:bar() 是在 foo() 内部调用的,那它应该能访问到 foo() 里的 myName。但实际上,bar() 是在全局定义的 ,所以它的 outer 指向的是全局的词法环境。

当 bar() 执行时,它先在自己的词法环境中找 myName,没有;然后顺着 outer 指针去全局词法环境查找,找到了 var myName = '极客时间',于是打印出来。

👉 结论 :作用域是由函数定义的位置决定的,而不是调用位置。这就是 词法作用域 的核心思想。


二、作用域链:查找变量的"路径"

作用域链就是由一个个执行上下文的 outer 指针串联而成的链条。我们可以通过以下代码进一步理解(2.js):

ini 复制代码
function bar(){
  var myName = '极客世界'
  let test1 = 100
  if(1){
    let myName = 'Chrome 浏览器'
    console.log(test) // ❌ 报错:test is not defined
  }
}

function foo(){
  var myName = '极客邦'
  let test = 2
  {
    let test = 3
    bar()
  }
}

var myName = '极客时间'

let myAge = 10

let test = 1

foo()

这段代码中,bar() 函数内部试图打印 test,但它找不到。

🔍 查找过程如下:

  1. 在 bar() 的词法环境中找 test → 没有;
  2. 到 bar() 的 outer 指向的词法环境(全局)找 → 全局有 let test = 1,但注意!bar() 是在全局定义的,所以它只能访问全局的 test;
  3. 但是 bar() 执行时,test 被重新赋值了吗?没有 ,因为 bar() 并不在 foo() 内部定义,所以它不会继承 foo() 的作用域。

因此,console.log(test) 实际上是在全局查找 test,结果是 1。

⚠️ 注意:bar() 无法访问 foo() 中的 test,即使它是从 foo() 中调用的。这再次证明了:JS 是词法作用域,不是动态作用域。

我们可以结合下面这张图来理解调用栈和作用域链的关系:

  • bar() 的执行上下文在栈顶;
  • 它的 outer 指向全局;
  • 因此查找 test 时,直接跳到了全局词法环境。

三、闭包:函数的"专属背包"

接下来是最有意思的------闭包(Closure)。

闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕。

我们来看第三个例子(3.js):

javascript 复制代码
function foo(){
  var myName = '极客时间'
  let test1 = 1
  const test2 = 2
  var innerBar = {
    getName: function(){
      console.log(test1)
      return myName
    },
    setName: function(newName){
      myName = newName
    }
  }
  return innerBar
}

var bar = foo() // foo 执行完毕,出栈
//它已经出栈了 那bar里面的变量应该回收吧?
//代码的执行证明 它不会回收
//foo函数确实是出栈了 但是getName/setName还需要foo()函数里面的变量 所以它会'打个包' (如果一个变量被引用的话 那么它们就不能顺利的进行垃圾回收)
bar.setName('极客邦')
bar.getName() // 输出: 极客邦

🤯 为什么 foo() 已经出栈了,还能修改和读取里面的变量?

因为在 foo() 返回 innerBar 对象时,getName 和 setName 这两个方法都引用了 foo() 内部的变量 myName 和 test1。V8 引擎发现这些变量被"外部引用"了,就不会回收它们。

于是,foo() 的执行上下文虽然出栈了,但它的 词法环境被保留了下来,形成了一个"闭包"。

💡 这个被保留下来的词法环境,就是闭包本身。而其中被引用的变量,叫做 自由变量。

我们再看一张图:

  • setName 执行时,它的 outer 指向 foo() 的词法环境;
  • 即使 foo() 已经执行结束,这个环境依然存在;
  • 所以 myName 可以被修改为 '极客邦';
  • 后续调用 getName() 时,依然能拿到更新后的值。

✅ 闭包的形成条件:

  1. 函数嵌套函数;
  2. 内部函数被返回或暴露到外部;
  3. 内部函数引用了外部函数的变量。

四、闭包的生命周期:什么时候释放?

闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。

比如:

ini 复制代码
var bar = foo()
bar = null // 此时,bar 不再引用 innerBar,闭包可以被垃圾回收

一旦 bar 被置为 null,getName 和 setName 就不再被引用,V8 引擎就会回收 foo() 的词法环境,释放内存。

🔒 闭包是一种"记忆"机制,但也会带来内存泄漏的风险。使用完后记得释放引用!


五、总结:词法作用域 vs 动态作用域

特性 词法作用域(JavaScript) 动态作用域
查找依据 函数定义的位置 函数调用的位置
是否依赖调用栈顺序 否 是
示例语言 JavaScript、Python、C++ Bash、一些脚本语言

JavaScript 是典型的词法作用域语言,这意味着:

  • 函数的 outer 指针在编译阶段就确定;
  • 不管你在哪调用,只要函数定义在全局,它的 outer 就指向全局;
  • 闭包的存在正是基于这种静态作用域的特性。

六、常见误区澄清

❌ 误区一:"在哪个函数里调用,就查哪个函数的作用域"

这是动态作用域的思维。JavaScript 不是这样工作的。

✅ 正确做法:看函数定义在哪 ,outer 指向哪里,就从哪里开始查。

❌ 误区二:"函数执行完,里面的变量就没了"

不一定!如果函数返回了一个引用了内部变量的函数,那么这些变量会被保留,形成闭包。

❌ 误区三:"闭包就是匿名函数"

不对。闭包是一种现象,不一定是匿名函数。只要满足条件,任何函数都可以形成闭包。


七、图解回顾

我们再来快速回顾一下几张关键图:

图1:bar() 调用时的作用域链

  • bar() 的 outer 指向全局;
  • 查找 test 时,从全局找到 test = 1。

图2:foo() 执行时的执行上下文

  • foo() 的词法环境包含 test1, test2;
  • 变量环境包含 myName, innerBar。

图3:闭包生效时的状态

  • setName 执行时,outer 指向 foo() 的词法环境;
  • 即使 foo() 已出栈,数据依然可访问。

八、写在最后

JavaScript 的作用域机制看似复杂,但只要抓住一个核心:词法作用域 + outer 指针 + 闭包,就能轻松应对大多数场景。

记住一句话:

函数的作用域由它定义的位置决定,而不是调用的位置。

当你看到一个函数在别处被调用时,不要慌,先问一句:"它是在哪定义的?" 然后顺着 outer 指针去找,一切就清晰了。

闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。

希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!


📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!

相关推荐
lerhxx4 小时前
AI 应用中的上下文管理 —— 从"上下文窗口"到"分层压缩"的工程实践
前端·javascript
骑着蜗牛撵大象3274 小时前
多 Agent 分治协作:MapReduce 模式下的并行扇出与结果归并
前端
用户55318297324 小时前
Flutter iOS 热更新深入 Dart VM:读懂函数入口、解释执行与调用桥接
前端
前端snow4 小时前
为什么大厂要用Postgres + mogodb的框架?
前端
运维有小邓15 小时前
2026年主流AD域管理软件有哪些?
前端
Mahut5 小时前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品
Mickey同学5 小时前
提示词注入:AI 时代的「SQL 注入」
前端
deli0075 小时前
GROUP BY 先别想当然:我把 SQL 分组语义做成了沙盘,8 个实验 + 27 条自检全绿
前端
WayneX5 小时前
开源 Vue 3 组件库 Morya UI:把组件、文档、AI 工具链一起做进一个包
前端·vue.js·前端框架
用户15741568165345 小时前
页面白屏 + Invalid prop: type check failed for prop "options"?原来是 script setup 的 ref
前端