JS 代码是如何跑起来的?带你深入 V8 引擎和事件循环的幕后

嘿,小伙伴们,我是老码小张。平时写 JavaScript 代码,有没有遇到过这种情况:你明明写了个 setTimeout(callback, 0),心想这回调函数总该立刻执行了吧?结果呢,它偏偏就是插队到了后面,比你后面写的同步代码还要晚执行。或者有时候,一个复杂的计算或者一个没写好的循环,直接让你的网页卡住不动,用户体验直线下降?

这些现象背后,其实都藏着 JavaScript 的底层运行机制。很多初级开发者可能觉得,我只要会写业务代码就行了,了解这些底层原理有啥用?用处可大了!搞懂了 JS 是怎么跑起来的,你就能更从容地处理异步逻辑,写出性能更好的代码,遇到疑难杂症时也能更快地定位问题。

今天,咱们就来一起扒一扒 JavaScript 代码执行的幕后英雄------JS 引擎 (以大名鼎鼎的 V8 为例)以及它处理异步任务的"调度中心"------事件循环(Event Loop)。准备好了吗?发车!

一、你的 JS 代码,谁来执行?------认识 JavaScript 引擎

咱们写的 JavaScript 代码,本质上就是一堆字符串。浏览器或者 Node.js 环境本身并不直接认识这些字符串,需要一个"翻译官"兼"执行者"来把它们变成机器能懂的指令并运行起来。这个角色,就是 JavaScript 引擎

市面上有很多 JS 引擎,比如 Chrome 和 Node.js 用的 V8 ,Firefox 用的 SpiderMonkey ,Safari 用的 JavaScriptCore 等等。虽然名字不同,但它们的核心工作流程大同小异。咱们今天重点聊聊 V8。

你可以把 V8 引擎想象成一个高度优化的工厂,你的 JS 代码就是原材料,最终产出的是执行结果。这个工厂内部大致是这样运作的:

  1. 解析(Parsing)

    • 代码首先会被解析器(Parser)接收。解析器会进行词法分析 ,把你的代码字符串打碎成一个个有意义的单元,叫做 Token (比如 leta=1 这些)。
    • 然后进行语法分析 ,根据语法规则把这些 Token 组合成一个树形结构,叫做抽象语法树(Abstract Syntax Tree, AST)。AST 非常重要,它清晰地表达了代码的结构和意图,后续的编译和优化都基于它。
    graph LR; A[JS 源代码] --> B(解析器 Parser); B --> C(词法分析); C --> D[Tokens]; D --> E(语法分析); E --> F[抽象语法树 AST];
  2. 编译与执行(Compilation & Execution)

    • 拿到 AST 后,V8 并不会直接逐行解释执行(那样太慢了)。它采用了更高效的 JIT(Just-In-Time)编译技术。
    • 首先,AST 会被一个叫 Ignition解释器 快速转换成字节码(Bytecode)。字节码是一种中间代码,比机器码更抽象,但比 AST 更接近底层,执行效率比直接解释 AST 高。
    • 解释器在执行字节码的同时,还会收集一些信息,比如哪些函数被频繁调用(称为"热点函数"),哪些变量类型是固定的等等。
    • 对于那些"热点函数",V8 会启动它的优化编译器(TurboFan) 。TurboFan 会利用收集到的信息,把这些热点函数的字节码编译成高度优化的机器码,直接交给 CPU 执行,速度嗖嗖的!
    • 如果优化后的代码在运行时发现之前的假设不成立(比如一个变量的类型变了),优化就会被取消(Deoptimization),回退到执行字节码。
    graph TD; A[抽象语法树 AST] --> B(解释器 Ignition); B --> C[字节码 Bytecode]; C -- 执行 --> D{运行结果}; B -- 同时收集信息 --> E(分析数据); E -- "识别热点代码" --> F(优化编译器 TurboFan); F --> G[优化后的机器码]; G -- 执行 --> D; G -- "优化失效 (Deoptimization)" --> C;

这个 JIT 机制,就像是给你的代码装了个涡轮增压,平时用经济模式(解释执行字节码)跑,遇到需要飙车的地方(热点函数)就自动切换到运动模式(执行优化机器码),兼顾了启动速度和运行效率。

二、代码跑起来的地方:调用栈与内存堆

引擎开始执行代码了,那具体是在哪里执行,数据又存放在哪里呢?这里就引出了两个核心概念:调用栈(Call Stack)内存堆(Memory Heap)

  • 调用栈(Call Stack)

    • 这是一个后进先出(LIFO)的数据结构。当一个函数被调用时,会为这个函数创建一个执行帧(Execution Frame) ,并把它 压入(push) 栈顶。这个帧里包含了函数的参数、局部变量等信息。
    • 如果这个函数内部又调用了其他函数,那么新的函数帧会被继续压入栈顶。
    • 当一个函数执行完毕(return 或 抛出异常),它的执行帧就会从栈顶弹出(pop)
    • JS 是单线程的,意味着同一时间只能做一件事,这个"事"就是在调用栈顶的那个函数。如果一个函数执行时间过长(比如死循环或大量计算),就会阻塞整个调用栈,后续任务无法执行,页面就会卡顿,这就是所谓的"阻塞主线程"。

    来看个简单的例子:

    javascript 复制代码
    function funcB() {
      console.log('进入 B');
      // ... do something ...
      console.log('离开 B');
    }
    
    function funcA() {
      console.log('进入 A');
      funcB();
      console.log('离开 A');
    }
    
    console.log('开始');
    funcA();
    console.log('结束');

    它的调用栈变化大致如下(简化表示):

    graph TD; subgraph 调用栈变化 direction LR Start --> Step1 --> Step2 --> Step3 --> Step4 --> Step5 --> End end subgraph Step1 [执行 console.log '开始'] S1[main] end subgraph Step2 [调用 funcA] S2_1[funcA] --> S2_2[main] end subgraph Step3 [funcA 调用 funcB] S3_1[funcB] --> S3_2[funcA] --> S3_3[main] end subgraph Step4 [funcB 执行完毕] S4_1[funcA] --> S4_2[main] end subgraph Step5 [funcA 执行完毕] S5[main] end subgraph End [执行 console.log '结束' ] S6[main] end Start --- |main入栈| Step1 Step1 --- |funcA入栈| Step2 Step2 --- |funcB入栈| Step3 Step3 --- |funcB出栈| Step4 Step4 --- |funcA出栈| Step5 Step5 --- |main执行完毕| End

    (注意:为了简洁,这里用 main() 代表全局执行上下文)

  • 内存堆(Memory Heap)

    • 这是一个用于存储 对象(Object) 的地方。简单来说,除了基本类型(Number, String, Boolean, null, undefined, Symbol, BigInt)的值通常直接存在栈上(或优化后存在寄存器),其他像对象、数组、函数等复杂类型的数据,都是在堆里面分配内存空间的。
    • 当我们在代码里创建一个对象 let obj = { name: '小张' }; 时,obj 这个变量(存在栈上)存的是一个地址,指向堆内存中实际存储 { name: '小张' } 这块数据的地方。
    • 垃圾回收(Garbage Collection)机制会定期扫描堆内存,找出那些不再被引用的对象,并回收它们占用的空间。

三、解密异步:事件循环(Event Loop)与任务队列

好了,重头戏来了!JS 是单线程的,一次只能干一件事。那像 setTimeoutfetch 请求、DOM 事件监听这些耗时的或者需要等待的操作怎么办?难道要一直等着它们完成,让页面卡死吗?当然不行!这就是异步 机制发挥作用的地方,而事件循环(Event Loop) 就是这一切的调度核心。

除了 JS 引擎,浏览器或 Node.js 环境还提供了很多宿主 API,比如:

  • 浏览器环境 :DOM API、setTimeout/setIntervalXMLHttpRequest/fetch、用户事件(点击、滚动)等。
  • Node.js 环境 :文件 I/O (fs模块)、网络请求 (http模块)、setTimeout/setInterval 等。

这些 API 通常是 C++ 实现的,可以在后台执行,不会阻塞 JS 主线程。当我们在 JS 里调用这些异步 API 时(比如 setTimeout(myCallback, 1000)),实际上是:

  1. JS 引擎告诉宿主环境:"喂,老兄,帮我启动一个定时器,1 秒后执行 myCallback 这个函数。"
  2. 然后 JS 引擎就继续往下执行同步代码了,不等定时器。
  3. 宿主环境(比如浏览器的定时器模块)在后台默默计时。
  4. 1 秒后,定时器模块发现时间到了,就把 myCallback 函数放进一个叫 任务队列(Task Queue) 的地方排队。注意,只是放进去排队,还没执行!

那什么时候执行呢?事件循环(Event Loop) 登场!

事件循环是一个持续运行的过程,它的任务就是不断地检查:

  • 调用栈(Call Stack)是不是空的? (也就是当前有没有 JS 同步代码在执行)
  • 任务队列(Task Queue)里有没有待处理的任务?

一旦调用栈变空了 ,事件循环就会从任务队列里取出一个 任务(比如刚才那个 myCallback),把它压入调用栈,让 JS 引擎去执行。

这个过程可以用下图来表示:

解答开篇问题 :为什么 setTimeout(callback, 0) 不会立即执行? 因为它虽然设置的延迟是 0 毫秒,但 callback 仍然会被交给宿主环境的定时器模块,然后被放入任务队列。事件循环必须等到当前调用栈里的所有同步代码都执行完毕后,才会去任务队列里捞这个 callback 来执行。所以,它总是在当前脚本的同步代码之后执行。

微任务(Microtask)与宏任务(Macrotask)

为了处理更精细的异步场景(比如 Promise),任务队列其实还分优先级。我们通常说的任务队列,存放的是宏任务(Macrotask) ,像 setTimeout, setInterval, I/O 操作, UI 渲染等的回调都属于宏任务。

还有一种优先级更高的队列,叫微任务队列(Microtask Queue)Promise.then/catch/finally 的回调、MutationObserver 的回调、Node.js 的 process.nextTick 都属于微任务。

事件循环的规则是:

  1. 执行完调用栈中的同步代码。
  2. 检查微任务队列,清空所有微任务(执行它们,如果在执行微任务的过程中又产生了新的微任务,也会在本轮一并清空)。
  3. (可选)进行 UI 渲染(浏览器环境)。
  4. 从宏任务队列中取出一个宏任务,压入调用栈执行。
  5. 回到第 1 步,不断循环。

关键点 :每一轮事件循环,只会执行一个 宏任务,但会执行所有 当前存在的微任务。微任务的优先级更高,总是在下一个宏任务开始之前被清空。这就是为什么 Promise 的 .then 会比 setTimeout(..., 0) 更早执行的原因。

四、实践干货:理解原理如何指导编码

懂了这些底层机制,对我们写代码有啥实际帮助呢?

  1. 避免阻塞主线程:知道了调用栈是单线程且会阻塞,就要避免在主线程执行长时间的同步计算。对于复杂计算,可以考虑使用 Web Workers 将其放到后台线程处理;对于 I/O 操作,要善用异步 API(Promise, async/await)。
  2. 理解异步顺序 :分清宏任务和微任务,能帮你准确预测代码执行顺序,尤其是在混合使用 setTimeout、Promise、async/await 时,调试起来更有底气。
  3. 优化性能:了解 V8 的 JIT 优化机制,虽然我们不能直接控制,但可以写出更符合优化器"口味"的代码,比如尽量保持函数参数和变量类型的稳定,避免在热点函数中进行频繁的对象结构变化等。(当然,现代 V8 已经很智能,过早优化有时并不可取,但理解原理总没错)。
  4. 调试疑难杂症:遇到奇怪的异步问题,或者页面卡顿,可以从调用栈、事件循环、任务队列的角度去分析,更容易找到根源。

好了,今天关于 JavaScript 如何运行的幕后故事就先聊到这。咱们一起探索了 V8 引擎的解析、编译(JIT),了解了调用栈和内存堆的作用,还深入了事件循环、宏任务与微任务的调度机制。

希望这次的分享,能让你对平时写的 JS 代码多了一份理解。记住,基础打得牢,上层建筑才能盖得稳!

我是老码小张,一个喜欢研究技术原理,并且在实践中不断成长的技术人。如果你觉得这篇文章对你有帮助,或者有什么想法想交流,欢迎在评论区留言。咱们下次再见!

相关推荐
南雨北斗7 分钟前
分布式锁
后端
佳腾_11 分钟前
【Web应用服务器_Tomcat】三、Tomcat 性能优化与监控诊断
前端·中间件·性能优化·tomcat·web应用服务器
再拼一次吧13 分钟前
Spring进阶篇
java·后端·spring
brzhang15 分钟前
告别 CURD,走向架构:一份帮你打通任督二脉的知识地图
前端·后端·架构
南雨北斗18 分钟前
分布式系统的优缺点
后端
Moment22 分钟前
在 React 里面实现国际化实现是太简单了 🙂‍↔️🙂‍↔️🙂‍↔️
前端·javascript·react.js
兜小糖的小秃毛24 分钟前
el-Input输入数字自动转千分位进行展示
前端·javascript·vue.js
兜小糖的小秃毛24 分钟前
文号验证-同时对两个输入框验证
开发语言·前端·javascript
brzhang25 分钟前
代码越写越乱?掌握这 5 种架构模式,小白也能搭出清晰系统!
前端·后端·架构
Asthenia041227 分钟前
为什么MySQL关联查询要“小表驱动大表”?深入解析与模拟面试复盘
后端