JavaScript 词法作用域、作用域链与闭包:从代码看机制
在学习 JavaScript 的过程中,作用域 是一个绕不开的核心概念。很多人一开始会误以为“变量在哪调用,就在哪找”,但其实 JS 的作用域是 词法作用域(Lexical Scoping) ,也就是说,函数的作用域由它定义的位置决定,而不是调用位置。今天我们就通过几段简单的代码和图解,来深入浅出地理解 JavaScript 中的 词法作用域、作用域链 和 闭包 这三个重要机制。
一、什么是执行上下文?调用栈是如何工作的?
在 JavaScript 中,每当一个函数被调用时,都会创建一个「执行上下文」(Execution Context),并压入调用栈(Call Stack)。这个执行上下文包含两个关键部分:
-
变量环境(Variable Environment) :存储用
var声明的变量和函数声明。 -
词法环境(Lexical Environment) :存储用
let、const声明的块级作用域变量。
此外,每个执行上下文的词法环境中还有一个特殊的属性:outer,它指向该函数定义时所在的作用域的词法环境。
✅ 简单说:
outer指针决定了作用域查找路径,即“作用域链”
我们来看第一个例子(1.js):
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):
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,但它找不到。
🔍 查找过程如下:
- 在
bar()的词法环境中找test→ 没有; - 到
bar()的outer指向的词法环境(全局)找 → 全局有let test = 1,但注意!bar()是在全局定义的,所以它只能访问全局的test; - 但是
bar()执行时,test被重新赋值了吗?没有,因为bar()并不在foo()内部定义,所以它不会继承foo()的作用域。
因此,console.log(test) 实际上是在全局查找 test,结果是 1。
⚠️ 注意:
bar()无法访问foo()中的test,即使它是从foo()中调用的。这再次证明了:JS 是词法作用域,不是动态作用域。
我们可以结合下面这张图来理解调用栈和作用域链的关系:
![]()
-
bar()的执行上下文在栈顶; - 它的
outer指向全局; - 因此查找
test时,直接跳到了全局词法环境。
三、闭包:函数的“专属背包”
接下来是最有意思的——闭包(Closure)。
闭包的本质是:一个函数能够访问并记住其外部函数的变量,即使外部函数已经执行完毕。
我们来看第三个例子(3.js):
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()时,依然能拿到更新后的值。
✅ 闭包的形成条件:
- 函数嵌套函数;
- 内部函数被返回或暴露到外部;
- 内部函数引用了外部函数的变量。
四、闭包的生命周期:什么时候释放?
闭包并不会一直占用内存。只有当外部仍然持有对闭包函数的引用时,闭包才会被保留。
比如:
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 指针去找,一切就清晰了。
闭包虽然强大,但也需要谨慎使用,避免不必要的内存占用。
希望这篇文章能帮你理清思路,下次遇到作用域问题时,不再迷茫!
📌 附注:本文所用代码和图解均来自个人学习笔记,图片仅为示意,实际运行时请自行验证逻辑。欢迎在评论区交流你的理解!