第 16 章

执行上下文栈与 Realm:调用栈结构与跨领域陷阱

每次函数调用都会创建一个独立的执行上下文,这是 JavaScript 运行机制的基石

JavaScript 引擎执行代码的核心不是"逐行扫描",而是维护一个执行上下文栈(Execution Context Stack,简称 EC Stack 或 Call Stack)。每次函数被调用,引擎就把一个新的执行上下文(Execution Context,EC)压入栈顶;函数执行完毕,这个 EC 从栈顶弹出,控制权交还给下方的 EC。这个进栈 / 出栈的过程,决定了变量可见范围、this 值、以及代码的执行顺序。

Realm 是另一个层面的概念:它是一套完整的 JavaScript 运行时环境,包含独立的全局对象和所有内置构造函数。一个页面里的主文档、一个 <iframe>、一个 Web Worker,各自持有一个独立的 Realm。跨 Realm 传递对象时,instanceof 会静默失败——这不是 bug,是规范有意为之的设计。

理解执行上下文栈和 Realm,是理解本书第 17 至 20 章(作用域链、变量提升、闭包、this 绑定)的前提基础。这两个概念共同构成了 JavaScript 代码"在哪里运行"和"如何寻找变量"的底层模型。


🔹 Level 1 · 你需要知道的

调用栈:函数调用的进出顺序

调用栈是一个后进先出(LIFO)的数据结构。程序启动时,引擎首先创建**全局执行上下文(Global EC)**并推入栈底,它贯穿整个程序生命周期,直到页面关闭或进程退出。

调用栈的工作方式非常直觉化:它记录了"当前代码执行到哪里了,以及执行完之后应该回到哪里"。每当一个函数被调用,引擎需要记住两件事:第一,这个函数需要访问哪些变量(词法环境);第二,函数执行完后控制权交给谁(调用方 EC)。这两件事都编码在执行上下文里。执行上下文被压入栈的那一刻,它就成为了"当前正在运行的上下文"(Running Execution Context),所有操作都针对它进行。

function bar() {
  console.log('bar 执行');
}

function foo() {
  bar();
  console.log('foo 执行');
}

foo();

上面这段代码的调用栈变化:

  1. 程序开始 → 推入 Global EC
  2. foo() 被调用 → 推入 foo EC
  3. bar() 被调用 → 推入 bar EC
  4. bar 执行完毕 → 弹出 bar EC,控制权回到 foo EC
  5. foo 执行完毕 → 弹出 foo EC,控制权回到 Global EC
  6. 程序结束 → 弹出 Global EC

每个 EC 是相互独立的。foo 里的局部变量对 bar 不可见,bar 里声明的变量也不会污染 foo。这种隔离是 JavaScript 函数作用域的物理基础。变量查找不是"在整个内存里乱找",而是沿着 EC 栈里的词法环境链向上寻找,第 17 章会详细讲解这条链的结构。

在浏览器 DevTools 中,调试器的"Call Stack"面板实时展示当前的 EC 栈状态,每一行代表一个 EC 及其对应的源码位置。当代码执行到断点时,你可以点击 Call Stack 里的不同帧,切换查看各个 EC 的本地变量,这就是调用栈可视化调试的基本原理。

栈溢出:递归没有终止条件的后果

调用栈的深度是有上限的,这个上限由 JavaScript 引擎实现决定,不是 ECMAScript 规范规定的。不同引擎、不同版本、不同平台的限制各不相同。Chrome(V8 引擎)大约 10,000 ~ 15,000 层,Firefox(SpiderMonkey)大约 50,000 层,Node.js 大约 10,000 ~ 12,000 层(视递归函数的参数数量而定,参数越多栈帧越大,可用的帧数越少)。超出限制后引擎抛出:

RangeError: Maximum call stack size exceeded

最常见的触发场景是无终止条件的递归。每一次递归调用都会创建一个新的 EC 压入栈,而函数永远不 return,所以旧的 EC 永远不弹出,栈不断增长直到溢出:

// 错误示例:无终止条件
function countdown(n) {
  console.log(n);
  countdown(n - 1); // 永远不会停止,每次调用都新建一个 EC
}
countdown(5); // RangeError: Maximum call stack size exceeded

// 正确示例:有终止条件
function countdown(n) {
  if (n < 0) return; // 终止条件:n 为负数时停止递归
  console.log(n);
  countdown(n - 1);
}
countdown(5); // 5, 4, 3, 2, 1, 0(6 层 EC,执行完后全部弹出)

栈溢出也可能由间接递归触发——函数 A 调用函数 B,函数 B 又调用函数 A,形成环形调用链。这种情况更隐蔽,在设计互相调用的解析器、状态机或事件处理链时需要特别小心。

调试栈溢出最有效的方法:在 Chrome DevTools 里,错误会附带完整的 Call Stack 轨迹,从栈顶到栈底每一帧都可见。通常你会看到同一个函数名在 Call Stack 里循环出现,这就是递归无终止条件的明显特征。节点版则可以用 --stack-trace-limit=100 参数增大 Node.js 打印的调用栈深度,默认只打印 10 层。

Realm:独立的 JavaScript 运行环境

Realm 是一个相对陌生的概念,但它解释了很多令人困惑的行为。简单理解:Realm 是一套独立的 JavaScript 运行时"宇宙",拥有自己的全局对象和一整套内置构造函数。浏览器里,每个窗口、每个 iframe、每个 Worker 线程都运行在各自独立的 Realm 里。在 Node.js 里,主进程是一个 Realm,每次调用 vm.createContext() 会创建一个新 Realm。

Realm 的核心内容包括:

关键结论:不同 Realm 的 Array 是不同的构造函数对象。即使它们的名字相同、行为相同,它们在内存里是两个独立的对象。一个 iframe 里创建的数组,用主文档的 Arrayinstanceof 检查会失败,因为原型链的终点是 iframe 的 Array.prototype,而不是主文档的 Array.prototype

// 主文档
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const iframeArray = new iframe.contentWindow.Array(1, 2, 3);

// iframe 里的数组 vs 主文档的 Array 构造函数
console.log(iframeArray instanceof Array);               // false ← 跨 Realm 检测失败
console.log(Array.isArray(iframeArray));                 // true ← 安全的跨 Realm 检测方案
console.log(Object.prototype.toString.call(iframeArray)); // "[object Array]" ← 也安全

Array.isArray 之所以能跨 Realm 正确工作,是因为它使用的是 [[IsArray]] 内部操作,这个操作直接检查对象内部的类型标记,不走原型链。这是规范专门为跨 Realm 场景提供的修复方案,第 16 章 Level 3 会详细讲解规范定义。


🔸 Level 2 · 它是怎么运行的

执行上下文的三个核心组件

ECMAScript 规范定义,每个执行上下文包含以下核心组件(不同类型的 EC 有所不同)。理解这些组件,就理解了"代码在当前上下文里如何查找变量、绑定 this、归属哪个 Realm"的完整答案:

组件 含义 涉及的主要场景
LexicalEnvironment 当前词法环境,let/const/function/class 的查找起点 变量查找、TDZ
VariableEnvironment var 声明所在环境,大多数时候与 LexicalEnvironment 相同 var 提升
ThisBinding 当前 EC 绑定的 this 方法调用、箭头函数
Realm Record 当前 EC 所属的 Realm 内置对象访问
ScriptOrModule 当前运行的脚本/模块记录 ESM import.meta

LexicalEnvironmentVariableEnvironment 在绝大多数情况下指向同一个环境记录。只有在 try...catch 块和 with 语句中它们会短暂分离:catch (e) 会创建一个新的 LexicalEnvironment(专门用于绑定 e 变量),而 VariableEnvironment 保持不变,仍然指向外层函数的 var 存储位置。这个细节在 99% 的日常开发场景里可以不关心,但在涉及 try...catch 里的 var 声明行为时会有体现。

ThisBinding 保存当前上下文的 this 值。对于箭头函数创建的 EC,ThisBinding 是特殊的 'lexical' 标记,意味着"没有自己的 this,从外层查找"。第 20 章会详细讲解 this 的完整机制。

Realm Record 决定了当代码访问内置对象时,拿到的是哪个 Realm 的版本。比如 new Array() 时,引擎会查当前 EC 的 Realm Record,找到这个 Realm 的 %Array% 内置构造函数,用它来创建数组。这就是为什么不同 Realm 里创建的数组是"不同族谱"的。

调用栈的完整结构示意

下图展示了 foo() 调用 bar() 时的完整调用栈状态。注意每个 EC 都有独立的 LexicalEnvironment,但它们的 Realm 通常是相同的(除非是跨 iframe 或 Worker 的调用):

┌─────────────────────────────────────────────────────────────┐
│                      EC Stack (调用栈)                       │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  bar EC  (栈顶 · 当前正在执行)                        │   │
│  │  ├─ LexicalEnvironment → bar 的 EnvironmentRecord    │   │
│  │  ├─ VariableEnvironment → bar 的 EnvironmentRecord   │   │
│  │  ├─ ThisBinding → undefined (严格模式)                │   │
│  │  │               window/global (非严格模式)           │   │
│  │  └─ Realm → 当前页面的 Realm Record                   │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  foo EC                                              │   │
│  │  ├─ LexicalEnvironment → foo 的 EnvironmentRecord    │   │
│  │  ├─ VariableEnvironment → foo 的 EnvironmentRecord   │   │
│  │  ├─ ThisBinding → undefined / window/global          │   │
│  │  └─ Realm → 当前页面的 Realm Record                   │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  Global EC  (栈底 · 始终存在)                         │   │
│  │  ├─ LexicalEnvironment → GlobalEnvironmentRecord     │   │
│  │  ├─ VariableEnvironment → GlobalEnvironmentRecord    │   │
│  │  ├─ ThisBinding → globalThis (window/global)         │   │
│  │  └─ Realm → 当前页面的 Realm Record                   │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
│  ← 调用栈增长方向(每次函数调用,向上添加新 EC)              │
└─────────────────────────────────────────────────────────────┘

Realm 的完整内容与内置对象映射表

每个 Realm 持有一张完整的内置对象映射表([[Intrinsics]]),规范用 %Xxx% 的符号来引用这些内置对象。以下是 Realm 的核心结构:

┌─────────────────────────────────────────────────────────────┐
│                        Realm Record                          │
│                                                             │
│  [[Intrinsics]]   一张映射表,键是规范名,值是内置对象        │
│  ├─ %Array%              → Array 构造函数                    │
│  ├─ %ArrayPrototype%     → Array.prototype                  │
│  ├─ %Object%             → Object 构造函数                  │
│  ├─ %ObjectPrototype%    → Object.prototype                 │
│  ├─ %Function%           → Function 构造函数                │
│  ├─ %FunctionPrototype%  → Function.prototype               │
│  ├─ %Promise%            → Promise 构造函数                 │
│  ├─ %PromisePrototype%   → Promise.prototype                │
│  ├─ %TypeError%          → TypeError 构造函数               │
│  ├─ %Map%                → Map 构造函数                     │
│  ├─ %Set%                → Set 构造函数                     │
│  └─ ... (约 200 个内置对象,参见 ECMA-262 附录 B)            │
│                                                             │
│  [[GlobalObject]]  → 全局对象 (window / global / self)      │
│  [[GlobalEnv]]     → GlobalEnvironmentRecord                │
│  [[TemplateMap]]   → 模板字面量对象缓存(Map 结构)           │
└─────────────────────────────────────────────────────────────┘

[[TemplateMap]] 是一个不常为人知的细节:模板字面量(如 `Hello, ${name}`)在每个 Realm 里有独立的缓存,确保相同代码位置的模板标签总是收到同一个冻结数组对象。这是规范对模板字符串性能优化的一部分。

两个不同 Realm 的 %Array% 是完全独立的函数对象,即使它们的名字都叫 Array、代码行为完全一致。这就是为什么 iframe.contentWindow.Array !== window.Array ——它们是内存里两个不同的对象,只是功能相同。原型链查找时,iframeArray.__proto__ 指向 iframe 的 Array.prototype,而 instanceof Array 查找的是主文档的 Array.prototype,两者不同,导致结果为 false

Generator 函数的执行上下文如何挂起

Generator 函数是 ECMAScript 2015 引入的迭代控制机制,它的执行上下文管理方式与普通函数完全不同,是理解异步函数(async/await 底层基于 Generator 和 Promise 协作)的重要基础。

调用 Generator 函数(如 counter())不会立即执行函数体,而是创建一个 GeneratorObject(生成器对象)并返回,此时 Generator 的 EC 尚未进入调用栈。每次调用 gen.next() 时,Generator 的 EC 被推入栈顶,执行到下一个 yield 表达式;到达 yield 时,这个 EC 不是被弹出销毁,而是被"挂起"——保存到 GeneratorObject 的内部槽 [[GeneratorContext]] 里,然后从调用栈移除。下次调用 next() 时,从 [[GeneratorContext]] 恢复 EC,重新推入栈顶继续执行。

这个机制使得 Generator 的 EC 可以在多次 next() 调用之间"跨越时间"存活,而普通函数的 EC 从调用开始到 return 语句,生命周期是连续且不可中断的。

function* counter() {
  console.log('阶段 1:开始');
  yield 1;           // EC 在这里挂起,保存到 GeneratorObject
  console.log('阶段 2:继续');
  yield 2;           // EC 再次挂起
  console.log('阶段 3:完成');
  return 3;          // EC 执行完毕,GeneratorObject 状态 → 'completed'
}

const gen = counter();  // 创建 GeneratorObject,EC 尚未执行

// 第 1 次调用:推入 EC,执行到 yield 1,EC 挂起
console.log(gen.next()); // 打印"阶段 1:开始",返回 { value: 1, done: false }

// 第 2 次调用:恢复 EC,从 yield 1 之后继续,执行到 yield 2,EC 挂起
console.log(gen.next()); // 打印"阶段 2:继续",返回 { value: 2, done: false }

// 第 3 次调用:恢复 EC,从 yield 2 之后继续,执行到 return 3,EC 销毁
console.log(gen.next()); // 打印"阶段 3:完成",返回 { value: 3, done: true }

// GeneratorObject 状态变为 'completed'
console.log(gen.next()); // { value: undefined, done: true }(已完成,不再执行)

Generator EC 的挂起行为有一个重要的内存影响:只要 GeneratorObject 存活(即 done 还不是 true),被挂起的 EC 以及它的 LexicalEnvironment(包含所有局部变量)就不会被 GC。这个细节在大量数据处理场景中需要注意——一个保存了大量中间状态的 Generator 如果长期不完成,会持续占用内存。

尾调用优化(TCO)的执行上下文复用机制

尾调用优化(Tail Call Optimization,TCO)是 ECMAScript 2015 规范中新增的一条要求:在严格模式下,如果函数的最后一步操作是对另一个函数的调用,且调用结果被直接返回(没有任何后续操作),引擎可以复用当前 EC,而不是创建新的 EC 压入栈。这样,无论递归多深,调用栈深度都保持为 1,从根本上消除了栈溢出的可能。

尾调用的判断标准很严格。return f() 是尾调用(调用后直接返回,没有后续操作);return 1 + f() 不是(调用后还要做加法);f(); return; 也不是(调用后虽然没有表达式操作,但隐式返回 undefined 前先执行了 f(),规范认为这不是尾调用位置)。

'use strict';

// 真正的尾调用:最后一步是 return factorial(n-1, acc*n),没有后续操作
// ECMAScript 规范要求此处可以复用当前 EC(TCO)
function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return factorial(n - 1, acc * n); // 尾调用
}

// 不是尾调用:return n * factorial(n-1) 中,调用返回后还要做乘法
// 乘法操作需要保留当前 EC,TCO 不适用
function badFactorial(n) {
  if (n <= 1) return 1;
  return n * badFactorial(n - 1); // 非尾调用
}

规范要求归规范要求,现实中引擎的实现情况却大相径庭。Safari(JavaScriptCore)从 2016 年起完整实现了 TCO,factorial(1000000) 在 Safari 里正常运行,不会栈溢出。而 V8(Chrome/Node.js)以"实现复杂度高、调试体验差(TCO 会删除中间帧,导致调用栈信息缺失)"为由,明确拒绝实现 TCO,即使在严格模式下也不做这个优化。这意味着,不能依赖 TCO 来编写跨平台的深度递归代码。

正确的可移植写法是将递归改写为迭代,或使用 Trampoline(蹦床函数)技术。Trampoline 的原理是:让递归函数每次返回"下一步要执行的函数"而不是直接递归调用,然后由一个外部循环不断调用这些函数,实现"假递归"而实质是循环——Level 4 会给出完整实现。


🔺 Level 3 · 规范怎么定义的

规范 Section 9.4:Execution Contexts(执行上下文)

ECMAScript 规范(ECMA-262)第 9.4 节对执行上下文的官方定义如下:

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. At any point in time, there is at most one execution context per agent that is actually executing code. This is known as the agent's running execution context.

这段定义揭示了两个关键点。第一,"specification device"——执行上下文是规范用来描述代码执行状态的抽象概念,不是某个 JavaScript 可以操作的对象(你无法在代码里直接访问执行上下文)。第二,"at any point in time, there is at most one execution context that is actually executing code"——调用栈上虽然可以同时存在多个 EC,但只有栈顶的那个是"正在运行"的。

每个执行上下文拥有的状态组件(State Components),规范定义的完整列表:

组件名称 类型 描述
code evaluation state 执行/挂起/完成的状态(Generator 和 Async 函数用于记录挂起点)
Function Object / null 当前正在执行的函数对象(脚本/模块级别为 null)
Realm Realm Record 关联的 Realm Record
ScriptOrModule Script / Module Record 关联的脚本或模块
LexicalEnvironment Environment Record 词法环境
VariableEnvironment Environment Record 变量环境
PrivateEnvironment PrivateEnvironment Record 类私有字段用

规范描述调用栈的方式:维护一个执行上下文栈(execution context stack),栈顶的 EC 称为运行中的执行上下文(running execution context)。所有规范中的"当前词法环境"均指"运行中的 EC 的 LexicalEnvironment"。

规范对"挂起"的定义(Section 9.4.2):当一个新 EC 被压入栈时,之前的 running EC 被"挂起"(suspended)——它不再是 running EC,但仍留在栈上。当新 EC 执行完毕弹出后,被挂起的 EC 恢复为 running EC。Generator 的挂起是另一种机制,它会把 EC 从栈上移除,而不仅仅是让它停止运行。

规范 Section 9.3:Realms(领域)

规范第 9.3 节定义了 Realm Record 的结构和创建流程。Realm Record 包含以下字段:

字段 类型 描述
[[Intrinsics]] Record(映射表) 内置对象的引用,键是规范名(如 %Array%
[[GlobalObject]] Object 全局对象(window/global/self
[[GlobalEnv]] GlobalEnvironmentRecord 全局词法环境记录
[[TemplateMap]] List of Records 模板字面量对象缓存
[[LoadedModules]] List of Records 已加载的模块记录

CreateRealm() 算法(简化):

  1. 创建新的 Realm Record realmRec,初始化各字段为空
  2. 调用 CreateIntrinsics(realmRec)——这是最耗时的步骤,需要创建约 200 个内置对象,包括它们的原型、构造函数、方法,并互相建立引用关系(例如 Array.prototype.__proto__ === Object.prototype
  3. 设置 realmRec.[[GlobalObject]] 为 undefined(由后续步骤 SetRealmGlobalObject 设置)
  4. 设置 realmRec.[[GlobalEnv]] 为 undefined
  5. 设置 realmRec.[[TemplateMap]] 为空列表
  6. 返回 realmRec

SetRealmGlobalObject(realmRec, globalObj, thisValue) 算法:

  1. 如果 globalObj 为 undefined,创建一个新的普通对象作为全局对象
  2. realmRec.[[GlobalObject]] 设置为 globalObj
  3. 创建新的 GlobalEnvironmentRecord(以 globalObj 为基础,包含 ObjectRecord 和 DeclarativeRecord 两个子记录)
  4. realmRec.[[GlobalEnv]] 设置为该 GlobalEnvironmentRecord

浏览器在每次创建新文档(包括 iframe 的文档)时,都会执行完整的 CreateRealm()SetRealmGlobalObject() 流程,这就是为什么不同文档有各自独立的全局对象和内置构造函数。

GlobalEnvironmentRecord 的内部结构

GlobalEnvironmentRecord 不是一个单一的记录,而是两个子记录的组合体,这个设计是为了同时支持 var 的全局挂载行为和 let/const 的块级隔离行为:

GlobalEnvironmentRecord
├─ [[ObjectRecord]]       → ObjectEnvironmentRecord
│   ├─ [[BindingObject]]  → 全局对象(window/global)
│   └─ var 声明、函数声明 → 成为全局对象的属性(window.x = 1)
└─ [[DeclarativeRecord]]  → DeclarativeEnvironmentRecord
    ├─ let 声明           → 存在 DeclarativeRecord 里
    ├─ const 声明         → 存在 DeclarativeRecord 里
    └─ class 声明         → 存在 DeclarativeRecord 里

这就是为什么:

查找变量时,GlobalEnvironmentRecord.GetBindingValue 的规范逻辑(简化):

  1. 调用 [[DeclarativeRecord]].HasBinding(name) — 先查 DeclarativeRecord
  2. 如果找到,从 DeclarativeRecord 返回值
  3. 否则,调用 [[ObjectRecord]].GetBindingValue(name) — 再查全局对象的属性
  4. 如果仍找不到,抛出 ReferenceError

[[IsArray]] 内部操作与跨 Realm 安全检测的规范依据

规范 Section 7.2.2 定义了 IsArray(argument) 抽象操作,这是 Array.isArray() 的实现基础:

IsArray(argument):
1. 如果 argument 是 Array exotic object → 返回 true
   (Array exotic object:拥有特殊 [[DefineOwnProperty]] 内部方法的对象,
     所有通过 Array 构造函数或数组字面量创建的数组都是此类型)
2. 如果 argument 是 Proxy exotic object:
   a. 如果 argument.[[ProxyHandler]] 为 null → 抛出 TypeError
   b. 取 argument.[[ProxyTarget]] 为 target
   c. 返回 ? IsArray(target)  ← 递归剥离 Proxy 包装
3. 否则 → 返回 false

IsArray 操作不访问任何 prototype 属性,不涉及原型链查找,只检查对象的内部类型标记(Array exotic object 是由 ECMAScript 引擎内部标记的类型,不是 JavaScript 层面的属性)。因此它完全不受 Realm 隔离的影响——不管数组是在哪个 Realm 创建的,只要它是一个 Array exotic object,IsArray 就返回 true

instanceof 操作符底层走的是 OrdinaryHasInstance 操作,核心是:沿着对象的 [[Prototype]] 链查找,检查是否存在等于 Array.prototype 的节点。这里的 Array.prototype当前代码所在 Realm 的 %ArrayPrototype%,但被检测数组的 [[Prototype]] 链末端是创建该数组的 Realm 的 %ArrayPrototype%。两个不同的对象,比较结果为 false

这个设计差异完美解释了跨 Realm instanceof 失败的根本原因,以及 Array.isArray 能够正确工作的规范依据。


💎 Level 4 · 边界与陷阱

陷阱 1:跨 iframe 的 instanceof 静默失败导致业务逻辑错误

在实际工程里,跨 Realm 的 instanceof 失败往往是静默的、难以发现的。父页面把一个数组传给 iframe,iframe 里的代码做 instanceof Array 检测,得到 false,然后走进了"不是数组"的分支,输出了错误结果——没有抛错,日志里也没有明显的错误信息,只是业务逻辑悄悄地走错了。

// 父页面 (parent.html)
const iframe = document.createElement('iframe');
iframe.src = 'child.html';
document.body.appendChild(iframe);

iframe.onload = function() {
  // 父页面创建的数组,传给 iframe
  const arr = [1, 2, 3, 4, 5];
  iframe.contentWindow.processData(arr);
};

// child.html 里的代码
function processData(data) {
  if (data instanceof Array) {
    // 父页面传来的数组,instanceof 失败!走进这里的概率是 0
    console.log('数组处理:长度 =', data.length);
    const sum = data.reduce((acc, x) => acc + x, 0);
    console.log('求和结果:', sum);
  } else {
    // 实际上走进这里!数据被当成非数组处理,业务逻辑错误
    console.log('非数组数据,按对象处理');
    console.log(Object.keys(data)); // ['0', '1', '2', '3', '4'](数字索引属性)
  }
}

根因分析arr 是用父页面 Realm 的 %Array% 创建的,它的 [[Prototype]] 链是:arr → 父页面的 Array.prototype → 父页面的 Object.prototype → null。iframe 里的 Array 是 iframe Realm 的 %Array%instanceof 检查的是 iframe 的 Array.prototype,而 arr.__proto__ 是父页面的 Array.prototype,两者是不同的对象,instanceof 返回 false

完整的跨 Realm 安全检测方案

function processData(data) {
  // 方案 1:Array.isArray(最推荐,语义清晰)
  if (Array.isArray(data)) {
    console.log('是数组,长度:', data.length);
    return;
  }
  
  // 方案 2:Object.prototype.toString(通用,适合需要区分更多类型的场景)
  const type = Object.prototype.toString.call(data);
  if (type === '[object Array]') {
    console.log('是数组');
    return;
  }
  
  // 方案 3:检查 constructor.name(不可靠,因为 constructor 可以被修改)
  // 不推荐:data.constructor.name === 'Array'
  
  // 方案 4:结构化克隆后在当前 Realm 检查(适合需要确保对象在当前 Realm 里的场景)
  // const localCopy = structuredClone(data); // 深拷贝,保证在当前 Realm 创建
  // if (localCopy instanceof Array) { ... }
}

陷阱 2:Node.js vm 模块创建的新 Realm 导致类型检测失败

Node.js 的 vm 模块是服务端 JavaScript 沙箱执行的标准工具。每次 vm.createContext() 创建一个新的 Realm,其中的内置构造函数与外部完全隔离:

const vm = require('vm');

// 创建沙箱 Realm
const sandbox = { x: 10, result: null };
const context = vm.createContext(sandbox);

// 在沙箱中创建数组和对象
vm.runInContext('result = [1, 2, 3]', context);
const result = sandbox.result; // 从沙箱取出数组

// 类型检测的各种结果
console.log(result instanceof Array);            // false ← 跨 Realm,失败
console.log(Array.isArray(result));              // true  ← 安全
console.log(result instanceof vm.runInContext('Array', context)); // true ← 使用沙箱的 Array

// 原型链检查
const sandboxArray = vm.runInContext('Array', context);
console.log(sandboxArray === Array);             // false ← 两个不同的 Array 构造函数
console.log(sandboxArray.prototype === Array.prototype); // false ← 两个不同的 prototype

// 错误示例:序列化再检测
const serialized = JSON.stringify(result);
const deserialized = JSON.parse(serialized);
console.log(deserialized instanceof Array);      // true ← JSON 反序列化在当前 Realm 创建
console.log(Array.isArray(deserialized));        // true

在实际工程中,Node.js 插件系统、用户代码沙箱(如 Figma 的插件运行时、Observable 的代码单元)都面临这个问题。通用的解决方案是在跨 Realm 边界时序列化数据(JSON.stringify / JSON.parse,或更强大的 structuredClone),或者统一使用 Array.isArrayObject.prototype.toString 这类不依赖 Realm 的检测方法。

陷阱 3:尾调用优化的引擎差异导致代码不可移植

ECMAScript 2015 规范明确要求在严格模式下必须实现尾调用优化,但这条规范在 V8 引擎(Chrome、Node.js、Deno)里至今没有被实现。2016 年,V8 团队短暂实现了 TCO(代号 "Harmony TCO"),但在 Chrome 55(2016 年底)中被移除,理由是:TCO 修改了调用栈结构,导致 Error.stack 缺少中间帧,调试工具无法展示完整的函数调用链,对开发体验的影响超过了它带来的性能收益。

这意味着,如果你写了一个依赖 TCO 来避免栈溢出的深度递归函数,它在 Safari 上能正常运行 100,000 层,但在 Chrome 和 Node.js 上会在约 12,000 层时抛出 RangeError。这是一个严重的跨平台兼容性问题:

'use strict';

// 这个函数在 Safari 里可以处理任意深度(TCO 生效)
// 在 Chrome/Node.js 里,n > ~12000 时 RangeError
function sum(n, acc = 0) {
  if (n === 0) return acc;
  return sum(n - 1, acc + n); // 尾调用——规范要求 TCO,V8 不实现
}

// Safari:sum(1000000) = 500000500000
// Chrome:sum(1000000) → RangeError: Maximum call stack size exceeded

可移植的解决方案——Trampoline(蹦床函数)技术:

// Trampoline:把递归转换为循环,不依赖引擎的 TCO 实现
function trampoline(fn) {
  return function(...args) {
    let result = fn(...args);
    // 如果返回的是函数,继续调用;直到返回非函数值(最终结果)
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  };
}

// 将递归函数改写为"返回下一步函数"的形式
const sum = trampoline(function _sum(n, acc = 0) {
  if (n === 0) return acc;              // 终止条件:返回最终值
  return () => _sum(n - 1, acc + n);   // 非终止:返回下一步的函数,不直接调用
});

console.log(sum(1000000)); // 500000500000,在任何引擎上都不会栈溢出
// 原理:每次"递归"实际上是 trampoline 的 while 循环的一次迭代
// 调用栈始终只有 2 层:trampoline 函数 + _sum 函数

Trampoline 的开销:每次"递归"需要创建一个闭包(() => _sum(...)),有轻微的内存分配和 GC 开销。对于性能极敏感的场景,可以直接用显式循环替代。

陷阱 4:不同引擎的调用栈深度差异影响跨平台代码

调用栈深度不是 ECMAScript 规范规定的固定值,各引擎根据自己的实现和目标平台自行决定。这个差异在某些场景下会造成严重的跨平台问题:

引擎/平台 版本 典型调用栈深度 主要影响因素
V8(Chrome) Chrome 120 10,000 ~ 15,000 函数参数数量(参数越多,栈帧越大,深度越小)
V8(Node.js) Node 20 10,000 ~ 12,000 同上,加上 Node.js 的内部调用开销
SpiderMonkey(Firefox) Firefox 121 ~50,000 JIT 优化级别,优化越深帧越小
JavaScriptCore(Safari) Safari 17 ~65,000 TCO 实现,尾调用时实际无限
Hermes(React Native) 0.73 ~5,000 移动端内存限制,帧大小保守
QuickJS(嵌入式) 2024 ~20,000 解释器模式,帧结构紧凑
// 测量当前引擎的调用栈深度的工具函数
function measureStackDepth(depth = 0) {
  try {
    // 每次递归向下一层
    return measureStackDepth(depth + 1);
  } catch (e) {
    // 栈溢出时返回当前深度
    return depth;
  }
}

console.log(`当前引擎调用栈深度:${measureStackDepth()}`);
// V8(Chrome 120):约 13,845
// SpiderMonkey(Firefox 121):约 50,233
// JavaScriptCore(Safari 17):约 64,991
// Hermes(React Native 0.73):约 4,892

实践建议:跨平台应用的递归深度应以最保守的引擎(Hermes,约 5,000)为基准。如果代码需要处理深度超过 3,000 层的递归,应当使用迭代或 Trampoline 替代,无论运行在什么引擎上都是安全的。

陷阱 5:Generator 未完成时 EC 持有大量内存

未完成的 Generator 对象会持有被挂起的 EC,该 EC 持有 LexicalEnvironment,LexicalEnvironment 持有所有局部变量。如果在 Generator 中途绑定了大量数据,而 Generator 又长时间不完成,这些数据就无法被 GC:

function* processLargeDataset(data) {
  // 假设 data 是一个 100MB 的 Float64Array
  const intermediate = new Float64Array(data.length); // 再分配 100MB 中间数据
  
  for (let i = 0; i < data.length; i++) {
    intermediate[i] = data[i] * 2;
    if (i % 10000 === 0) {
      yield i; // 每处理 1 万条数据 yield 一次,供外部控制节奏
    }
  }
  
  return intermediate; // 最终返回处理结果
}

const dataset = new Float64Array(10 * 1024 * 1024); // 80MB 输入数据
const gen = processLargeDataset(dataset);

// 只调用了一次 next(),Generator 在第一个 yield 处挂起
gen.next(); // 此时 intermediate(100MB)无法被 GC,直到 Generator 完成或被关闭

// 正确:要么迭代到结束,要么显式关闭
// 方案 1:用 for...of 迭代到结束(会自动关闭)
for (const progress of processLargeDataset(dataset)) {
  console.log('进度:', progress);
}
// 迭代完成后 intermediate 被 GC

// 方案 2:中途停止时显式调用 return() 关闭 Generator
const gen2 = processLargeDataset(dataset);
gen2.next(); // 处理第一批
gen2.return(); // 强制关闭,EC 销毁,intermediate 可以被 GC

规范规定for...of 循环正常结束(迭代器 done 为 true)时,Generator 自然完成;for...of 里使用 break 时,引擎会自动调用迭代器的 return() 方法关闭 Generator(这是规范对 Completion Record 处理的要求)。但手动创建并中途放弃的 Generator 不会自动关闭,必须显式调用 .return() 或等其自然完成。


小结

  1. 每次函数调用创建一个执行上下文(EC)压入调用栈,函数返回时 EC 弹出;调用栈深度有引擎限制,V8 约 10,000~15,000 层,SpiderMonkey 约 50,000 层,Hermes(React Native)约 5,000 层;超出限制抛出 RangeError: Maximum call stack size exceeded

  2. 执行上下文包含三个核心组件:LexicalEnvironment(let/const/class 作用域)、VariableEnvironment(var 作用域)和 ThisBinding(this 值),以及 Realm Record(所属 Realm)和 ScriptOrModule(所属脚本/模块);大多数场景下前两者指向同一个环境记录,仅在 try-catch 和 with 语句中短暂分离。

  3. Realm 是独立的 JavaScript 运行时环境,包含约 200 个独立的内置构造函数和它们的原型;iframe、Worker、Node.js vm.createContext 各自创建独立 Realm;不同 Realm 的同名构造函数(如 Array)是完全不同的对象,原型链不互通。

  4. 跨 Realm 的 instanceof 检测会静默失败(因为原型链终点指向不同 Realm 的 prototype);正确做法是使用 Array.isArray()(内部用 [[IsArray]] 操作,不走原型链)或 Object.prototype.toString.call()

  5. Generator 函数执行到 yield 时,EC 被保存到 GeneratorObject 的内部槽 [[GeneratorContext]] 并从调用栈移除(挂起);未完成的 Generator 持有整个 EC 和 LexicalEnvironment,阻止局部变量被 GC;主动放弃 Generator 时应调用 generator.return() 显式关闭,释放内存。

本章评分
4.5  / 5  (16 评分)

💬 留言讨论