Skip to content

09 · JavaScript 运行时 ​

本篇解决什么问题: 事件循环、内存回收、模块机制这三件事,决定了你写的代码什么时候跑、占多少内存、能不能被打包优化。 它们是面试必考、也是排查"页面卡了""内存涨了""循环依赖报错"的唯一入口。

📌 与 A04(浏览器与网络)的分工:A04 讲渲染流水线与网络,本篇讲 JS 引擎侧的执行与内存。 A04 只给事件循环的基础结论,本篇做深度拆解。

📌 与 A18(手写题)的分工:本篇讲机制,A18 讲手写实现。


开篇速览 ​

主题回答什么问题面试常见度
调用栈与执行上下文"为什么报错堆栈是反的?this 到底指向谁?"⭐⭐⭐
事件循环"setTimeout(fn, 0) 为什么不是立刻执行?微任务插在哪?"⭐⭐⭐⭐⭐
内存与 GC"内存泄漏怎么查?闭包一定泄漏吗?"⭐⭐⭐⭐
ESM vs CJS"为什么 Tree Shaking 只对 ESM 有效?循环依赖怎么破?"⭐⭐⭐⭐

一、调用栈与执行上下文 ​

JS 是单线程的:同一时刻只有一个函数在跑,靠**调用栈(Call Stack)**管理执行顺序。

js
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 的关键):

  1. 创建阶段:建作用域链 → 建变量对象(var 初始化为 undefined,函数声明整体提升)→ 确定 this
  2. 执行阶段:赋值、执行代码

⚠️ let / const 在创建阶段也被绑定,但处于 TDZ(暂时性死区),访问会抛 ReferenceError—— 这就是"看起来提升了但不能用"的原因。


二、事件循环深挖 ​

2.1 一张图看懂 ​

        ┌──────────────────────────────┐
        │        调用栈 Call Stack      │  ← 同步代码在这里跑
        └──────────────┬───────────────┘
                       │ 空了就去取
        ┌──────────────▼───────────────┐
        │      微任务队列 Microtask      │  ← 清空后才进入下一步
        │  Promise.then / queueMicrotask│     **一次循环全部清空**
        │  MutationObserver             │
        └──────────────┬───────────────┘
                       │ 微任务清空后
        ┌──────────────▼───────────────┐
        │   渲染时机(可选)             │  ← rAF → 样式/布局 → 绘制
        └──────────────┬───────────────┘
                       │
        ┌──────────────▼───────────────┐
        │      宏任务队列 Macrotask      │  ← **每次只取一个**
        │  setTimeout / setInterval     │
        │  I/O / 事件回调 / postMessage │
        └──────────────────────────────┘

核心规则(背这三条就够):

  1. 同步代码跑完 → 清空所有微任务(不是清空一个)
  2. 微任务执行中新产生的微任务,会在同一次循环里被清空(可能饿死渲染)
  3. 取一个宏任务执行 → 回到第 1 步

2.2 经典输出题 ​

js
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、MutationObserverPromise.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 常见内存泄漏(排查清单) ​

js
// ① 意外的全局变量(未声明的变量挂到 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.exportsimport / export
加载时机运行时动态加载编译时静态分析
顶层 thismodule.exports(空对象)undefined
输出值的拷贝(对象则是引用拷贝)只读引用(live binding)
循环依赖可能拿到不完整的 exports靠 live binding 缓解,但仍需避免
Tree Shaking❌ 不支持✅ 支持(静态结构的前提)

4.1 为什么 Tree Shaking 只对 ESM 有效 ​

Tree Shaking 需要在编译期确定"哪些导出没被用到"。

  • ESM 的 import 在顶层、静态、不可写在条件里 → 打包器能静态分析出依赖图 → 安全删除死代码
  • CJS 的 require() 可以写在 if 里、可以拼接路径 → 只能运行时才知道 → 不敢删

4.2 循环依赖怎么破 ​

js
// a.js 依赖 b.js,b.js 又依赖 a.js
// CJS:谁先加载谁拿到的就是"半成品" exports
// ESM:live binding 让值最终能对上,但**执行顺序**仍可能出问题

三条实用规则:

  1. 不要循环——这是设计问题,不是技巧问题
  2. 抽第三方模块:把 A、B 共用的部分下沉到 common.js
  3. 改依赖方向:让高层依赖低层,而不是互相依赖(见 A17 · 架构分层)

自检清单 ​

  • [ ] 能说出"同步 → 微任务全清空 → 取一个宏任务"的完整顺序,并手推输出题
  • [ ] 能解释 rAF 为什么比 setTimeout 更适合做动画
  • [ ] 能说出 V8 新生代 / 老生代分别用什么算法,以及晋升条件
  • [ ] 能列举 4 种内存泄漏并知道用 Heap Snapshot 排查
  • [ ] 能说清 ESM 支持 Tree Shaking 而 CJS 不支持的根本原因

常见误区 ​

❌ 误区✅ 事实
"闭包一定导致内存泄漏"闭包只是持有引用;泄漏是因为你没释放对闭包的引用
"setTimeout(fn, 0) 立刻执行"最少也要等一轮事件循环,且受浏览器最小延迟(嵌套 >5 层为 4ms)限制
"微任务每次只执行一个"全部清空,包括执行中新产生的
"ESM 输出的是值拷贝"是只读引用(live binding),模块内部改了外面会跟着变

动手练习 ​

  1. 手推下面代码的输出,再用浏览器验证:
js
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')
  1. 写一段必然泄漏的代码(游离 DOM + 定时器),用 DevTools 拍两次快照,找到它并修复。

相关文档 ​