执行上下文:把作用域、调用栈与宿主接起来
前面已经分别学过闭包、this 和事件循环。现在把它们放到同一张图上:代码在哪里找变量、当前执行到哪里、下一段工作何时开始,是三个不同的问题。 把三者都叫作“上下文”,讨论起来就像三个人共用一个名字。
本篇使用 Node 的 .mjs 文件。浏览器例子会单独说明;前置内容是第二章的作用域与闭包、第五章的异步控制和第六章的生成器。下面分别使用它们解释变量查找、暂停和恢复。
程序开始执行前,已经发生了什么
保存以下内容为 parse-failure.mjs,单独运行:
console.log('准备出发')
const amount = ;结果是 SyntaxError,第一行也不会打印。引擎先解析这个源文件;它不是每读到一行就无条件执行一行。语法检查无法通过,这次模块求值便没有开始。
再把第二行改成 missingName(),第一行就能打印,随后才发生 ReferenceError。语法错误与运行时错误的发生阶段不同。另一个模块已经产生的副作用,也不会因为你随后加载的模块解析失败而被撤销。
“JavaScript 到底是解释型还是编译型”经常引发口水战。对写代码更有用的结论是:规范规定可观察行为,并不要求厂商必须使用某种解释器或 JIT 架构。我们可以讨论具体引擎如何解析、编译和优化,不能把某个引擎的流水线变成所有实现的语言定义。
声明的准备与赋值的执行是两件事
解析成功也不表示所有变量已经有了最终值。代码进入某个作用域时,要先安排这个作用域中的声明;执行到初始化表达式时,才计算表达式并把结果交给对应绑定。第二章的“提升”在这里可以拆成可观察的步骤,而不是想象引擎把几行源代码搬到了文件开头。
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";对暂时性死区里的词法绑定做同样操作仍然会抛错。这个例外说明,判断语义不能只从最后的值出发,还必须知道名称解析找到了什么。
调用栈记录进度,环境记录变量绑定
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),活动调用关系是下面这样:
调用栈(谁正在执行) 变量查找(从哪里找 factor)
scale scale 本次调用的环境
report ↓
模块代码 makeScale(3) 创建的环境
↓
模块的外层环境report 的 factor = 100 不在右侧这条链上。调用者在谁的上面,不决定被调用者能访问谁的局部变量。这是词法作用域与调用栈的根本区别。
ECMAScript 用 Environment Record(环境记录) 描述名称与绑定的关系,用外层联系描述嵌套。它们是规范工具,程序不能直接拿到一个环境记录对象。教学图里的方框也不保证一一对应堆中的某个实体,引擎可以优化表示,只要行为不变。
同一段函数代码,并不意味着同一份绑定
把上一例的“函数保留了外层联系”再推进一步:联系指向哪一次调用?
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 0left.add 和 left.read 创建于同一次调用,联系到同一个 total 绑定,所以读方法能看到写方法的修改。right 来自另一次调用,拥有另一个绑定。函数的源码位置相同,可以确定查找结构;每次运行创建的环境不同,决定这次查找最终读到哪份状态。
makeMeter 的源码只有一份
├─ 第一次调用的 total ← left.add、left.read
└─ 第二次调用的 total ← right.add、right.read块作用域同样会参与环境链,却不代表每进入一对花括号就压入一个新的函数调用栈帧。反过来,调用另一个函数会改变活动执行上下文,但新函数的外层词法环境仍由其创建位置决定。这两条变化规律必须分别跟踪。
一次调用怎样建立、暂停和结束
执行上下文用于跟踪代码运行所需的信息。一次普通函数调用可以粗略理解为:建立这次调用的环境、绑定参数、执行函数体、返回结果或传播异常。递归时,同一个函数可以拥有多次不同的活动调用。
function descend(n) {
console.log('进入', n)
if (n > 0) descend(n - 1)
console.log('离开', n)
}
descend(2)
// 进入 2 → 进入 1 → 进入 0 → 离开 0 → 离开 1 → 离开 2不要把“函数返回”理解为“所有相关内存马上消失”。闭包可能继续使用绑定;垃圾回收看可达性。也不要把“每次执行都创建环境”的规范描述当成“必须逐次分配一份同样大的物理对象”。
生成器更能说明“暂停”与“结束”不同:
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 之后,原来的调用者已经继续了
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 打开:
<!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 可以用内置模块构造一个小实验:
import { runInNewContext } from 'node:vm'
const foreign = runInNewContext('[4, 5]')
console.log(foreign instanceof Array) // false
console.log(Array.isArray(foreign)) // trueinstanceof 通常检查构造器的 prototype 是否位于对象的原型链上。外部数组的原型来自另一个 Realm,与你这里的 Array.prototype 不相同。Array.isArray 检查数组身份,不靠当前 Realm 的构造器做这个推断。
Realm 不是“安全沙箱”的同义词。Node 的 vm 不应被当作执行不可信代码的安全隔离方案。跨来源窗口能否互相访问,还由浏览器同源规则决定。
Job、宿主与“单线程”的范围
ECMAScript 描述 Promise 作业等语言机制;浏览器负责网络、计时、渲染和事件循环的许多具体规则。Worker 可以在另一个执行代理中运行,因此“JavaScript 是单线程”不能推导出“整个浏览器只有一条线程”。
同一代理内,当前一段同步代码通常会运行完,才把控制权交出去;但是它调用的 getter、代理陷阱、用户回调又可能同步执行其他代码。面对共享内存时,还需要考虑其他代理与原子操作。把“同步”简单等同于“没有任何重入和并发影响”也会出错。
理解了这个分工,再读第五章的任务/微任务顺序,就不会把 Promise、网络线程和 DOM 渲染画成同一个队列。
练习
- 把
report中的factor改成-1,说明为何triple(7)不变;再设计能改变后续结果的闭包写法。 - 浏览器普通脚本与模块中,顶层
var、let和this有何差别?写出你会如何验证。 - 为什么不应使用
value instanceof Array作为处理 iframe 数据时唯一的数组判断? - 为什么调用异步函数外围的同步 try/catch 捕获不到 await 之后的抛错?怎样改写调用处才能捕获?