一段 JavaScript 代码执行时,到底发生了什么

一段 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 调用了 outerouter 又调用了 inner,最后错误发生在 inner

outerinner 依次被调用时,调用栈里的变化大致如下。


四、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()

它的执行过程可以概括成:

  1. JavaScript 为整段脚本创建全局执行上下文;
  2. 全局执行上下文进入调用栈;
  3. 调用 outer(),创建并压入 outer 的执行上下文;
  4. 调用 inner(),创建并压入 inner 的执行上下文;
  5. inner 沿着词法作用域向外查找变量;
  6. inner 执行结束并出栈;
  7. outer 执行结束并出栈;
  8. 全局代码执行完成。

这一篇最重要的不是记住"执行上下文"的定义,而是建立一个基本画面:

JavaScript 每执行一段代码,都需要对应的运行环境;函数调用时,这些运行环境会进入调用栈,执行结束后再依次退出。

后面我们要讲的很多内容,都建立在这张图上:

  • 变量提升和暂时性死区,发生在执行上下文准备过程中;
  • 作用域链决定变量去哪里找;
  • 闭包解释函数结束后,为什么某些变量还在;
  • 事件循环解释调用栈清空后,异步任务什么时候回来;
  • 栈溢出则是调用上下文只进不出造成的结果。

下一篇准备从两段非常像的代码开始:

js 复制代码
console.log(a)
var a = 1

和:

js 复制代码
console.log(b)
let b = 1

一个输出 undefined,一个直接报错。

到时候再看看,所谓"变量提升",到底提升了什么。

相关推荐
智能起源1 小时前
关于元素层级过多,导致压祯的问题(使用winform或者WPF应用的webview组件嵌套网页时,动效卡顿问题)
前端
Old Uncle Tom2 小时前
银行用户画像 -- 金融目标与需求意图
前端·人工智能·金融
IT_陈寒2 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
东方小月2 小时前
从0开发一个 Coding Agent(一):前言
前端·人工智能·typescript
恋猫de小郭2 小时前
Flutter 全新真 3D 实现,用 flutter_scene 能开发一个「我的世界」
android·前端·flutter
会周易的程序员2 小时前
Mermaid Renderer:一款的图表可视化小工具
前端·vue.js·流程图·mermaid
ji_shuke2 小时前
远程排查 Web 系统问题:如何导出 HAR 文件协助定位
前端·问题排查
程序员爱钓鱼3 小时前
Rust String 与 &str 详解:字符串所有权、借用与转换
前端·后端·rust