09 · JavaScript 运行时
本篇解决什么问题: 事件循环、内存回收、模块机制这三件事,决定了你写的代码什么时候跑、占多少内存、能不能被打包优化。 它们是面试必考、也是排查"页面卡了""内存涨了""循环依赖报错"的唯一入口。
📌 与 A04(浏览器与网络)的分工:A04 讲渲染流水线与网络,本篇讲 JS 引擎侧的执行与内存。 A04 只给事件循环的基础结论,本篇做深度拆解。
📌 与 A18(手写题)的分工:本篇讲机制,A18 讲手写实现。
开篇速览
| 主题 | 回答什么问题 | 面试常见度 |
|---|---|---|
| 调用栈与执行上下文 | "为什么报错堆栈是反的?this 到底指向谁?" | ⭐⭐⭐ |
| 事件循环 | "setTimeout(fn, 0) 为什么不是立刻执行?微任务插在哪?" | ⭐⭐⭐⭐⭐ |
| 内存与 GC | "内存泄漏怎么查?闭包一定泄漏吗?" | ⭐⭐⭐⭐ |
| ESM vs CJS | "为什么 Tree Shaking 只对 ESM 有效?循环依赖怎么破?" | ⭐⭐⭐⭐ |
一、调用栈与执行上下文
JS 是单线程的:同一时刻只有一个函数在跑,靠**调用栈(Call Stack)**管理执行顺序。
function c() { throw new Error('boom') }
function b() { c() }
function a() { b() }
a()
// 栈:a → b → c,报错时打印顺序是 c → b → a(栈顶先出)执行上下文(Execution Context) 分三类,每次函数调用都创建一个新的:
| 类型 | 何时创建 | 特点 |
|---|---|---|
| 全局上下文 | 脚本启动 | 只有一个,浏览器里 this === window |
| 函数上下文 | 每次函数调用 | 每个调用都是新上下文(递归会栈溢出) |
| Eval 上下文 | eval() | 严格模式下有独立作用域,永远别用 |
执行上下文的生命周期分两步(这是理解 var 提升与 TDZ 的关键):
- 创建阶段:建作用域链 → 建变量对象(
var初始化为undefined,函数声明整体提升)→ 确定this - 执行阶段:赋值、执行代码
⚠️
let/const在创建阶段也被绑定,但处于 TDZ(暂时性死区),访问会抛ReferenceError—— 这就是"看起来提升了但不能用"的原因。
二、事件循环深挖
2.1 一张图看懂
┌──────────────────────────────┐
│ 调用栈 Call Stack │ ← 同步代码在这里跑
└──────────────┬───────────────┘
│ 空了就去取
┌──────────────▼───────────────┐
│ 微任务队列 Microtask │ ← 清空后才进入下一步
│ Promise.then / queueMicrotask│ **一次循环全部清空**
│ MutationObserver │
└──────────────┬───────────────┘
│ 微任务清空后
┌──────────────▼───────────────┐
│ 渲染时机(可选) │ ← rAF → 样式/布局 → 绘制
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ 宏任务队列 Macrotask │ ← **每次只取一个**
│ setTimeout / setInterval │
│ I/O / 事件回调 / postMessage │
└──────────────────────────────┘核心规则(背这三条就够):
- 同步代码跑完 → 清空所有微任务(不是清空一个)
- 微任务执行中新产生的微任务,会在同一次循环里被清空(可能饿死渲染)
- 取一个宏任务执行 → 回到第 1 步
2.2 经典输出题
console.log('1')
setTimeout(() => console.log('2'))
Promise.resolve().then(() => console.log('3'))
queueMicrotask(() => console.log('4'))
console.log('5')
// 输出:1 5 3 4 2推导:同步打印 1、5 → 清空微任务 3、4 → 取一个宏任务打印 2。
2.3 浏览器 vs Node 的差异(面试高频)
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 微任务 | Promise.then、queueMicrotask、MutationObserver | Promise.then、process.nextTick(优先级更高) |
| 宏任务阶段 | 单一队列 | 分 6 个阶段:timers → pending callbacks → idle/prepare → poll → check → close |
setImmediate | 无 | 存在,在 check 阶段执行 |
process.nextTick | 无 | 每阶段结束后立即清空,优先于 Promise |
Node 11 之后行为与浏览器对齐(每个宏任务后清空微任务),但阶段划分仍是 Node 独有。 详见 A23 · Node.js 与 BFF。
2.4 渲染时机:requestAnimationFrame 在哪
rAF 不是宏任务也不是微任务,它属于渲染阶段,在微任务清空后、下一次绘制前执行:
宏任务 → 微任务全部清空 → rAF 回调 → 样式计算/布局/绘制 → 下一个宏任务推论:
- 在
rAF里改样式,能避免布局抖动(Layout Thrashing) - 用
setTimeout(fn, 0)做动画会丢帧,因为它的执行与屏幕刷新不同步
三、内存管理与 GC
3.1 生命周期
分配 → 使用 → 不再需要时释放。JS 的释放靠 GC(垃圾回收) 自动完成,判断标准是可达性(Reachability):
从根对象(全局对象、当前调用栈、DOM 树)出发,能顺着引用链摸到的就是活对象,摸不到的就回收。
3.2 主流算法:分代回收 + 标记清除
V8 把堆分成两块:
| 区域 | 放什么 | 回收算法 | 特点 |
|---|---|---|---|
| 新生代(New Space) | 新创建的对象 | Scavenge(复制算法) | 快、频繁,多数对象"朝生夕死" |
| 老生代(Old Space) | 存活过多次 GC 的对象 | 标记-清除 + 标记-整理 | 慢、不频繁 |
晋升条件:对象在新生代存活过一轮 GC,或 Scavenge 时 To 空间占用超过 25%。
⚠️ 引用计数(Reference Counting)已不是主流——它无法解决循环引用。 IE8 时代的 COM 对象泄漏正源于此,现代浏览器都用标记清除。
3.3 常见内存泄漏(排查清单)
// ① 意外的全局变量(未声明的变量挂到 window)
function leak() { oops = new Array(1e6) } // 严格模式能避免
// ② 被遗忘的定时器 / 事件监听
const timer = setInterval(fn, 1000) // 组件卸载时忘了 clearInterval
window.addEventListener('resize', fn) // 忘了 removeEventListener
// ③ 闭包持有大对象(闭包本身不泄漏,是它"留住"的对象泄漏)
function outer() {
const big = new Array(1e7)
return () => console.log(big.length) // 只要返回的函数活着,big 就不释放
}
// ④ 游离的 DOM 引用
const cache = {}
cache.el = document.getElementById('app')
document.body.removeChild(document.getElementById('app'))
// 节点已从 DOM 移除,但 cache.el 仍引用着它 → 整棵子树不回收排查手段:DevTools → Memory → Heap Snapshot,对比两次快照,看 Detached DOM tree 与 Shallow/Retained Size。
四、模块机制:ESM vs CJS
| 维度 | CommonJS(CJS) | ES Module(ESM) |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
顶层 this | module.exports(空对象) | undefined |
| 输出 | 值的拷贝(对象则是引用拷贝) | 只读引用(live binding) |
| 循环依赖 | 可能拿到不完整的 exports | 靠 live binding 缓解,但仍需避免 |
| Tree Shaking | ❌ 不支持 | ✅ 支持(静态结构的前提) |
4.1 为什么 Tree Shaking 只对 ESM 有效
Tree Shaking 需要在编译期确定"哪些导出没被用到"。
- ESM 的
import在顶层、静态、不可写在条件里 → 打包器能静态分析出依赖图 → 安全删除死代码 - CJS 的
require()可以写在 if 里、可以拼接路径 → 只能运行时才知道 → 不敢删
4.2 循环依赖怎么破
// a.js 依赖 b.js,b.js 又依赖 a.js
// CJS:谁先加载谁拿到的就是"半成品" exports
// ESM:live binding 让值最终能对上,但**执行顺序**仍可能出问题三条实用规则:
- 不要循环——这是设计问题,不是技巧问题
- 抽第三方模块:把 A、B 共用的部分下沉到
common.js - 改依赖方向:让高层依赖低层,而不是互相依赖(见 A17 · 架构分层)
自检清单
- [ ] 能说出"同步 → 微任务全清空 → 取一个宏任务"的完整顺序,并手推输出题
- [ ] 能解释
rAF为什么比setTimeout更适合做动画 - [ ] 能说出 V8 新生代 / 老生代分别用什么算法,以及晋升条件
- [ ] 能列举 4 种内存泄漏并知道用 Heap Snapshot 排查
- [ ] 能说清 ESM 支持 Tree Shaking 而 CJS 不支持的根本原因
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "闭包一定导致内存泄漏" | 闭包只是持有引用;泄漏是因为你没释放对闭包的引用 |
"setTimeout(fn, 0) 立刻执行" | 最少也要等一轮事件循环,且受浏览器最小延迟(嵌套 >5 层为 4ms)限制 |
| "微任务每次只执行一个" | 全部清空,包括执行中新产生的 |
| "ESM 输出的是值拷贝" | 是只读引用(live binding),模块内部改了外面会跟着变 |
动手练习
- 手推下面代码的输出,再用浏览器验证:
async function async1() {
console.log('a')
await async2()
console.log('b') // await 后面的代码 ≈ 放进 then 里
}
async function async2() { console.log('c') }
console.log('d')
setTimeout(() => console.log('e'))
async1()
new Promise(r => { console.log('f'); r() }).then(() => console.log('g'))
console.log('h')- 写一段必然泄漏的代码(游离 DOM + 定时器),用 DevTools 拍两次快照,找到它并修复。
相关文档
- 手写实现(防抖节流 / Promise / 并发控制)→ A18 · 手写题与代码设计
- 事件循环的服务端差异 → A23 · Node.js 与 BFF
- 渲染流水线与帧预算 → A04 · 浏览器与网络
- 打包阶段如何用掉 ESM 静态结构 → A21 · 构建工具原理