3. JavaScript运行时与执行机制

JavaScript 是单线程语言,但浏览器和 Node.js 却能同时处理网络请求、定时器、用户交互和渲染。支撑这种“并发假象”的核心,就是运行时的事件循环(Event Loop)机制。理解它,是排查页面卡顿、回调延迟、内存泄漏等问题的前提。

调用栈、任务队列与事件循环

运行时主要由三部分组成:调用栈(Call Stack)、任务队列(Task Queue)和事件循环。调用栈负责执行同步代码,遵循后进先出;当遇到异步 API(如 setTimeout、fetch、DOM 事件)时,浏览器会把回调交给对应的 Web API 线程处理,处理完成后将回调放入任务队列。事件循环则不断检查:调用栈是否为空?若为空,就从队列中取出一个任务压入栈执行。

关键点在于:只有同步代码全部执行完,调用栈清空后,事件循环才会开始处理队列中的回调。这解释了一个常见现象——setTimeout(fn, 0) 并不会立即执行,它至少要等到当前同步脚本跑完。

宏任务与微任务:优先级差异

任务队列并非只有一条。规范将异步任务分为两类:

  • 宏任务(MacroTask):setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染、MessageChannel 等。
  • 微任务(MicroTask):Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node)。

事件循环每一轮的执行顺序是:取出一个宏任务执行 → 调用栈清空 → 清空所有微任务(包括微任务执行过程中新产生的微任务)→ 如有需要执行渲染 → 进入下一轮。也就是说,微任务的优先级高于宏任务,且会在当前宏任务结束后被“一次性清空”。

下面这段代码是面试与实战中高频出现的顺序判断题,请先自己推演,再看注释结果:

console.log('1 script start');

setTimeout(() => {
  console.log('2 setTimeout');        // 宏任务
}, 0);

Promise.resolve()
  .then(() => console.log('3 promise then'))
  .then(() => console.log('4 promise then2'));

queueMicrotask(() => console.log('5 queueMicrotask'));

console.log('6 script end');

// 输出顺序:
// 1 script start
// 6 script end
// 3 promise then
// 5 queueMicrotask
// 4 promise then2
// 2 setTimeout

推演逻辑:同步代码先输出 1、6;调用栈清空后清空微任务队列,依次输出 3、5,而 3 的 then 又产生新微任务 4,仍在同一轮清空;微任务全部结束后,才执行宏任务 setTimeout,输出 2。

内存管理与垃圾回收

JavaScript 内存分为栈内存(存放基本类型和引用地址)和堆内存(存放对象、数组、函数等引用类型)。垃圾回收(GC)主要采用标记清除算法:从根对象(全局对象、当前调用栈中的变量)出发,标记所有可达对象,未被标记的即视为垃圾回收。现代引擎还引入分代回收,将对象分为新生代和老生代,新生代用 Scavenge 算法快速回收,老生代用标记整理减少碎片。

内存泄漏的本质是:对象已经不再需要,但仍被根对象可达引用链持有,导致 GC 无法回收。常见场景包括:

  • 未清理的定时器或事件监听器,回调闭包持续引用外部大对象;
  • 全局变量意外挂载,如遗漏 var/let/const 造成隐式全局;
  • 闭包长期持有不再使用的大数组或 DOM 节点;
  • DOM 已移除但 JS 仍持有其引用,形成“游离 DOM”。

性能瓶颈的定位与优化

事件循环视角下,性能瓶颈通常表现为两类:长任务阻塞和微任务饥饿。长任务指单个宏任务执行时间过长(一般超过 50ms),会阻塞渲染和后续任务,造成掉帧、点击无响应。微任务饥饿指微任务中不断产生新微任务,导致队列永远清空不完,事件循环无法进入下一轮,页面同样卡死。

定位手段上,可用 Chrome DevTools 的 Performance 面板录制,观察 Main 线程上的长条任务;用 Memory 面板拍摄堆快照(Heap Snapshot),对比多次快照中持续增长且未被回收的对象;也可用 performance.now() 在代码中打点测量关键路径耗时。

优化原则:把大计算拆成多个小任务,用 setTimeout 或 requestIdleCallback 让出主线程;对纯计算密集型任务考虑 Web Worker;及时 clearTimeout、removeEventListener、断开 IntersectionObserver;避免在微任务中无限递归。

事件循环执行模型与任务优先级 调用栈 Call Stack 同步代码执行 函数入栈 / 出栈 栈空 → 触发下一轮 微任务队列 MicroTask Promise.then / queueMicrotask MutationObserver 优先级高 · 每轮全部清空 宏任务队列 MacroTask setTimeout / setInterval I/O · UI 渲染 · MessageChannel 每轮仅取一个执行 Event Loop 检查栈空 → 调度任务 栈空通知 先清空微任务 再取一个宏任务 浏览器渲染 Render 微任务清空后 · 视需要执行 循环下一轮 执行顺序:同步代码 → 清空微任务 → 渲染(如需)→ 取一个宏任务 → 重复