阅读视图

发现新文章,点击刷新页面。

前端监控体系与实践(二):全局监控

继上一篇前端监控体系与实践:从错误上报到内存与 GC 观测,当需要全局监控时该如何实施呢?

监控采集集中为 initClientMonitoring(),在应用入口调用一次。常见做法是在 main.js 中、创建根实例之前调用;若需复用,可封装为 Vue 插件,在 install 中调用同一函数。

init 中通常注册:errorunhandledrejection;对 history.pushState / replaceState 做包装并监听 popstate 以记录路由变化;可按需通过 PerformanceObserver 采集 LCP。若另有 GC 相关探针,可通过自定义事件 frontend-monitor:gc-suspect 由业务侧 dispatchEvent,非必需。


采集模块示例

环境变量命名需与构建工具一致(Vue CLI 常用 VUE_APP_*)。以下为 clientMonitor.js 示例:

/**
 * 客户端全局监控:错误、未处理 Promise、路由变化、基础 Web Vitals(LCP)。
 * 上报走 window.__FRONTEND_MONITOR_REPORT__(payload)。
 *
 * payload.type 约定:error | unhandledrejection | navigation | web-vital | gc-suspect
 */

function isMonitorEnabled() {
  if (typeof window === "undefined") return false;
  if (process.env.VUE_APP_FRONTEND_MONITOR === "1") return true;
  return process.env.NODE_ENV === "development";
}

function isGcReportEnabled() {
  if (typeof window === "undefined") return false;
  if (process.env.VUE_APP_FRONTEND_MONITOR_GC === "1") return true;
  return process.env.NODE_ENV === "development";
}

function report(payload) {
  const fn = window.__FRONTEND_MONITOR_REPORT__;
  if (typeof fn === "function") {
    try {
      fn(payload);
    } catch (e) {
      /* 上报回调异常不应影响主流程 */
    }
  }
  if (process.env.NODE_ENV === "development") {
    console.debug("[frontend-monitor]", payload);
  }
}

let installed = false;

export function initClientMonitoring() {
  if (typeof window === "undefined" || installed) return;
  if (!isMonitorEnabled()) return;
  installed = true;

  window.addEventListener("error", (ev) => {
    const err = ev.error;
    report({
      type: "error",
      message: ev.message || String(err != null ? err : "unknown"),
      source: ev.filename,
      lineno: ev.lineno,
      colno: ev.colno,
      stack: err instanceof Error ? err.stack : undefined,
    });
  });

  window.addEventListener("unhandledrejection", (ev) => {
    const r = ev.reason;
    const reason =
      r instanceof Error
        ? r.message + "\n" + (r.stack || "")
        : String(r);
    report({ type: "unhandledrejection", reason });
  });

  const path = () =>
    window.location.pathname + window.location.search + window.location.hash;

  report({ type: "navigation", kind: "initial", path: path() });

  window.addEventListener("popstate", () => {
    report({ type: "navigation", kind: "popstate", path: path() });
  });

  const origPush = history.pushState.bind(history);
  history.pushState = function () {
    origPush.apply(history, arguments);
    report({ type: "navigation", kind: "pushState", path: path() });
  };
  const origReplace = history.replaceState.bind(history);
  history.replaceState = function () {
    origReplace.apply(history, arguments);
    report({ type: "navigation", kind: "replaceState", path: path() });
  };

  if (isGcReportEnabled()) {
    window.addEventListener("frontend-monitor:gc-suspect", (ev) => {
      const d = ev.detail;
      if (d) report({ type: "gc-suspect", id: d.id, aliveMs: d.aliveMs });
    });
  }

  try {
    const po = new PerformanceObserver((list) => {
      for (const e of list.getEntries()) {
        if (e.entryType === "largest-contentful-paint") {
          report({
            type: "web-vital",
            name: "LCP",
            value: Math.round(e.startTime),
          });
        }
      }
    });
    po.observe({ type: "largest-contentful-paint", buffered: true });
  } catch (e) {
    /* 浏览器不支持 LCP observer */
  }
}

installed 用于防止重复初始化。上述监听绑定在 windowhistory 上,与具体页面组件无关,不宜分散到各页面的 mounted 中重复注册。


main.js

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import { initClientMonitoring } from "@/lib/monitoring/clientMonitor";

initClientMonitoring();

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");

若初始化依赖远程配置(例如先请求 /config 再决定是否开启监控),应注意:errorunhandledrejection 注册过晚时,可能在脚本加载初期遗漏部分异常。


Vue 插件封装(可选)

// plugins/clientMonitoring.js
import { initClientMonitoring } from "@/lib/monitoring/clientMonitor";

export default {
  install() {
    initClientMonitoring();
  },
};

// main.js
import ClientMonitoring from "./plugins/clientMonitoring";
Vue.use(ClientMonitoring);

同一插件多次 Vue.use 只会执行一次 install,与模块内 installed 标志可并存,择一即可。


router.afterEach 是否必要

在已包装 history 的前提下,vue-router 使用 History 模式时,路由切换通常会触发 pushState / replaceState仅按 URL 做埋点或 RUM 时,一般不必再写 afterEach,否则易与历史 API 包装产生重复上报,需在服务端或协议层约定去重。

当需要 路由名称、meta 等无法从 URL 直接还原的信息(例如实验分组、业务归属)时,可在 router.afterEach 中调用 __FRONTEND_MONITOR_REPORT__ 单独上报;字段需与网关或数据模型一致。


Vue.config.errorHandler

组件渲染与生命周期中的错误未必冒泡至 windowerror 事件,建议在 main.js 中配置全局 errorHandler

Vue.config.errorHandler = (err, vm, info) => {
  const fn = window.__FRONTEND_MONITOR_REPORT__;
  if (typeof fn === "function") {
    try {
      fn({
        type: "error",
        message: err && err.message ? err.message : String(err),
        stack: err instanceof Error ? err.stack : undefined,
        source: info,
      });
    } catch (_) {}
  }
  if (process.env.NODE_ENV === "development") {
    console.error("[vue-error]", err, info);
  }
};

若在祖先组件中使用 errorCaptured 拦截子树错误,应与 errorHandler 的上报策略一并设计,避免同一异常多次上报。


上报接入

window.__FRONTEND_MONITOR_REPORT__ = function (payload) {
  // sendBeacon / fetch
};

采集逻辑集中在 init 中实现;上报通过全局回调转发,变更采集端点或采样策略时,优先修改该回调或其封装层。

上一篇: 前端监控体系与实践:从错误上报到内存与 GC 观测

前端监控体系与实践:从错误上报到内存与 GC 观测

前端监控的目标,是把「用户侧真实体验」和「线上可观测性」连起来:出了问题能第一时间知道、能定位到版本与路径、能量化影响面,而不是依赖用户截图或口头描述。本文先梳理常见监控维度,再用一个基于 WeakRefFinalizationRegistryGC 监控工具 举例,说明如何把「疑似内存泄漏」从感觉变成可上报的信号。


1. 为什么要做前端监控

  • 错误与稳定性:未捕获异常、资源加载失败、接口 4xx/5xx、白屏,直接影响转化与留存。
  • 性能(RUM):首屏、可交互时间、长任务、INP 等,决定「卡不卡」的主观感受。
  • 业务与行为:关键漏斗、按钮曝光点击、实验分流,需要与技术指标同一条时间线对齐。
  • 安全与合规:CSP 违规、异常脚本注入等,有时也要在前端侧留痕。

没有监控时,团队往往在「复现难、归因慢、不知道影响多大」之间消耗;有监控后,可以把问题收敛到:哪次发布、哪条路由、哪类设备

1.1 典型场景:问题只在线上、本地怎么都复现不了

这类情况很常见,监控的意义就在于把「用户环境里的差异」变成可查的数据,而不是依赖你在本机再点一百遍。

虚构但贴近真实的一例:

某后台列表页带「侧滑详情抽屉」。客服反馈:安卓手机用一上午后,列表滚动越来越卡,偶发白屏;你用自己的电脑 Chrome 开着 DevTools 点了一下午,内存曲线平稳、Performance 里也没有明显长任务——本地就是复现不了。

为什么线上和本地会「长得不一样」:

维度 本地开发 线上用户侧
数据量 造数几条、几十条 真实账号上万条、分页反复加载
使用路径 点几条最短路径 反复打开/关闭抽屉、切 tab、退回列表
设备与内存 高配 PC、内存充裕 中低端机、系统杀进程压力大,GC 更频繁
网络与缓存 localhost / 企业内网 弱网、CDN 命中差异、接口偶发慢导致重试堆积
运行时长 每次刷新从零开始 单页长时间不刷新,泄漏是「攒出来」的

这时监控能帮你做什么(和本文 GC 示例如何接上):

  1. RUM / 自定义性能:按「路由 + 版本号」看 INP、长任务次数;若只有「列表+抽屉」这条路径在低端机上飙升,至少知道战场在哪
  2. 面包屑与会话:还原用户操作序列(进了多少次详情、是否总不关抽屉),本地往往不会按这种强度操作。
  3. 灰度打开 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:当注册的对象被回收时,会异步调用你提供的回调(不要假设回调的精确时机,只把它当作「已回收」的信号之一)。

组合起来可以:

  1. monitor(obj, id):注册监控,记录开始时间;
  2. 回收发生时:在 registry 回调里打日志、算存活时长、可上报监控平台;
  3. 兜底:一段时间后 checkAlive,若 deref() 仍有值,说明对象仍被强引用链挂住,疑似泄漏(也可能是用户仍停留在该页、或正常仍需要该节点——所以要配合「组件已卸载」等业务语义)。

4.2 完整示例代码(utils/gcMonitor.js

// 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 设计成可区分路由与实例。

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 使用时的注意点(避免误报)

  1. FinalizationRegistry 回调是异步且不确定时序的,不能与「同步卸载」画等号;checkAlive 的 5 秒只是示例,长生命周期页面要适当延长或多次采样。
  2. 若组件仍在当前路由或仍挂在树上deref() 一直非空是正常现象,不是泄漏。
  3. 生产环境建议:仅在灰度/调试开关打开时启用;上报时用采样率,避免刷屏。
  4. 兼容性:需较新的 JS 引擎;老旧 WebView 需自行降级或关闭该能力。

4.5 Vue2 示例:在路由切换中「批量」触发泄漏怀疑检查

很多泄漏不是单个组件的问题,而是「某个路由反复进出」才会逐渐堆积。一个很实用的做法是:

  • 组件卸载时登记 id(说明它“应该消失了”)
  • 路由切走后统一延迟检查:对离开的路由里登记过的全部 id 调用 checkAlive,一次切换跑一批,便于统计与上报

下面给出一个 Vue2 + Vue Router 的示例,核心是一个小插件 + 一个 mixin(或基类组件)。

4.5.1 路由批量调度器(utils/gcRouteBatch.js

// 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)
  • beforeDestroyrecordDestroy(id) + 把 id 交给路由批量调度器(归到当前路由)
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

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:直接在组件里写(最直观)

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 复用(更适合大规模接入)

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 节)时,没有监控几乎只能猜。WeakRefFinalizationRegistry 让我们能用较少侵入的方式观察回收行为;真正落地时,务必结合路由/挂载语义、采样与兼容性,把「疑似泄漏」变成可行动的工单,而不是控制台噪音。


参考与延伸阅读

❌