浏览器是怎么执行js文件的

作者:页面旅途时间:2026-11-03 20:30:36浏览:158

深入解析浏览器从网络下载、V8引擎词法与语法分析、字节码生成、JIT即时编译优化,到运行时调用栈、事件循环与垃圾回收的完整执行链路。

浏览器是怎么执行js文件的
浏览器端执行 JavaScript 的全链路阶段概览

简单来说,浏览器执行一个 JavaScript 文件需要依次走过五个阶段:通过网络线程下载脚本文件、由 JavaScript 引擎进行词法与语法分析并生成抽象语法树(AST)、解释器生成字节码并启动即时编译(JIT)优化为机器码、在调用栈与事件循环(Event Loop)机制驱动下执行具体的语句与异步任务,并在运行过程中由垃圾回收器(GC)自动管理与释放内存堆中的无用对象。任何一段看似简单的代码,其背后都是网络管线、浏览器内核主线程与脚本引擎底层组件精密协作的结果。

一、资源的获取与加载时机

浏览器在渲染网页时,主线程的 HTML 解析器(HTML Parser)自上而下逐行将 HTML 标记转换为 DOM 树节点。当解析器在文档中读到 <script src="..."> 标签时,执行流程便开始介入。

默认情况下,JavaScript 的执行具有解析阻塞性。因为 JavaScript 代码有可能通过 document.write 动态写入内容或修改既有的 DOM 结构,浏览器为了确保 DOM 树构建的准确性,必须停下当前的 HTML 解析工作。网络线程开始发起 HTTP/HTTPS 请求去下载目标 .js 文件,待文件完全下载到本地后,引擎立即在主线程中完成解析与执行,直到脚本执行完毕,HTML 解析器才会恢复继续向下解析。

为了避免外链脚本卡顿导致页面长时间白屏,现代浏览器提供了 async 与 defer 两种异步加载属性,它们的下载与执行时机有明显差异:

浏览器是怎么执行js文件的
图 1:三种脚本加载模式的执行时序与主线程阻塞对比
  • 普通脚本(<script>):遇到标签即暂停 HTML 解析,发起网络请求并等待下载,下载完成后立刻执行,执行完毕后才恢复 HTML 解析。如果脚本体积大且位于 <head> 内,会直接导致明显的页面渲染卡顿。
  • 异步脚本(<script async>):网络下载由后台独立网络线程并行完成,期间主线程继续解析 HTML。但一旦下载完成,浏览器会立刻暂停主线程当前的 HTML 解析,立即执行该脚本。如果有多个 async 脚本,它们的执行顺序取决于谁先下载完成,与它们在文档中的书写顺序无关。
  • 延迟脚本(<script defer>):脚本同样由后台独立线程并行下载,且完全不打断 HTML 解析过程。所有 defer 脚本会在 HTML 解析完毕、DOMContentLoaded 事件触发前,严格按照它们在 HTML 中声明的先后顺序依次执行。
实用准则:如果脚本依赖完整的 DOM 树,或者多个脚本之间有严格的加载依赖顺序(例如先加载核心类库,再加载业务代码),必须优先使用 defer;只有那些彼此完全独立且不操作当前 DOM 结构的辅助脚本(如独立的数据埋点或性能监控 SDK),才适合使用 async。

二、V8 引擎的解析与编译机制

当 .js 文件的字符流被下载到内存后,浏览器内核会将代码交付给内部的 JavaScript 引擎(如 Chrome 与 Node.js 使用的 V8、Firefox 的 SpiderMonkey 或 Safari 的 JavaScriptCore)。以当今最主流的 V8 引擎为例,其内部遵循一套将“快速启动”与“高效运行”平衡到极致的流水线架构。

浏览器是怎么执行js文件的
图 2:V8 JavaScript 引擎的解析、编译与 JIT 优化流水线

1. 词法分析与语法分析(Parsing)

JavaScript 源码本质上只是一串由人类可读字符组成的字符串。引擎无法直接理解这些字符,第一步就是由扫描器(Scanner)进行词法分析(Lexing),将字符流拆解为一个个具备语义的词法单元(Tokens),比如关键字 let、标识符 total、操作符 =、数字常量 100 等。

紧接着,解析器(Parser)对 Tokens 进行语法分析,验证代码是否符合语言语法规范,并构建出计算机能够理解的树状数据结构——抽象语法树(AST,Abstract Syntax Tree),同时生成记录变量生命周期与可见范围的作用域(Scope)。

为了缩短首屏启动时间,V8 采用了预解析(Pre-parser)与全量解析(Full-parser)相结合的策略。如果一个函数声明后没有被立即调用,预解析器只会做基本的语法检查,记录函数在源码中的位置与外部引用,而不会为其生成完整 AST。只有当该函数第一次在运行时被调用时,才会触发全量解析,从而大幅节约内存与 CPU 开销。

2. 字节码生成与 Ignition 解释器

早期的 V8 曾尝试直接将 JavaScript 源码编译为原生机器码,但这种做法会导致内存占用暴增,且面对移动端有限的内存环境表现很差。现代 V8 引入了名为 Ignition 的解释器。

Ignition 会把 AST 快速转换为统一规范的字节码(Bytecode)。字节码比机器码紧凑得多,通常只有机器码体积的四分之一左右。Ignition 能够快速逐行解释并执行字节码,使页面无需等待复杂的代码优化就能以毫秒级的速度启动运行。

3. TurboFan 的 JIT 优化与去优化(Deoptimization)

在 Ignition 解释执行字节码的同时,引擎内部设置了性能监控机制(Profiler)。如果某段函数被反复调用数十次以上,或者某个循环体内执行了大量操作,该代码段就会被判定为热点代码(Hotspot)。

此时,Ignition 会把收集到的类型反馈向量(Type Feedback Vector)以及该函数的字节码一并提交给 JIT 优化编译器 TurboFan。TurboFan 会基于已知的输入参数类型进行激进的推断优化,生成极具执行效率的 CPU 原生机器码,后续再次调用该函数时便可绕过解释器,直接以汇编级别的速度运行。

// 示例:类型稳定函数(利于 JIT 优化)
function add(x, y) {
  return x + y;
}

// 连续调用万次,传入的始终为 32 位整型
for (let i = 0; i < 10000; i++) {
  add(i, 1); // TurboFan 会将 add 深度内联并编译为原生整型加法指令
}

// 突然传入字符串,类型假设被打破
add("hello", " world"); 
// 触发 Deoptimization(去优化),抛弃机器码,安全回退至 Ignition 字节码解释执行

当动态传入非预期的类型时,机器码内部设置的守卫条件(Bailout Check)被触发,引擎会执行去优化(Deoptimization),丢弃此前生成的机器码,重新将执行权切回 Ignition 解释器。因此,在日常编码中保持函数参数结构与类型的单一性(Monomorphic),是让 JavaScript 保持巅峰性能的关键习惯。

三、运行时的调用栈与事件循环

代码经过编译后,进入实际的语句执行阶段。由于浏览器的 JavaScript 引擎在渲染进程中采用单线程模型(同一时间只能执行一段代码),它依靠调用栈(Call Stack)与事件循环(Event Loop)来协调同步与异步逻辑。

浏览器是怎么执行js文件的
图 3:浏览器运行时环境(调用栈、堆内存、Web APIs 与事件循环调度)

1. 执行上下文与调用栈

当脚本首次执行时,引擎会创建一个全局执行上下文(Global Execution Context)并将其压入调用栈底。每当一个函数被调用,引擎会为该函数创建全新的函数执行上下文压入栈顶。上下文的建立包含两步:

  • 创建阶段:确定 this 绑定目标;创建词法环境(Lexical Environment)与变量环境(Variable Environment),完成变量和函数声明的收集(这就是“变量提升”概念的底层本质,其中 let/const 在此阶段虽已声明但处于暂存死区 TDZ,在执行到声明语句前不可访问)。
  • 执行阶段:逐行赋值并执行语句。函数执行结束,其上下文从栈顶弹出,控制权交还给下层的调用方。

2. 异步任务与任务队列分类

单线程意味着如果发起一个需要耗时 2 秒的网络请求,主线程绝不能干等。浏览器利用自身多线程的宿主环境来解决这一瓶颈:当调用栈遇到 fetch()、setTimeout() 或 DOM 监听时,它会将底层工作交给浏览器的其他线程(网络线程、定时器线程等)去处理,主线程继续同步执行栈内剩余代码。

当异步事件完成后,后台线程会将对应的回调函数包装后推入任务队列中。事件循环规定了两类不同优先级的任务队列:

队列类型 主要产生来源 调度与执行机制
微任务(Microtasks) Promise.then/catch/finally、queueMicrotask()、MutationObserver 调用栈一旦变为空,必须一次性将微任务队列中的所有任务全部执行完毕。在微任务执行期间产生的新的微任务,也会在当前周期内一并清空。
宏任务(Macrotasks) setTimeout、setInterval、整体脚本、I/O 操作、UI 交互事件 在事件循环的一个轮次(Tick)中,引擎仅从宏任务队列头部取出一个任务压入调用栈执行,随后立刻转去检查并清空微任务队列。
// 验证执行时序的典型示例
console.log('1. 同步开始');

setTimeout(() => {
  console.log('4. 宏任务定时器触发');
}, 0);

Promise.resolve().then(() => {
  console.log('3. 微任务触发');
});

console.log('2. 同步结束');

// 实际输出顺序:
// 1. 同步开始
// 2. 同步结束
// 3. 微任务触发
// 4. 宏任务定时器触发

在清空微任务队列之后、取出下一个宏任务之前,浏览器会根据当前屏幕刷新率(如 60Hz 对应每 16.6ms 一帧)以及是否有样式与布局变更,决定是否执行样式计算(Style)、布局(Layout)、绘制(Paint)与合成(Composite)。如果主线程执行了一个耗时 200 毫秒的长同步循环,调用栈将长时间无法清空,事件循环彻底停滞,导致微任务无法执行、页面无法响应用户点击与滚动,出现明显的掉帧甚至页面无响应崩溃。

四、内存管理与垃圾回收

在 JavaScript 执行期间,简单类型变量直接存储在调用栈中,而复杂对象、函数闭包则存放在堆内存(Heap)中。由于 JavaScript 是一门自动垃圾回收的语言,V8 引擎通过一套分代式垃圾回收算法来保持内存的健康。

1. 新生代与 Scavenge 算法

绝大多数对象在代码中扮演临时角色,生命周期往往极其短暂。V8 将堆划分为新生代(Young Generation)和老生代(Old Generation)。

新生代空间一般只有数兆字节到几十兆字节,采用 Scavenge 算法结合 Cheney 复制机制管理。新生代内存被均分为活动使用的 From 空间 与空闲备用的 To 空间。发生 GC 时,存活的对象会被直接复制到 To 空间,而无用对象被原地忽略。随后两块空间的角色互换。当一个对象经过两次 Scavenge 回收依然存活,或者 To 空间 使用率超过 25% 时,该对象会被晋升(Promotion)到老生代区域中。

2. 老生代与全停顿优化

老生代存放经过时间检验的长寿命对象以及体积过大的对象。老生代采用标记清除(Mark-Sweep)与标记整理(Mark-Compact)算法:从全局对象及调用栈根节点出发进行可达性遍历,没有被引用的孤立对象被标记为垃圾;随后将存活对象向内存一端移动压紧,彻底消除碎片化的缝隙。

传统的垃圾回收会导致 JavaScript 主线程完全暂停,这一现象称为全停顿(Stop-The-World)。现代 V8 引入了增量标记(Incremental Marking)与并发标记(Concurrent Marking):垃圾回收器在后台辅助线程中与主线程 JavaScript 并发运行,或者将一次冗长的全量标记拆分为许多只有数毫秒的微小停顿,穿插在事件循环间隙完成,从而消除了因垃圾回收而引起的人眼可感知的画面卡顿。

五、全生命周期总结与开发建议

综上所述,浏览器执行一个 JS 文件是一项涉及“网络加载 - 引擎编译 - 栈与队列运行时 - 内存回收”的系统工程。理解这一底层运作流程,能够直接指导我们写出更高性能、更少隐患的前端代码。

浏览器是怎么执行js文件的
图 4:浏览器执行 JS 文件的五大关键阶段总结与排障对照矩阵

结合本文阐述的底层执行原理,在实际项目开发中应遵循以下核心原则:

  1. 合理规划脚本引入策略:在 HTML 中合理配置 defer 与 async,消除不必要的 DOM 解析阻塞。首屏非必需业务组件采用动态 import() 按需代码分割。
  2. 编写引擎友好的稳定代码:尽量让对象的形状(Shape / Hidden Class)与函数入参类型保持稳定,避免在同一函数中频繁传递不同数据类型,防止 TurboFan 发生昂贵的去优化回退。
  3. 严防单线程长任务(Long Tasks):不要在主线程中执行超过 50ms 的密集型同步计算。如有大规模数据过滤、文件解析或图像计算需求,应将其移交给 Web Worker 在独立线程中运行。
  4. 善待事件循环与微任务:了解 Promise 与定时器的优先层级,切勿在微任务队列中无节制地递归生成新的微任务,否则调用栈与微任务队列将永久占据主线程,导致浏览器无法执行 UI 渲染。
  5. 谨防内存泄漏:在页面切换或组件销毁时,务必主动解绑挂载在 window 上的事件监听器,清理未完成的 setInterval 定时器,并避免全局闭包意外持有不再使用的巨型数据对象。
作者最新文章

相关标签:

相关文章
更多