Skip to content

浏览器环境下 JavaScript 异步执行模型解析

引言

JavaScript 作为 Web 开发的核心语言,其在浏览器中的执行模型,特别是异步行为的处理及与宿主环境的交互,对应用性能和用户体验至关重要。许多开发者对事件循环、任务队列有基本认识,但对其底层精确的工作流程、宿主环境如何通过嵌入式 API 控制 JS 引擎、JS 如何通过绑定调用原生功能及其间的数据传递细节缺乏深入了解。

示例代码:

html
<!DOCTYPE html>
<html>
  <body>
    <h1>事件循环与引擎通信深度解析</h1>
    <script>
      console.log('主脚本同步执行:开始');
      var globalInfo = '初始全局信息'; // Variable in global scope

      // setTimeout schedules a macrotask
      setTimeout(() => {
        console.log('宏任务(setTimeout回调)执行: globalInfo =', globalInfo);
        // Example of potentially scheduling a microtask from within a macrotask
        Promise.resolve().then(() => {
          console.log('宏任务执行期间调度的微任务执行');
        });
        globalInfo = '宏任务修改后信息'; // Modify global variable
      }, 1000);

      // Promise schedules a microtask relative to the current synchronous script
      Promise.resolve().then(() => {
        console.log(
          '主脚本同步执行期间调度的微任务执行: globalInfo =',
          globalInfo
        );
        globalInfo = '微任务修改后信息'; // Modify global variable
      });

      console.log('主脚本同步执行:结束');
    </script>
    <p>脚本执行后内容</p>
  </body>
</html>

核心组件与概念

理解 JS 在浏览器中的执行,需掌握以下核心组件和概念:

  • 宿主环境 (Host Environment): 即浏览器,提供 JS 运行上下文,包括 DOM、BOM、Web API(如 setTimeout, console)、事件循环、任务队列等。
  • JavaScript 引擎 (JS Engine): 如 V8,负责解析、编译、执行 JS 代码,管理调用栈和内存堆。
  • 事件循环 (Event Loop): 浏览器协调事件、脚本、渲染等的核心机制。
  • 任务队列 (Task Queues):
    • 宏任务队列 (Macrotask Queue): 存储 setTimeout 回调、I/O、UI 事件等。
    • 微任务队列 (Microtask Queue): 存储 Promise 回调、queueMicrotask 等。在宏任务后、渲染前清空。
  • 调用栈 (Call Stack): 引擎跟踪函数调用的结构。
  • 执行上下文 (Execution Context): 代码执行时的环境(全局或函数)。
  • 词法环境 (Lexical Environment) & 作用域链 (Scope Chain): 定义标识符查找规则。
  • 闭包 (Closure): 函数与其定义时词法环境的组合。
  • 嵌入式 API (Embedding API): JS 引擎提供给宿主(C++等)的接口,用于创建/控制 JS 环境、暴露原生功能、调用 JS 函数。是 Host -> Engine 控制的主要手段。
  • 绑定 (Bindings): 连接 JS 函数调用和宿主原生代码实现的机制。是 JS -> Host 调用的基础。
  • 数据封送 (Marshalling): 在 JS 与原生代码间传递数据时进行的类型转换和内存管理。

详细执行流程分析

HTML 解析与脚本遭遇

  • 文档解析: 浏览器主线程上的 html 解析器 按照 html 标准规范 处理字节流,构建 DOM (Document Object Model) 树。它处理了文档头部信息及 <h1> 元素。
  • <script> 标签识别: 解析器遇到 <script> 的开始标签。
  • 解析阻塞: 对于此常规脚本(无 async/defer),html 解析器 必须暂停 对后续 html(如 <p> 标签)的解析。浏览器需要优先处理此脚本。

脚本准备与 JavaScript 执行环境初始化

  • 脚本源获取: 浏览器获取 <script> 标签内的文本作为 javascript 源代码。
  • 引擎与环境准备: 浏览器准备调用其内嵌的 JavaScript 引擎(如 V8)。如果这是该文档环境首次执行 JS,浏览器会指示引擎创建一个 JavaScript 执行环境 (Realm)。这包括:
    • 初始化 JS 调用栈 (Call Stack)。
    • 创建全局执行上下文 (Global Execution Context) 并压入调用栈。
    • 创建关联的全局词法环境 (Global Lexical Environment) 用于存储全局声明。
    • 创建全局对象 (Global Object)(浏览器中为 window),并与全局环境关联。
  • 调用 javascript 引擎及环境创建: 浏览器准备调用其内嵌的 javascript 引擎(如 V8)。
  • Host -> Engine (通过 Embedding API): 浏览器使用 V8 API 创建 v8::Isolate (虚拟机实例) 和 v8::Context (执行上下文)。此上下文关联一个全局对象 (window)全局词法环境。宿主需使用 v8::HandleScope 管理引擎对象的句柄。
  • 宿主 API 注入与绑定 (关键通信细节):
    • 在引擎执行用户脚本前,浏览器作为宿主,通过 Embedding API(如 v8::ObjectTemplate, v8::FunctionTemplate)在 v8::Context 的全局对象上定义属性。
    • console 对象绑定:
      • 宿主创建一个 v8::ObjectTemplate 代表 console
      • 在此模板上,使用 v8::FunctionTemplate 定义 log 等方法,并使用 SetCallHandler() 将这些模板绑定到相应的浏览器原生 C++ 回调函数(如 BrowserConsoleLogCallback)。
      • 最后,将 console 的对象模板实例化,并设置到全局对象的模板或实例上,属性名为 "console"。生成的 JS console 对象内部关联到浏览器的原生 Console 服务。
    • setTimeout 函数绑定:
      • 宿主创建一个 v8::FunctionTemplate,使用 SetCallHandler() 将其绑定BrowserSetTimeoutCallback C++ 函数。
      • 将此函数模板实例化为一个 JS 函数,并设置为全局对象的 "setTimeout" 属性。
    • 意义: 这个过程确保了 JS 环境中存在 console.logsetTimeout 这些标识符,并且对它们的调用会被引擎路由到绑定的原生实现。

JavaScript 同步执行阶段

  • 引擎执行开始: JS 引擎在已初始化的全局执行上下文中开始执行脚本。
  • 执行 console.log('主脚本同步执行:开始');:
    • 引擎解析,找到 console.log(全局对象上的宿主函数)。
    • JS -> Host 通信 (调用绑定):
      • 引擎准备参数:将 JS 字符串 '主脚本同步执行:开始' 封送 (Marshal) 为 V8 内部表示 (v8::Local<v8::String>)。
      • 引擎激活与 console.log 关联的绑定,调用注册的 C++ 回调函数 BrowserConsoleLogCallback,传递封送后的参数。
      • JS 执行暂停,控制权转移到浏览器原生代码,执行控制台输出。
      • 原生代码执行完毕,控制权交还引擎。
  • 执行 var globalInfo = '初始全局信息';: 引擎在全局词法环境中创建并赋值。
  • 执行 setTimeout(() => {...}, 1000);:
    • 函数对象创建与闭包: 引擎创建箭头函数对象,[[Environment]] 捕获全局环境。
    • JS -> Host 通信 (API 调用):
      • 引擎查找 setTimeout(宿主函数)。
      • 准备参数:将箭头函数的 V8 函数对象引用 (v8::Local<v8::Function>) 和 JS 数字 1000 (封送为 C++ doubleint) 准备好。
      • 引擎通过绑定调用 BrowserSetTimeoutCallback C++ 函数,传递参数。
    • 宿主原生代码执行 (启动定时器):
      • BrowserSetTimeoutCallback 接收到 JS 函数引用和延迟值。
      • 它与浏览器定时器模块交互,启动定时器,并将 JS 函数引用存储起来(通常需创建持久句柄 v8::Persistent<v8::Function> 以跨越 JS 调用边界)。
      • 原生代码生成 Timer ID。
      • Host -> JS 通信 (返回值): 将 Timer ID (C++ 类型) 封送回 JS Number 类型 (v8::Local<v8::Number>),作为 setTimeout 调用的返回值传递给引擎。
      • 原生代码执行完毕,控制权交还引擎(JS 代码未使用返回值)。
  • 执行 Promise.resolve().then(() => {...});:
    • Promise 创建与决议。
    • .then() 注册回调。
    • Engine -> Host 通信 (请求微任务调度): 引擎执行 .then 逻辑时,识别到需要调度微任务。它调用宿主环境提供的异步任务调度接口(通过 Embedding API 实现,如 v8::Isolate::EnqueueMicrotask(callback_function_object)),将 .then 的回调函数对象 (v8::Local<v8::Function>) 传递给宿主。
    • 宿主环境接收此请求,将此回调包装成一个微任务,放入微任务队列
  • 执行 console.log('主脚本同步执行:结束');: 引擎再次通过绑定调用原生 console.log (JS -> Host 通信)。
  • 同步执行完毕: 引擎完成同步脚本,通知浏览器,控制权交还。

HTML 解析恢复

  • 浏览器主线程恢复 HTML 解析,处理后续内容,并可能触发渲染。

定时器独立运行与到期

  • setTimeout 的定时器在后台计时。
  • 约 1000ms 后定时器到期

宏任务入队

  • 定时器模块通知主事件循环基础设施。
  • 基础设施创建一个宏任务(包含之前存储的 JS 函数的 v8::Persistent 句柄)。
  • 此宏任务被放入宏任务队列

事件循环 Tick 精确机制

事件循环持续运行,以下是处理我们异步任务的一个典型 Tick (迭代) 的精确步骤:

  • Tick 前置条件: 调用栈为空。

  • 步骤 7.1: 更新渲染 (可能)

    • 浏览器决策是否执行渲染流水线。
  • 步骤 7.2: 执行微任务检查点 (Perform Microtask Checkpoint)

    • 事件循环必须检查微任务队列。发现 Promise.then 的微任务。
    • 循环清空微任务队列:
      • 出队: 取出微任务。
      • Host -> Engine 通信 (执行回调): 事件循环通过 Embedding API (如 v8::Function::Call(context, global_receiver, argc, argv)) 指示 JS 引擎执行微任务回调。宿主需准备 JS 函数引用(v8::Local<v8::Function>)和调用参数。
      • 引擎执行微任务回调: 引擎压栈、创建上下文、设置作用域链。执行 console.log(...) (发生 JS -> Host 通信,输出 "主脚本...微任务..."),修改 globalInfo。出栈。
    • 微任务队列变空。
  • 步骤 7.3: 选取宏任务

    • 事件循环检查宏任务队列,选择 setTimeout 产生的任务。标记为当前处理任务。
  • 步骤 7.4: 执行选中的宏任务 (setTimeout 回调)

    • Host -> Engine 通信 (执行回调): 事件循环使用 v8::Function::Call() 指示引擎执行 setTimeout 回调(宿主从 v8::Persistent 创建 v8::Local 传递)。
    • 引擎执行宏任务回调: 引擎压栈、创建上下文、设置作用域链。执行回调体:
      • 执行 console.log(...) (JS -> Host 通信,输出 "宏任务...回调...")。
      • 执行 Promise.resolve().then(...):再次发生 Engine -> Host 通信,通过 EnqueueMicrotask 请求调度一个新的微任务。宿主将其放入微任务队列。
      • 修改 globalInfo
    • 回调执行完毕,出栈。清除当前处理任务标记。
  • 步骤 7.5: 再次执行微任务检查点

    • 事件循环再次必须检查微任务队列。发现刚调度的微任务。
    • 循环清空微任务队列:
      • 出队: 取出微任务。
      • Host -> Engine 通信 (执行回调): 事件循环使用 v8::Function::Call() 指示引擎执行回调。
      • 引擎执行微任务回调: 引擎压栈、上下文、作用域链。执行 console.log(...) (JS -> Host 通信,输出 "宏任务执行期间...")。出栈。
    • 微任务队列变空。
  • 步骤 7.6: 更新渲染 (可能)

    • 浏览器再次决策是否渲染。
  • 步骤 7.7: Tick 结束,准备下一轮

    • 当前 Tick 完成,事件循环回到起点或监控状态。

最终状态与控制台输出

  • 调用栈最终为空。
  • 全局变量 globalInfo 的最终值为 '宏任务修改后信息'。
  • 控制台按严格顺序输出了:
    1. 主脚本同步执行:开始
    2. 主脚本同步执行:结束
    3. 主脚本同步执行期间调度的微任务执行: globalInfo = 初始全局信息
    4. 宏任务(setTimeout回调)执行: globalInfo = 微任务修改后信息
    5. 宏任务执行期间调度的微任务执行
  • 事件循环继续运行。

结论

浏览器中 JavaScript 的执行是一个深度耦合、多方协作的系统工程。其核心在于事件循环模型对宏任务和微任务的精确调度,以及浏览器(宿主环境)与 JS 引擎之间通过定义良好的 嵌入式 API (Host 控制 Engine) 和 绑定机制 (JS 调用 Host,包含数据封送) 实现的复杂而高效的通信。理解这些底层机制,包括 API 调用流程、数据封送、异步任务(特别是微任务)的调度接口,对于开发者编写高性能、行为符合预期的 Web 应用至关重要。本报告通过将通信细节深度整合到执行流程的每一步,旨在提供对此过程最为权威和详尽的解析。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)