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 代码多了一份理解。记住,基础打得牢,上层建筑才能盖得稳!

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

相关推荐
GetcharZp15 小时前
玩转 Linux 机器视觉:手把手带你搞定 Ubuntu 下海康工业相机 C++ SDK
后端
橙子家16 小时前
浏览器缓存之【基础键值存储】:Local storage 和 Session storage
前端
星星在线18 小时前
MusicFree:一个「All in One」的个人音乐服务器,让听歌回归简单
前端·后端
IT_陈寒19 小时前
Redis的SETNX并发问题让我加了三天班
前端·人工智能·后端
demo007x19 小时前
Docling 文档转换以及技术架构分析
前端·后端·程序员
京东云开发者20 小时前
京东市民服务又“上新”!这次是黑龙江“龙易办”
前端
袋鱼不重21 小时前
我的神奇同事,AI 用多了居然写了个 Open In Codex
前端·后端·ai编程
用户83562907805121 小时前
使用 Python 操作 Word 内容控件
后端·python
像我这样帅的人丶你还21 小时前
啥? 前端也要会干Java?🛵🛵🛵
后端
竹林81821 小时前
Web3表单签名验证:我用 wagmi 和 ethers 给 DApp 加了一个“免密登录”,踩坑记录全在这了
javascript