嘿,小伙伴们,我是老码小张。平时写 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 代码就是原材料,最终产出的是执行结果。这个工厂内部大致是这样运作的:
-
解析(Parsing):
- 代码首先会被解析器(Parser)接收。解析器会进行词法分析 ,把你的代码字符串打碎成一个个有意义的单元,叫做 Token (比如
let
、a
、=
、1
这些)。 - 然后进行语法分析 ,根据语法规则把这些 Token 组合成一个树形结构,叫做抽象语法树(Abstract Syntax Tree, AST)。AST 非常重要,它清晰地表达了代码的结构和意图,后续的编译和优化都基于它。
graph LR; A[JS 源代码] --> B(解析器 Parser); B --> C(词法分析); C --> D[Tokens]; D --> E(语法分析); E --> F[抽象语法树 AST]; - 代码首先会被解析器(Parser)接收。解析器会进行词法分析 ,把你的代码字符串打碎成一个个有意义的单元,叫做 Token (比如
-
编译与执行(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 是单线程的,意味着同一时间只能做一件事,这个"事"就是在调用栈顶的那个函数。如果一个函数执行时间过长(比如死循环或大量计算),就会阻塞整个调用栈,后续任务无法执行,页面就会卡顿,这就是所谓的"阻塞主线程"。
来看个简单的例子:
javascriptfunction 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 是单线程的,一次只能干一件事。那像 setTimeout
、fetch
请求、DOM 事件监听这些耗时的或者需要等待的操作怎么办?难道要一直等着它们完成,让页面卡死吗?当然不行!这就是异步 机制发挥作用的地方,而事件循环(Event Loop) 就是这一切的调度核心。
除了 JS 引擎,浏览器或 Node.js 环境还提供了很多宿主 API,比如:
- 浏览器环境 :DOM API、
setTimeout
/setInterval
、XMLHttpRequest
/fetch
、用户事件(点击、滚动)等。 - Node.js 环境 :文件 I/O (
fs
模块)、网络请求 (http
模块)、setTimeout
/setInterval
等。
这些 API 通常是 C++ 实现的,可以在后台执行,不会阻塞 JS 主线程。当我们在 JS 里调用这些异步 API 时(比如 setTimeout(myCallback, 1000)
),实际上是:
- JS 引擎告诉宿主环境:"喂,老兄,帮我启动一个定时器,1 秒后执行
myCallback
这个函数。" - 然后 JS 引擎就继续往下执行同步代码了,不等定时器。
- 宿主环境(比如浏览器的定时器模块)在后台默默计时。
- 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
都属于微任务。
事件循环的规则是:
- 执行完调用栈中的同步代码。
- 检查微任务队列,清空所有微任务(执行它们,如果在执行微任务的过程中又产生了新的微任务,也会在本轮一并清空)。
- (可选)进行 UI 渲染(浏览器环境)。
- 从宏任务队列中取出一个宏任务,压入调用栈执行。
- 回到第 1 步,不断循环。
关键点 :每一轮事件循环,只会执行一个 宏任务,但会执行所有 当前存在的微任务。微任务的优先级更高,总是在下一个宏任务开始之前被清空。这就是为什么 Promise 的 .then
会比 setTimeout(..., 0)
更早执行的原因。
四、实践干货:理解原理如何指导编码
懂了这些底层机制,对我们写代码有啥实际帮助呢?
- 避免阻塞主线程:知道了调用栈是单线程且会阻塞,就要避免在主线程执行长时间的同步计算。对于复杂计算,可以考虑使用 Web Workers 将其放到后台线程处理;对于 I/O 操作,要善用异步 API(Promise, async/await)。
- 理解异步顺序 :分清宏任务和微任务,能帮你准确预测代码执行顺序,尤其是在混合使用
setTimeout
、Promise、async/await
时,调试起来更有底气。 - 优化性能:了解 V8 的 JIT 优化机制,虽然我们不能直接控制,但可以写出更符合优化器"口味"的代码,比如尽量保持函数参数和变量类型的稳定,避免在热点函数中进行频繁的对象结构变化等。(当然,现代 V8 已经很智能,过早优化有时并不可取,但理解原理总没错)。
- 调试疑难杂症:遇到奇怪的异步问题,或者页面卡顿,可以从调用栈、事件循环、任务队列的角度去分析,更容易找到根源。
好了,今天关于 JavaScript 如何运行的幕后故事就先聊到这。咱们一起探索了 V8 引擎的解析、编译(JIT),了解了调用栈和内存堆的作用,还深入了事件循环、宏任务与微任务的调度机制。
希望这次的分享,能让你对平时写的 JS 代码多了一份理解。记住,基础打得牢,上层建筑才能盖得稳!
我是老码小张,一个喜欢研究技术原理,并且在实践中不断成长的技术人。如果你觉得这篇文章对你有帮助,或者有什么想法想交流,欢迎在评论区留言。咱们下次再见!