一段 JavaScript 代码执行时,到底发生了什么
本文是「JavaScript 运行现场」系列的第 1 篇。
这个系列不打算把 JavaScript 拆成一堆需要背诵的结论,而是从一段真实代码出发,看看它到底是怎么运行起来的。
先看一段很普通的代码:
js
const globalName = 'global'
function outer() {
const outerName = 'outer'
function inner() {
console.log(globalName, outerName)
}
inner()
}
outer()
运行结果没有任何意外:
text
global outer
如果只看结果,这段代码几乎没什么好讲的。
但换一个问题:
当 JavaScript 执行到
outer()时,内部到底发生了什么?
是先找到函数,再把函数里的代码全部读一遍吗?
inner() 被调用时,为什么能找到 outerName?
函数执行结束后,里面创建的变量又去了哪里?
这些问题表面上分散,背后其实都绕不开两个东西:
- 执行上下文;
- 调用栈。
它们听起来有点像面试术语,但并不神秘。只要顺着代码执行一遍,就能看出它们到底在干什么。
一、JavaScript 不是从第一行直接"莽"到最后一行
很多人第一次理解 JavaScript 执行过程时,会把它想成:
text
读第一行
→ 执行第一行
→ 读第二行
→ 执行第二行
实际过程没有这么简单。
JavaScript 引擎拿到一段代码后,会先做一些准备工作,例如:
- 解析代码;
- 确认语法是否合法;
- 识别变量和函数声明;
- 为即将执行的代码准备运行环境。
准备完成后,代码才会真正进入执行阶段。
对于我们这段代码来说,引擎首先面对的是全局代码:
js
const globalName = 'global'
function outer() {
// ...
}
outer()
在执行全局代码前,JavaScript 会创建一个对应的运行环境。
这个环境就叫:
全局执行上下文。
可以先把它理解成:
JavaScript 执行一段代码时,为这段代码准备的一间工作室。
这间工作室里会保存:
- 当前有哪些变量;
- 当前有哪些函数;
- 变量应该去哪里查找;
this在当前环境中是什么;- 代码执行到什么位置。
只要一段可执行代码开始运行,就需要一个对应的执行上下文。
全局代码有全局执行上下文。
函数每调用一次,也会创建一次新的函数执行上下文。
注意,是"每调用一次",不是"每定义一次"。
这两个概念很容易混在一起。
js
function sayHello() {
console.log('hello')
}
这只是定义了一个函数。
只有真正执行:
js
sayHello()
JavaScript 才会为这次调用创建新的函数执行上下文。
如果调用三次:
js
sayHello()
sayHello()
sayHello()
就会先后创建三次函数执行上下文。
函数代码是同一份,但每次调用时使用的参数、局部变量和执行位置都可以不同。
为了把这个"运行环境"看得更直观,可以先看一下全局代码和函数调用分别会创建什么。

二、全局代码开始执行时,发生了什么
回到最开始的代码:
js
const globalName = 'global'
function outer() {
const outerName = 'outer'
function inner() {
console.log(globalName, outerName)
}
inner()
}
outer()
JavaScript 准备执行这段脚本时,首先创建全局执行上下文。
可以先简化成这样:
text
全局执行上下文
变量:
globalName
函数:
outer
这里不需要把它理解成一个真实存在的 JavaScript 对象。
它更像引擎内部用来管理当前代码的一套记录。
接下来,代码真正开始执行:
js
const globalName = 'global'
globalName 被初始化为字符串 'global'。
然后继续往下。
函数声明:
js
function outer() {
// ...
}
在准备阶段已经被识别,所以这里不会再次调用它,只是继续向后执行。
直到遇到:
js
outer()
事情才开始变得有意思。
JavaScript 发现这里发生了一次函数调用,于是暂停当前全局代码的执行,为 outer() 创建新的函数执行上下文。
这时,内存中可以理解为同时存在两个运行环境:
text
全局执行上下文
outer 函数执行上下文
但问题来了:
JavaScript 怎么知道现在应该执行哪一个?
总不能两个上下文一起往前跑。
这就需要调用栈。
三、调用栈决定"现在轮到谁执行"
调用栈可以理解成 JavaScript 用来管理函数调用顺序的一摞盘子。
后放上去的盘子,先拿下来。
这就是典型的:
后进先出。
当一段脚本开始运行时,全局执行上下文会先进入调用栈:
text
栈顶
┌────────────────┐
│ 全局执行上下文 │
└────────────────┘
执行到:
js
outer()
outer 的函数执行上下文被压入调用栈:
text
栈顶
┌──────────────────┐
│ outer 执行上下文 │
├──────────────────┤
│ 全局执行上下文 │
└──────────────────┘
此时栈顶是 outer,所以 JavaScript 暂停全局代码,开始执行 outer 内部的代码。
js
const outerName = 'outer'
局部变量 outerName 被创建并初始化。
接着定义 inner,然后执行:
js
inner()
inner 被调用,JavaScript 又为这次调用创建新的执行上下文,并压入调用栈:
text
栈顶
┌──────────────────┐
│ inner 执行上下文 │
├──────────────────┤
│ outer 执行上下文 │
├──────────────────┤
│ 全局执行上下文 │
└──────────────────┘
现在轮到 inner 执行。
它只有一行代码:
js
console.log(globalName, outerName)
执行完成后,inner 的执行上下文从调用栈弹出:
text
栈顶
┌──────────────────┐
│ outer 执行上下文 │
├──────────────────┤
│ 全局执行上下文 │
└──────────────────┘
outer 后面没有其他代码了,于是它也执行结束,从调用栈弹出:
text
栈顶
┌────────────────┐
│ 全局执行上下文 │
└────────────────┘
最后,全局代码执行完成。
整个调用顺序可以写成:
text
全局代码开始
→ 调用 outer
→ 调用 inner
→ inner 执行结束
→ 回到 outer
→ outer 执行结束
→ 回到全局
这就是调用栈最核心的作用:
记录当前执行到哪里,以及函数结束后应该回到哪里。
平时在浏览器控制台中看到的错误调用信息,本质上就是调用栈留下的路径。
例如:
text
at inner
at outer
at main
它其实在告诉你:
main调用了outer,outer又调用了inner,最后错误发生在inner。
当 outer 和 inner 依次被调用时,调用栈里的变化大致如下。

四、inner 为什么能找到 outerName
现在还有一个问题没有解决:
js
function outer() {
const outerName = 'outer'
function inner() {
console.log(outerName)
}
inner()
}
outerName 明明是在 outer 里声明的。
inner 自己的执行上下文里并没有这个变量。
那它为什么还能正常访问?
JavaScript 查找变量时,不会只看当前执行上下文。
它会先找当前作用域,如果没有,再沿着外层词法作用域继续查找。
可以把 inner 查找 outerName 的过程理解成:
text
inner 自己有 outerName 吗?
没有
↓
outer 作用域里有吗?
有
↓
使用 outerName
查找 globalName 时则是:
text
inner 自己有 globalName 吗?
没有
↓
outer 作用域里有吗?
没有
↓
全局作用域里有吗?
有
↓
使用 globalName
所以最开始的代码能输出:
text
global outer
关键在于:
函数能访问哪些外部变量,在函数定义的位置就已经基本决定了。
这叫词法作用域。
看下面这个例子:
js
const name = 'global'
function printName() {
console.log(name)
}
function run() {
const name = 'run'
printName()
}
run()
很多人第一次看,会猜输出:
text
run
因为 printName() 是在 run() 里面调用的。
实际输出却是:
text
global
原因是 printName 定义在全局作用域。
它查找 name 时,会沿着自己定义时所在的外层作用域查找,而不是看"谁调用了我"。
也就是说,JavaScript 更关心:
这个函数写在哪里?
而不是:
这个函数从哪里被调用?
这也是作用域链、闭包和很多变量查找问题的基础。
不过 this 走的是另一套规则,不能和词法作用域混为一谈。后面的专题会单独讲。
五、函数执行结束后,局部变量去哪了
inner 执行结束后,它的执行上下文会从调用栈弹出。
outer 执行结束后也是一样。
那么:
js
const outerName = 'outer'
这个局部变量去哪了?
一般情况下,如果函数执行结束后,再也没有其他地方需要访问这些变量,它们之后就有机会被垃圾回收。
注意,这里说的是"有机会",不是函数一结束,内存立刻当场清空。
垃圾回收由 JavaScript 引擎管理,什么时候真正回收,不是代码逐行控制的。
但从程序逻辑上看,outer 执行完以后,外部也访问不到 outerName:
js
outer()
console.log(outerName) // ReferenceError
因为它只属于 outer 的局部作用域。
不过我们稍微改一下代码:
js
function outer() {
const outerName = 'outer'
return function inner() {
console.log(outerName)
}
}
const fn = outer()
fn()
这次 outer() 明明已经执行结束了,后来调用 fn() 时,却仍然能输出:
text
outer
为什么?
因为返回出去的 inner 仍然需要访问 outerName。
只要这个内部函数还存在,对应的外层变量就不能简单丢掉。
这就是闭包会出现的地方。
但这里先不展开。
这一篇只需要记住:
执行上下文出栈,表示这次函数调用结束了;但相关变量是否能被回收,还要看后面是否仍然存在引用。
调用栈管理的是执行顺序。
垃圾回收管理的是内存是否还能被访问。
两者有关联,但不是同一件事。
六、调用栈为什么会溢出
既然函数调用会不断把执行上下文压入栈中,那么如果函数一直调用下去,会发生什么?
看一段没有出口的递归:
js
function loop() {
loop()
}
loop()
执行过程会变成:
text
全局执行上下文
loop 第 1 次调用
loop 第 2 次调用
loop 第 3 次调用
loop 第 4 次调用
......
每次 loop() 都还没结束,就又调用了一个新的 loop()。
旧的执行上下文无法弹出,新的执行上下文还在不断压入。
调用栈空间不是无限的。
达到上限后,浏览器通常会报类似错误:
text
RangeError: Maximum call stack size exceeded
这就是栈溢出。
正常递归为什么不会溢出?
因为它有结束条件。
js
function countdown(n) {
if (n <= 0) {
return
}
console.log(n)
countdown(n - 1)
}
countdown(3)
调用过程大致是:
text
countdown(3)
→ countdown(2)
→ countdown(1)
→ countdown(0)
执行到 countdown(0) 时直接返回。
随后调用栈开始逐层弹出:
text
countdown(0) 结束
→ 回到 countdown(1)
→ 回到 countdown(2)
→ 回到 countdown(3)
所以写递归时,真正重要的不只是"函数调用自己",而是:
- 每次调用是否更接近结束条件;
- 是否存在明确出口;
- 调用深度是否可能过大。
这也是为什么处理特别深的树结构时,有些场景会改用显式栈和循环,而不是直接递归。
递归是否安全,关键不在于函数有没有调用自己,而在于调用栈能不能正常退回来。

小结
现在再看最开始的代码:
js
const globalName = 'global'
function outer() {
const outerName = 'outer'
function inner() {
console.log(globalName, outerName)
}
inner()
}
outer()
它的执行过程可以概括成:
- JavaScript 为整段脚本创建全局执行上下文;
- 全局执行上下文进入调用栈;
- 调用
outer(),创建并压入outer的执行上下文; - 调用
inner(),创建并压入inner的执行上下文; inner沿着词法作用域向外查找变量;inner执行结束并出栈;outer执行结束并出栈;- 全局代码执行完成。
这一篇最重要的不是记住"执行上下文"的定义,而是建立一个基本画面:
JavaScript 每执行一段代码,都需要对应的运行环境;函数调用时,这些运行环境会进入调用栈,执行结束后再依次退出。
后面我们要讲的很多内容,都建立在这张图上:
- 变量提升和暂时性死区,发生在执行上下文准备过程中;
- 作用域链决定变量去哪里找;
- 闭包解释函数结束后,为什么某些变量还在;
- 事件循环解释调用栈清空后,异步任务什么时候回来;
- 栈溢出则是调用上下文只进不出造成的结果。
下一篇准备从两段非常像的代码开始:
js
console.log(a)
var a = 1
和:
js
console.log(b)
let b = 1
一个输出 undefined,一个直接报错。
到时候再看看,所谓"变量提升",到底提升了什么。