前端监控的目标,是把「用户侧真实体验」和「线上可观测性」连起来:出了问题能第一时间知道 、能定位到版本与路径 、能量化影响面 ,而不是依赖用户截图或口头描述。本文先梳理常见监控维度,再用一个基于 WeakRef 与 FinalizationRegistry 的 GC 监控工具 举例,说明如何把「疑似内存泄漏」从感觉变成可上报的信号。
1. 为什么要做前端监控
- 错误与稳定性:未捕获异常、资源加载失败、接口 4xx/5xx、白屏,直接影响转化与留存。
- 性能(RUM):首屏、可交互时间、长任务、INP 等,决定「卡不卡」的主观感受。
- 业务与行为:关键漏斗、按钮曝光点击、实验分流,需要与技术指标同一条时间线对齐。
- 安全与合规:CSP 违规、异常脚本注入等,有时也要在前端侧留痕。
没有监控时,团队往往在「复现难、归因慢、不知道影响多大」之间消耗;有监控后,可以把问题收敛到:哪次发布、哪条路由、哪类设备。
1.1 典型场景:问题只在线上、本地怎么都复现不了
这类情况很常见,监控的意义就在于把「用户环境里的差异」变成可查的数据,而不是依赖你在本机再点一百遍。
虚构但贴近真实的一例:
某后台列表页带「侧滑详情抽屉」。客服反馈:安卓手机用一上午后,列表滚动越来越卡,偶发白屏;你用自己的电脑 Chrome 开着 DevTools 点了一下午,内存曲线平稳、Performance 里也没有明显长任务------本地就是复现不了。
为什么线上和本地会「长得不一样」:
| 维度 | 本地开发 | 线上用户侧 |
|---|---|---|
| 数据量 | 造数几条、几十条 | 真实账号上万条、分页反复加载 |
| 使用路径 | 点几条最短路径 | 反复打开/关闭抽屉、切 tab、退回列表 |
| 设备与内存 | 高配 PC、内存充裕 | 中低端机、系统杀进程压力大,GC 更频繁 |
| 网络与缓存 | localhost / 企业内网 | 弱网、CDN 命中差异、接口偶发慢导致重试堆积 |
| 运行时长 | 每次刷新从零开始 | 单页长时间不刷新,泄漏是「攒出来」的 |
这时监控能帮你做什么(和本文 GC 示例如何接上):
- RUM / 自定义性能 :按「路由 + 版本号」看 INP、长任务次数;若只有「列表+抽屉」这条路径在低端机上飙升,至少知道战场在哪。
- 面包屑与会话:还原用户操作序列(进了多少次详情、是否总不关抽屉),本地往往不会按这种强度操作。
- 灰度打开
GCMonitor一类工具 (低采样、仅特定路由):对「抽屉根节点」在onUnmounted之后做延迟存活检查;若线上大量出现「某组件 id 已卸载仍长期存活」的上报,而本地没有------说明强引用链或全局缓存 与用户数据规模、打开次数耦合,这就把「无法复现」收窄成可统计的线上特征,再回到代码里查事件监听、全局 Map、单例缓存是否按页面卸载清理。
结论:本地复现不了 ≠ 问题不存在;用线上监控把环境、路径、版本、设备拉齐,再配合有针对性的轻量探针(如 GC 观测),才能把「玄学问题」变成可修的工单。
2. 常见监控分层(你可以按优先级落地)
| 层级 | 典型内容 | 常见载体 |
|---|---|---|
| 采集 | 全局 error / unhandledrejection、路由变化、性能条目(PerformanceObserver) |
SDK 或自研脚本 |
| 传输 | sendBeacon、批量队列、失败重试 |
网关 / 同域 API |
| 存储与查询 | 日志索引、TraceId、用户/会话维度 | ELK、ClickHouse、厂商后台 |
| 告警与工单 | 阈值、同比、影响用户数 | PagerDuty、企业微信等 |
实践建议:先做「全局错误 + 基础 RUM(导航、LCP 等)」,再按业务补自定义事件;自定义越多,越要在 SDK 里做采样与体积控制,避免拖慢主线程。
3. 性能与内存:RUM 之外的「泄漏」怎么抓
性能监控里,内存问题相对难:堆快照适合线下深挖,线上则更适合:
- Chrome Memory Pressure API (若可用)与 Performance.measureUserAgentSpecificMemory(需隔离上下文等,使用面有限);
- 定期观察
JSHeapSizeLimit相关指标(粗粒度); - 结合业务生命周期:路由离开、弹窗关闭后,相关 DOM/闭包是否仍被强引用。
下面示例走另一条路:用语言特性观察「对象是否已被 GC 回收」,适合在开发/灰度阶段对「组件卸载后是否仍被挂住」做自动化怀疑。
4. 示例:GCMonitor------用 WeakRef + FinalizationRegistry 观测回收
4.1 思路说明
WeakRef:对目标对象是弱引用,不会阻止 GC;deref()在对象仍存活时返回引用,否则返回undefined。FinalizationRegistry:当注册的对象被回收时,会异步调用你提供的回调(不要假设回调的精确时机,只把它当作「已回收」的信号之一)。
组合起来可以:
monitor(obj, id):注册监控,记录开始时间;- 回收发生时:在 registry 回调里打日志、算存活时长、可上报监控平台;
- 兜底 :一段时间后
checkAlive,若deref()仍有值,说明对象仍被强引用链挂住,疑似泄漏(也可能是用户仍停留在该页、或正常仍需要该节点------所以要配合「组件已卸载」等业务语义)。
4.2 完整示例代码(utils/gcMonitor.js)
javascript
// utils/gcMonitor.js
class GCMonitor {
constructor() {
this.refs = new Map() // id → { weakRef, timestamp }
this.registry = new FinalizationRegistry((id) => {
const info = this.refs.get(id)
if (info) {
const duration = Date.now() - info.timestamp
console.log(`[GC] ✅ 组件 ${id} 已被回收,存活时长:${duration}ms`)
this.refs.delete(id)
}
})
}
/**
* 监控一个 DOM 节点(或任意对象)
* @param {object} obj - 要监控的对象(通常是组件的根 DOM 元素)
* @param {string} id - 唯一标识(推荐格式:组件名_路由_时间戳)
*/
monitor(obj, id) {
if (this.refs.has(id)) {
console.warn(`[GCMonitor] 组件 ${id} 已存在监控,跳过`)
return
}
const weakRef = new WeakRef(obj)
this.refs.set(id, {
weakRef,
timestamp: Date.now(),
})
this.registry.register(obj, id)
// 延迟 5 秒后主动检查是否还活着(兜底检测)
setTimeout(() => this.checkAlive(id), 5000)
}
/**
* 主动检查某个对象是否已被回收
* @param {string} id
* @returns {boolean} true=还活着,false=已回收
*/
checkAlive(id) {
const info = this.refs.get(id)
if (!info) return false // 已被 FinalizationRegistry 清理
const obj = info.weakRef.deref()
if (obj) {
console.error(
`[GCMonitor] 🚨 疑似泄漏:组件 ${id} 在 ${
Date.now() - info.timestamp
}ms 后仍然存活!`
)
// 可上报到监控平台
// window.__SENTRY__?.captureMessage(`内存泄漏疑似: ${id}`)
return true
} else {
console.log(`[GCMonitor] 组件 ${id} 已被回收(主动检测到)`)
this.refs.delete(id)
return false
}
}
/**
* 记录组件销毁次数(用于统计泄漏率)
* @param {string} id
*/
recordDestroy(id) {
console.log(`[组件销毁] ${id} 已从 DOM 树移除,等待 GC 验证`)
// 可以扩展:将 id 存入一个 Set,后续对比 GC 回调数量
}
/**
* 获取所有仍存活的监控对象 ID(调试用)
*/
getAliveIds() {
const alive = []
for (const [id, info] of this.refs.entries()) {
if (info.weakRef.deref()) {
alive.push(id)
}
}
return alive
}
}
// 导出全局单例
export default new GCMonitor()
4.3 在组件里怎么用(示意)
要点 :在「挂载完成、能拿到根 DOM」时 monitor;在「卸载钩子」里调用 recordDestroy(可选),并把 id 设计成可区分路由与实例。
javascript
import gcMonitor from '@/utils/gcMonitor'
const id = `UserCard_/users/${userId}_${Date.now()}`
onMounted(() => {
const el = rootRef.value // 或 this.$el
if (el) gcMonitor.monitor(el, id)
})
onUnmounted(() => {
gcMonitor.recordDestroy(id)
})
4.4 使用时的注意点(避免误报)
- FinalizationRegistry 回调是异步且不确定时序的 ,不能与「同步卸载」画等号;
checkAlive的 5 秒只是示例,长生命周期页面要适当延长或多次采样。 - 若组件仍在当前路由或仍挂在树上 ,
deref()一直非空是正常现象,不是泄漏。 - 生产环境建议:仅在灰度/调试开关打开时启用;上报时用采样率,避免刷屏。
- 兼容性:需较新的 JS 引擎;老旧 WebView 需自行降级或关闭该能力。
4.5 Vue2 示例:在路由切换中「批量」触发泄漏怀疑检查
很多泄漏不是单个组件的问题,而是「某个路由反复进出」才会逐渐堆积。一个很实用的做法是:
- 组件卸载时登记 id(说明它"应该消失了")
- 路由切走后统一延迟检查 :对离开的路由里登记过的全部 id 调用
checkAlive,一次切换跑一批,便于统计与上报
下面给出一个 Vue2 + Vue Router 的示例,核心是一个小插件 + 一个 mixin(或基类组件)。
4.5.1 路由批量调度器(utils/gcRouteBatch.js)
javascript
// utils/gcRouteBatch.js
import gcMonitor from '@/utils/gcMonitor'
/**
* 在路由切换时,对「离开的路由」里登记过的组件 id 批量触发 checkAlive。
* - 只负责调度,不负责采集 DOM(DOM 由组件自己 monitor)
* - 建议仅在灰度/调试开关下启用,并控制采样
*/
export function setupGCRouteBatch(router, options = {}) {
const {
enabled = true,
delayMs = 8000, // 给 GC 留出时间窗口;可根据页面复杂度调大
sampleRate = 0.1, // 线上建议采样
} = options
if (!enabled) return { trackDestroyed: () => {} }
// routeKey → Set<id>
const destroyedByRoute = new Map()
const keyOf = (route) => {
const name = route && route.name ? route.name : 'noname'
const path = route && route.path ? route.path : ''
const fullPath = route && route.fullPath ? route.fullPath : ''
return `${name}|${path}|${fullPath}`
}
function shouldSample() {
return Math.random() < sampleRate
}
function trackDestroyed(route, id) {
const k = keyOf(route)
let set = destroyedByRoute.get(k)
if (!set) {
set = new Set()
destroyedByRoute.set(k, set)
}
set.add(id)
}
router.afterEach((to, from) => {
if (!from) return
if (!shouldSample()) return
const fromKey = keyOf(from)
const ids = destroyedByRoute.get(fromKey)
if (!ids || ids.size === 0) return
// 路由离开后,延迟批量检查:还活着 → 疑似泄漏
setTimeout(() => {
for (const id of ids) gcMonitor.checkAlive(id)
destroyedByRoute.delete(fromKey)
}, delayMs)
})
return { trackDestroyed }
}
4.5.2 组件侧统一接入(Vue2 mixin 示例)
组件侧做两件事:
mounted:拿到根 DOM 后monitor(el, id)beforeDestroy:recordDestroy(id)+ 把 id 交给路由批量调度器(归到当前路由)
javascript
import gcMonitor from '@/utils/gcMonitor'
// mixins/gcTrackMixin.js
export function createGCTrackMixin(options = {}) {
const { componentName, getRootEl, trackDestroyed } = options
return {
data() {
const route = this.$route
const name = componentName || this.$options.name || 'AnonymousComponent'
const fullPath = route && route.fullPath ? route.fullPath : 'noroute'
return {
__gc_track_id__: `${name}_${fullPath}_${Date.now()}_${this._uid}`,
}
},
mounted() {
const el = getRootEl ? getRootEl.call(this) : this.$el
if (el) gcMonitor.monitor(el, this.__gc_track_id__)
},
beforeDestroy() {
gcMonitor.recordDestroy(this.__gc_track_id__)
if (typeof trackDestroyed === 'function') {
trackDestroyed(this.$route, this.__gc_track_id__)
}
},
}
}
4.5.3 在应用入口启用(main.js)
javascript
import Vue from 'vue'
import router from './router'
import { setupGCRouteBatch } from '@/utils/gcRouteBatch'
// 建议:仅在灰度/调试环境开启,或受开关控制
const { trackDestroyed } = setupGCRouteBatch(router, {
enabled: true,
delayMs: 8000,
sampleRate: 0.1,
})
// 挂到全局,组件里可通过 this.$gcTrackDestroyed 调用
Vue.prototype.$gcTrackDestroyed = trackDestroyed
4.5.4 在组件中使用(示意)
方式 A:直接在组件里写(最直观)
javascript
import gcMonitor from '@/utils/gcMonitor'
export default {
name: 'UserDrawer',
mounted() {
this.__gcId = `UserDrawer_${this.$route.fullPath}_${Date.now()}_${this._uid}`
gcMonitor.monitor(this.$el, this.__gcId)
},
beforeDestroy() {
gcMonitor.recordDestroy(this.__gcId)
this.$gcTrackDestroyed && this.$gcTrackDestroyed(this.$route, this.__gcId)
},
}
方式 B:用 mixin 复用(更适合大规模接入)
javascript
import { createGCTrackMixin } from '@/mixins/gcTrackMixin'
export default {
name: 'UserDrawer',
mixins: [
createGCTrackMixin({
componentName: 'UserDrawer',
getRootEl() {
return this.$el // 或者 return this.$refs.rootEl
},
trackDestroyed(route, id) {
this.$gcTrackDestroyed && this.$gcTrackDestroyed(route, id)
},
}),
],
}
这种写法的好处是:你不需要在每个组件里手动 setTimeout(checkAlive);路由切走就是天然的批处理时机,也便于在监控平台按「from 路由」聚合统计疑似泄漏率。
5. 与「传统监控」如何配合
- 错误监控(Sentry、自研等):堆栈 + Release + SourceMap,解决「哪行代码炸了」。
- RUM:LCP、FID/INP、CLS、TTFB,解决「慢在哪里」。
- 本文 GC 示例 :偏向「卸载后的对象是否仍活着」,解决「是不是被挂住了」这一类内存侧怀疑。
三者互补:错误告诉你异常路径 ,性能告诉你主线程与资源 ,GC 监控在合适场景下帮你缩小泄漏排查的搜索范围。
6. 小结
前端监控的本质是用统一管道把线上信号送回来 :从全局错误与 RUM 打底,到业务自定义事件,再到像 GCMonitor 这样针对特定问题的轻量工具。尤其当问题呈现为仅线上、长路径、弱设备才暴露 (见上文 1.1 节)时,没有监控几乎只能猜。WeakRef 与 FinalizationRegistry 让我们能用较少侵入的方式观察回收行为;真正落地时,务必结合路由/挂载语义、采样与兼容性,把「疑似泄漏」变成可行动的工单,而不是控制台噪音。
参考与延伸阅读
- MDN:WeakRef、FinalizationRegistry
- Web Vitals 与 RUM:web.dev/vitals
- 浏览器 DevTools Memory 面板:适合与本文工具配合做线下验证