K 的一隅

JavaScript JavaScript 核心机制

函数都 return 了,为什么对象还在:垃圾回收与弱引用

从仓库盘点与备忘签是否仍被登记说起,理解可达性、闭包滞留、监听器缓存,以及 WeakMap、WeakRef 各自解决什么问题。

9 分钟阅读 更新于 2026-07-24
本文目录

仓库管理员月底盘点,不是看货架上有没有空位,而是看登记册上是否还能追溯到这批货。只要任一有效单据仍指着同一批纸箱,箱子就不能扔;单据全销账了,占地方才算真正释放。

上一章讨论模块如何加载、求值与保持状态;本章追问:这些引用何时才真正断开,引擎又凭什么收走对象。

JavaScript 的垃圾回收也类似:引擎不是按“函数有没有 return”单独决定,而是看对象是否仍从根(roots)经引用链可达。第二章说闭包让环境保持可达;第三章与第十二章分别加了 this 与原型引用;这一章把“何时能回收”说清楚。

第二章的实验,这里补上规则

给按钮绑一个闭包着大数组的监听器,组件卸载后若不移除,Memory 快照里常能看到 retained size 居高不下。不是因为“闭包邪恶”,而是因为监听器注册表仍持有函数引用,函数又持有词法环境,环境里又挂着大数组——整条链从 DOM 或框架内部根仍可达。

先建立直觉:回收看的是图,不是某一行代码写没写 return

可达性:从根出发,还能摸到就不能收

根通常包括:

  • 全局对象(浏览器里是 window 等)
  • 当前调用栈上的局部绑定
  • 被宿主保留的句柄(例如活动中的定时器、正在执行的 Worker、某些 DOM 节点注册表)

从这些根开始做图遍历,能走到的对象都算存活;走不到的,成为垃圾回收候选(具体时机由引擎决定,不要假设“立刻”)。

js
function build() {
  const huge = new Uint8Array(10_000_000)
  return () => huge.byteLength
}

const readSize = build()
// build 的栈帧已结束,但 huge 仍被返回的函数闭包可达
console.log(readSize())

build 返回后,外层帧可以弹出,但 huge 只要还被 readSize 闭包着,就不会被回收。函数结束 ≠ 所有局部变量都消失,要看是否仍被外面摸着。

常见滞留:监听器、缓存、模块单例、全局误挂

事件监听器:DOM 节点 → 监听器数组 → 你的回调 → 闭包环境 → 大对象。移除节点有时不够,还要 removeEventListener 同一引用。

缓存 Map

js
const cache = new Map()

function loadUser(id) {
  if (cache.has(id)) return cache.get(id)
  const profile = fetchProfile(id) // 假设返回大对象
  cache.set(id, profile)
  return profile
}

只要 cache 活着,所有 set 进去的 profile 都可达。需要上限、LRU 或弱引用策略时,普通 Map 会把 key 强留住。

模块单例(第十四章):顶层 const db = connect() 随模块实例存活,整个应用周期都可能不释放。

误挂全局

js
window.__debug = createHeavyState()

调试完忘记删掉,等于手动加了一条从根出发的边。

WeakMap 与 WeakSet:键弱引用,不延长对象生命周期

js
const meta = new WeakMap()

function attach(node, data) {
  meta.set(node, data)
}

let button = document.createElement('button')
attach(button, { log: new Array(1_000_000) })

button = null
// 当 button 元素本身不再被 DOM/JS 可达时,
// WeakMap 中的条目可随键一起被回收(无需手动 delete)

WeakMap 的键必须是对象,且对键是弱引用:不会仅仅因为 WeakMap 里登记过,就阻止键对象被回收。适合把 DOM 节点、实例对象与元数据关联,又不想让 Map 本身变成“第二个根”。

WeakSet 类似,常用于“记录我见过哪些对象”,而不想影响它们何时消失。

注意:弱引用结构不可遍历(没有 keys()),因为键可能随时被收走;只能在你仍持有键对象时 get

WeakRef 与 FinalizationRegistry:更弱的边与善后回调

js
let target = { id: 1 }
const ref = new WeakRef(target)

target = null
// 将来某次 GC 后,ref.deref() 可能返回 undefined

const registry = new FinalizationRegistry((heldValue) => {
  console.log('对象已回收,善后:', heldValue)
})

registry.register({ big: new Uint8Array(1_000_000) }, 'session-42')

WeakRef 允许你“尽量”拿着对象,但不保证它活着;deref() 可能得到 undefinedFinalizationRegistry 在对象被回收后异步调用回调,适合做统计或释放外部资源句柄。

两者都是进阶工具:回调时机不确定,不能替代明确的 dispose / removeEventListener / clearInterval。框架内部偶尔使用,业务代码应优先写清生命周期。

与第十三章结构化克隆对照:复制 vs 仍握着引用

Worker 的 postMessage 默认结构化克隆,主线程里的原对象仍可达;克隆体在 Worker 里是一份新图。第十三章强调“消息传副本”;本章强调“主线程别误以为发走就等于自己能放手”——若本地还留着引用,照样占内存。

Transferable 则把所有权挪走,发送方原缓冲区会 detach。那是“切断这条边”的另一种方式,不是 GC 自动发生,而是协议层明确转移。

分代回收:为什么“刚创建”和“活很久”待遇不同

引擎实现常把对象分成新生代与老年代(名称因引擎而异)。大量临时对象在几次回收内就能清掉;长期存活的对象则少动,以降低全堆扫描成本。

这对写代码的启示是:

  • 短生命周期的小对象(临时数组、中间字符串)通常不必过度焦虑;
  • 长生命周期容器(全局 Map、单例、未清理的订阅)才更值得审查 retaining path。

你仍无法精确控制“第几次 GC 收掉谁”,但可以控制哪些对象有必要活过本次路由、本次弹窗、本次会话

SPA 路由切换:组件没了,引用可能还在

单页应用切路由时,DOM 常被替换,但自定义订阅、定时器、window 上的调试变量、模块级缓存仍可能活着。闭包若抓着已卸载组件的状态,就会出现“页面看不见了,内存还在涨”。

清理清单可以包括:

  • clearInterval / clearTimeout
  • removeEventListener 与框架提供的 off / unsubscribe
  • 中止 AbortController 控制的 fetch
  • 终止不再需要的 Worker

Vue 3 的 onUnmounted、React useEffect 返回的清理函数,本质都是让你在组件从可达图里摘掉之前,主动剪断自己加出去的边。框架帮你卸 DOM,不会自动替你注销所有副作用。

与第二章、第十二章串起来看

闭包让词法环境延长可达;原型让多个实例共享方法对象;this 让同一份方法在不同实例上操作不同数据。垃圾回收并不区分“这是闭包”“这是原型”,它只看引用图。

因此排查内存时,三条线要同时问:

  • 是不是闭包把大对象拴在监听器上?
  • 是不是单例或模块顶层把缓存无限长大?
  • 是不是分离的 DOM 仍被 JS 变量指着?

WeakMap 适合“DOM 节点 ↔ 元数据”这种伴生关系;业务数据缓存仍需要明确上限或淘汰策略。

容易踩的坑:别急着背“闭包等于内存泄漏”

第一种误解是“只要有闭包就泄漏”。闭包是正常机制;泄漏指本应不再需要的对象仍被可达

第二种是“设为 null 就一定立刻回收”。你只是去掉一条边;若图上还有其他路径,对象仍存活。回收时机也不保证同步发生。

第三种认为 WeakMap 可以替代所有缓存。键必须是对象,且不能枚举全体条目;需要按字符串 key 做 LRU 时仍要用普通 Map 并自己设上限。

第四种在 Vue/React 里只卸载组件,却不注销定时器、订阅、Worker。框架卸 DOM 不等于你的副作用自动断引用,要对照各自 onUnmounted / useEffect 清理。

DevTools Memory 面板看 retained size 时,沿 retaining path 找“是谁还指着它”,比贴标签“闭包泄漏”更有用。

定时器与闭包链:第四章的延续

js
function start() {
  const big = new Uint8Array(1_000_000)
  setInterval(() => {
    console.log(big.byteLength)
  }, 60_000)
}

start 返回后,定时器句柄仍被宿主持有,回调闭包着 big,因此大数组整个周期都可达。第四章说定时器回调是任务;本章说任务回调若闭包大对象,GC 就无法收。清理时要 clearInterval,并确保不再重新 start

这与“函数已经 return”并不矛盾:return 结束的是那一次同步调用,不是全局注册表里的定时器条目。

控制台与 DevTools 里的“泄漏”提示

Chrome DevTools 的 Detached DOM tree 表示:节点已从文档树摘下,但仍被 JavaScript 引用指着,因此无法作为普通 DOM 垃圾回收。常见原因是全局变量、闭包或缓存仍保存着元素引用。

处理步骤通常是:

  1. 在 Memory 快照里选中 detached 节点,看 retaining path。
  2. 找到最靠近业务代码的那条边(监听器、闭包、Map 键)。
  3. 在组件销毁或路由离开时主动 removeEventListener、从 Map 删除、把变量置 null(仅当那是最后一条强边时才有意义)。

WeakMap 不能神奇地修复 detached DOM:若你还有别的强引用指着节点,它照样存活。弱引用只是避免“元数据 Map 本身”成为额外的根。

与第十四章模块单例:import 一次,状态活多久

js
// metrics.js
const samples = []

export function record(value) {
  samples.push(value)
}

export function size() {
  return samples.length
}

只要应用不刷新,模块实例通常一直活着,samples 数组就会一直增长,除非你自己 clear 或换用带上限的结构。第十四章说副作用只执行一次;本章说那次副作用创建的状态,会随模块一起在可达图里常驻。

这不是 bug,而是设计选择:全局指标、客户端路由缓存、主题配置都可能故意长命。但若把“每次请求的临时列表”也放在模块顶层,就会在不刷新页面的情况下越积越大。

写库或写页面数据层时,问一句:这份状态的生命周期应该跟一次调用一个组件,还是整个应用绑定?答案决定该放局部变量、闭包、实例字段,还是模块单例。

FinalizationRegistry 的使用边界

注册表适合“对象没了之后通知外部系统”这类场景,例如释放原生句柄对应的 id。不要用它做关键业务逻辑:回调可能在很久以后才跑,也可能在进程结束前都不跑。

js
const reg = new FinalizationRegistry((id) => {
  console.log('清理外部资源标记', id)
})

function openSession(id) {
  const state = { id, data: [] }
  reg.register(state, id)
  return state
}

state 仍被业务强引用,注册表不会触发。只有 state 变成不可达后,才可能异步调用回调。生产代码里,用户点击“关闭”仍应同步 dispose,不能把希望全押在 GC 上。

试一试:画一张从根到泄漏点的简图

选一个你写过的页面:列出可能从 window 或框架根对象出发的引用——全局变量、模块导出、事件监听、定时器、闭包。用箭头连到“怀疑泄漏的对象”。

然后为每条边写一种拆法:removeEventListenerclearInterval、缩小闭包字段、给 Map 加上限。图不必漂亮,但要能回答“是谁还握着它”。

内存问题很少靠“多写几次 null”解决,而是靠认清引用从哪条边连过来,再在正确的生命周期钩子里剪掉它。

下一章:在属性访问路径上挂钩子

第十二章说查属性会沿原型链向上;本章说对象何时能从根不可达。还有一类需求:不改变对象“长什么样”,却在每次读、写、删属性时插入自己的逻辑——校验、默认值、依赖收集、只读视图。

ES2015 的 ProxyReflect 就是在这条访问路径上设检查站。Vue 3 的响应式、许多数据校验库、调试用的透明包装,底层都绕不开它们。下一章从 get / set 等 trap 说起,把“查属性”从被动查找变成可编排的流程。