Skip to content

执行上下文:把作用域、调用栈与宿主接起来 ​

前面已经分别学过闭包、this 和事件循环。现在把它们放到同一张图上:代码在哪里找变量、当前执行到哪里、下一段工作何时开始,是三个不同的问题。 把三者都叫作“上下文”,讨论起来就像三个人共用一个名字。

本篇使用 Node 的 .mjs 文件。浏览器例子会单独说明;前置内容是第二章的作用域与闭包、第五章的异步控制和第六章的生成器。下面分别使用它们解释变量查找、暂停和恢复。

程序开始执行前,已经发生了什么 ​

保存以下内容为 parse-failure.mjs,单独运行:

js
console.log('准备出发')
const amount = ;

结果是 SyntaxError,第一行也不会打印。引擎先解析这个源文件;它不是每读到一行就无条件执行一行。语法检查无法通过,这次模块求值便没有开始。

再把第二行改成 missingName(),第一行就能打印,随后才发生 ReferenceError。语法错误与运行时错误的发生阶段不同。另一个模块已经产生的副作用,也不会因为你随后加载的模块解析失败而被撤销。

“JavaScript 到底是解释型还是编译型”经常引发口水战。对写代码更有用的结论是:规范规定可观察行为,并不要求厂商必须使用某种解释器或 JIT 架构。我们可以讨论具体引擎如何解析、编译和优化,不能把某个引擎的流水线变成所有实现的语言定义。

声明的准备与赋值的执行是两件事 ​

解析成功也不表示所有变量已经有了最终值。代码进入某个作用域时,要先安排这个作用域中的声明;执行到初始化表达式时,才计算表达式并把结果交给对应绑定。第二章的“提升”在这里可以拆成可观察的步骤,而不是想象引擎把几行源代码搬到了文件开头。

js
function inspect() {
  console.log(typeof declared) // function
  console.log(assigned)        // undefined
  try {
    console.log(lexical)
  } catch (error) {
    console.log(error.name)    // ReferenceError
  }
  function declared() { return 6 }
  var assigned = declared()
  let lexical = assigned + 1
  console.log(assigned, lexical) // 6 7
}
inspect()

对这次普通函数调用,函数声明的绑定在函数体开始运行前已经能取得函数对象;var assigned 已被初始化为 undefined,但 declared() 还没执行;let lexical 的绑定已经存在,却还未初始化。读它时发生异常,不能跳过它去找某个外层同名变量。

当执行流走到 var assigned = declared(),调用才产生 6。随后 let lexical = ... 求值并完成初始化。这里有三种状态:没有找到绑定、找到了但未初始化、已经初始化。前两种都可能表现为 ReferenceError,但原因不同,修复方式也不同。

typeof 也能帮助区分它们:对普通未声明名称求 typeof 会得到字符串 "undefined";对暂时性死区里的词法绑定做同样操作仍然会抛错。这个例外说明,判断语义不能只从最后的值出发,还必须知道名称解析找到了什么。

调用栈记录进度,环境记录变量绑定 ​

js
function makeScale(factor) {
  return function scale(value) {
    return value * factor
  }
}
const triple = makeScale(3)
function report() {
  const factor = 100
  return triple(7)
}
console.log(report()) // 21

调用 makeScale(3) 时,形参 factor 获得一个绑定。创建 scale 函数时,它保存了与当时词法环境的联系。makeScale 返回后,函数的这条联系依然可用。

调用 report() 后再调用 triple(7),活动调用关系是下面这样:

text
调用栈(谁正在执行)       变量查找(从哪里找 factor)
scale                    scale 本次调用的环境
report                        ↓
模块代码                 makeScale(3) 创建的环境
                              ↓
                         模块的外层环境

report 的 factor = 100 不在右侧这条链上。调用者在谁的上面,不决定被调用者能访问谁的局部变量。这是词法作用域与调用栈的根本区别。

ECMAScript 用 Environment Record(环境记录) 描述名称与绑定的关系,用外层联系描述嵌套。它们是规范工具,程序不能直接拿到一个环境记录对象。教学图里的方框也不保证一一对应堆中的某个实体,引擎可以优化表示,只要行为不变。

同一段函数代码,并不意味着同一份绑定 ​

把上一例的“函数保留了外层联系”再推进一步:联系指向哪一次调用?

js
function makeMeter() {
  let total = 0
  return {
    add() { total += 1 },
    read() { return total }
  }
}
const left = makeMeter()
const right = makeMeter()
left.add()
left.add()
console.log(left.read(), right.read()) // 2 0

left.add 和 left.read 创建于同一次调用,联系到同一个 total 绑定,所以读方法能看到写方法的修改。right 来自另一次调用,拥有另一个绑定。函数的源码位置相同,可以确定查找结构;每次运行创建的环境不同,决定这次查找最终读到哪份状态。

text
makeMeter 的源码只有一份
  ├─ 第一次调用的 total ← left.add、left.read
  └─ 第二次调用的 total ← right.add、right.read

块作用域同样会参与环境链,却不代表每进入一对花括号就压入一个新的函数调用栈帧。反过来,调用另一个函数会改变活动执行上下文,但新函数的外层词法环境仍由其创建位置决定。这两条变化规律必须分别跟踪。

一次调用怎样建立、暂停和结束 ​

执行上下文用于跟踪代码运行所需的信息。一次普通函数调用可以粗略理解为:建立这次调用的环境、绑定参数、执行函数体、返回结果或传播异常。递归时,同一个函数可以拥有多次不同的活动调用。

js
function descend(n) {
  console.log('进入', n)
  if (n > 0) descend(n - 1)
  console.log('离开', n)
}
descend(2)
// 进入 2 → 进入 1 → 进入 0 → 离开 0 → 离开 1 → 离开 2

不要把“函数返回”理解为“所有相关内存马上消失”。闭包可能继续使用绑定;垃圾回收看可达性。也不要把“每次执行都创建环境”的规范描述当成“必须逐次分配一份同样大的物理对象”。

生成器更能说明“暂停”与“结束”不同:

js
function* steps() {
  const label = '进度'
  yield label + '一'
  return label + '二'
}
const iterator = steps()
console.log(iterator.next()) // { value: '进度一', done: false }
console.log(iterator.next()) // { value: '进度二', done: true }

暂停后不一直占着活动调用栈,但恢复执行所需状态被保留。await 也会暂停异步函数的后续执行,让调用方继续;等待完成后的恢复通过相应作业调度。它不会把整条线程连人带椅子搬去“睡觉”。

await 之后,原来的调用者已经继续了 ​

js
const log = []
async function inspectLater() {
  log.push('函数前半段')
  await 0
  log.push('函数后半段')
  throw new Error('后半段失败')
}
let pending
try {
  pending = inspectLater()
  log.push('调用者继续')
} catch {
  log.push('外层同步捕获')
}
await pending.catch(error => log.push(error.message))
console.log(log.join(' → '))
// 函数前半段 → 调用者继续 → 函数后半段 → 后半段失败

调用者拿到的是 Promise。异步函数暂停后,调用者自己的 try 块已经能正常结束;后半段恢复时发生的错误成为 Promise 的拒绝,不能沿着一条早已结束的同步调用链退回原 catch。应当在 Promise 链上处理,或在调用者的 try 中 await 它。

这里的“恢复”也不等于重新从函数第一行运行一次。暂停点、局部绑定和必要的控制状态必须保留,才可能接着执行。至于实现如何存放这些信息,是引擎的选择;规范要求保留的是可观察行为。

全局对象不等于所有全局变量 ​

下面是浏览器普通脚本的完整实验,保存为 HTML 打开:

html
<!doctype html>
<meta charset="utf-8">
<script>
  var visibleOnWindow = 10
  let lexicalOnly = 20
  console.log(window.visibleOnWindow) // 10
  console.log(window.lexicalOnly)     // undefined
  console.log(lexicalOnly)            // 20
</script>

在普通脚本的全局环境中,顶层 var 与顶层词法声明的保存方式不同。let 可以是全局可见绑定,却不是 window 的属性。

改成 <script type="module"> 后,顶层声明属于模块。Node 的 .mjs 同样采用模块作用域;CommonJS 又有模块包装机制。因此,学习资料里的“顶层变量都在 window 上”缺少了关键前提。

globalThis 提供访问当前环境全局对象的统一入口;它不能让一个模块的局部声明自动变成全局属性,也不能让所有运行环境都出现 document。

Realm:为什么另一个窗口的数组有点陌生 ​

每个 Realm 拥有自己的内置对象,例如 Array 和 Object。浏览器中的同源 iframe 常用于观察这个现象;Node 可以用内置模块构造一个小实验:

js
import { runInNewContext } from 'node:vm'
const foreign = runInNewContext('[4, 5]')
console.log(foreign instanceof Array) // false
console.log(Array.isArray(foreign))  // true

instanceof 通常检查构造器的 prototype 是否位于对象的原型链上。外部数组的原型来自另一个 Realm,与你这里的 Array.prototype 不相同。Array.isArray 检查数组身份,不靠当前 Realm 的构造器做这个推断。

Realm 不是“安全沙箱”的同义词。Node 的 vm 不应被当作执行不可信代码的安全隔离方案。跨来源窗口能否互相访问,还由浏览器同源规则决定。

Job、宿主与“单线程”的范围 ​

ECMAScript 描述 Promise 作业等语言机制;浏览器负责网络、计时、渲染和事件循环的许多具体规则。Worker 可以在另一个执行代理中运行,因此“JavaScript 是单线程”不能推导出“整个浏览器只有一条线程”。

同一代理内,当前一段同步代码通常会运行完,才把控制权交出去;但是它调用的 getter、代理陷阱、用户回调又可能同步执行其他代码。面对共享内存时,还需要考虑其他代理与原子操作。把“同步”简单等同于“没有任何重入和并发影响”也会出错。

理解了这个分工,再读第五章的任务/微任务顺序,就不会把 Promise、网络线程和 DOM 渲染画成同一个队列。

练习 ​

  1. 把 report 中的 factor 改成 -1,说明为何 triple(7) 不变;再设计能改变后续结果的闭包写法。
  2. 浏览器普通脚本与模块中,顶层 var、let 和 this 有何差别?写出你会如何验证。
  3. 为什么不应使用 value instanceof Array 作为处理 iframe 数据时唯一的数组判断?
  4. 为什么调用异步函数外围的同步 try/catch 捕获不到 await 之后的抛错?怎样改写调用处才能捕获?

查看本篇答案

用心记录每一步成长