Vue 项目怎么做监控 SDK

前段时间线上又出了两次说不清的问题。一次是用户反馈页面白了,测试环境怎么点都是好的;一次是某个列表页偶发卡一下,自己电脑上飞快。控制台能看到的,多半是一句 Script error.,堆栈是空的。Sentry 能用,但项目多、字段要对齐自己的上报网关,有的页面还得把某一类采集关掉。

与其每个项目临时加几个 window.onerror,不如收成一个 SDK。Vue 项目里 init 一下,错误、性能、白屏按需打开。

先定监控范围

我把自己要的收成三类,再多先不做。

  • 错误:JS 运行时、资源加载失败、未捕获的 Promise、Vue 组件里抛出来的错
  • 性能:FP、FCP、LCP、页面加载耗时、PV,再加上慢接口
  • 异常:白屏、卡顿

点击埋点先留一个口子,业务要用的时候自己调 report。SDK 负责采集和组装,真正把数据发出去交给项目自己。上报地址、鉴权、要不要 sendBeacon,每个项目都不一样,写死在 SDK 里以后改一次疼一次。

SDK 长什么样

入口做成 Vue 插件就行。装的时候给上报地址,再按项目打开要的采集,不必一次全开。

import { createApp } from 'vue'
import App from './App.vue'
import { monitor } from './monitor'

createApp(App)
.use(monitor, {
endpoint: '/collect',
appName: 'shop-admin',
// 按项目关,别一股脑全开
captureError: true,
capturePerf: true,
captureBlank: false,
})
.mount('#app')

Vue 错误,别覆盖原来的 errorHandler

Vue 3 挂在 app.config.errorHandler,Vue 2 挂在 Vue.config.errorHandler。写法一样,区别只是你把谁传进来。

最容易犯的错是直接赋值,把业务原来的处理盖掉。组件里本来会弹个提示,接了监控之后提示没了,这种锅后面很难查。正确做法是包一层:先走原来的 handler,再上报。

const hooked = new WeakSet()

export function attachVueError(app, send) {
if (hooked.has(app)) return
hooked.add(app)

const prev = app.config.errorHandler
app.config.errorHandler = (err, instance, info) => {
if (typeof prev === 'function') prev.call(app, err, instance, info)

const text = err instanceof Error ? err.message : String(err)
send({
from: 'vue',
text,
stage: info,
name: instance?.type?.name || instance?.$options?.name || 'anonymous',
})
}
}

有两个细节别省。

err 不一定是 Error。Vue 2 里有人会 throw '出错了',你直接读 err.message,监控自己又炸一次,而这次发生在 errorHandler 里面,Vue 会当成已经处理过,页面上什么都不剩。

另外这个函数得幂等。热更新、重复 init,再包一层就会连环上报。打个标记,第二次进来直接返回。

组件名尽量带上。线上同一句 Cannot read properties of undefined 能出现在十几个页面,没有组件名基本等于没用。

JS、资源和 Promise

全局错误用捕获阶段听,冒泡阶段听不到资源加载失败。

window.addEventListener(
'error',
(event) => {
const el = event.target
// 捕获阶段才能接到资源失败,这时 target 是 script / img / link
if (el && el !== window && el.tagName) {
send({
from: 'resource',
tag: el.tagName,
url: el.src || el.href || '',
})
return
}
send({
from: 'js',
text: event.message,
file: event.filename,
line: event.lineno,
})
},
true,
)

window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
send({
from: 'promise',
text: reason instanceof Error ? reason.message : String(reason),
})
})

资源错误别把整个 outerHTML 传上去。img 的 src 如果是一段 data URI,一条日志能有几 MB。截断到几百个字符,再带上标签名和 DOM 路径就够定位。

Script error. 单独算一类。这不是业务写错了,是脚本跨域又没加 crossorigin,堆栈拿不到。上报的时候标成 cors,别和普通 JS 错误混在一个桶里,不然告警全是这句,真正的错误被淹没。

堆栈我只留前 5 帧:文件名、函数名、行号、列号。再多对定位帮助不大,体积倒是上去了。

性能不要自己用 Date 减

FP、FCP、LCP 用 PerformanceObserver,自己记 performance.now() 对不齐浏览器的定义,后面和 Lighthouse 对不上还得解释。

let lcp = 0

const po = new PerformanceObserver((list) => {
const entries = list.getEntries()
const last = entries[entries.length - 1]
if (last) lcp = Math.round(last.startTime)
})

po.observe({ type: 'largest-contentful-paint', buffered: true })

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && lcp) {
send({ from: 'lcp', value: lcp, path: location.pathname })
po.disconnect()
}
})

LCP 有个麻烦:它不是一次就算死的。用户还没滚动、图片还在加载,候选值会一直变。我的做法是:

  • 设一个超时,比如 5 秒,到点用当前候选值上报
  • 路由切换、页面进后台、用户第一次点击,立刻结算,别把上一个页面的 LCP 记到下一个路由上
  • buffered: true 能拿到注册前的记录,但旧记录不要重复报

CLS、INP 同理,都是“一段时间里的汇总值”,路由一切就要结算本段,不然整站只有一个数字,看不出是哪个页面在抖。

PV 在 SPA 里不能只听 load。首次加载报一次;hash 模式听 hashchange;history 模式把 pushState / replaceState 包一层,再听 popstate。同一个路径不要因为 query 变了再计一次。销毁的时候把原生方法还回去,不然后面别的库会怪。

慢接口劫持 XMLHttpRequest.prototype 就行,axios 走的也是这个,不用专门适配。耗时超过阈值的报慢请求,status === 0 和 5xx 报失败,4xx 多半是业务校验,先别报,不然登录失败能把监控打满。

白屏和卡顿

白屏不要拿“根节点没有子元素”来判断。现在的页面骨架屏、loading 容器都算有 DOM,用户看到的还是一片空白。

我的判断是:页面上取十几个采样点,用 document.elementsFromPoint 看命中的是不是容器或者骨架屏。全部命中,并且持续超过 6 秒,才记一次白屏。同一个 URL 报过就别再报。

采样可以先密后疏。前 30 秒每 2 秒看一次,白屏大多发生在这一段;页面稳了以后降到 30 秒一次。一直 2 秒轮询,监控自己也会吃主线程。

根节点不叫 #app 的项目,把实际容器选进判断里,不然正常页面也会被当成白屏。

卡顿用 requestAnimationFrame 算 FPS,低于 24 记一笔,几秒内最多报一次。这个数不用很准,它是用来告诉你“这个页面当时在掉帧”,不是用来替代 Performance 面板。

上报,才是容易写烂的地方

采集写完只是一半。上报写得糙,要么把用户网络打满,要么关键那条日志在关页面时丢了。

我用内存里一个数组接着。凑满 5 条立刻发,没凑满就等 requestIdleCallback,没有这个 API 就 setTimeout。浏览器忙的时候别跟业务抢。

相同错误要去重。用 子类型 + message + 文件 做一个短哈希当 errId,60 秒内同一个只走一次。去重标记有个顺序问题:等数据真正交到上报函数之后再记。如果进缓存就标记,发送失败的那条会被当成“已经报过”,窗口期内再也出不去。

页面关掉之前把缓存冲掉。pagehide 比 beforeunload 稳,移动端和往返缓存都认它,两个都注册一下。

const queue = []
let timer = 0

export function pushLog(item) {
queue.push(item)
if (queue.length >= 10) return flush()
clearTimeout(timer)
timer = setTimeout(flush, 2000)
}

function flush() {
if (!queue.length) return
const body = JSON.stringify(queue.splice(0))
if (navigator.sendBeacon) navigator.sendBeacon('/collect', body)
else fetch('/collect', { method: 'POST', body, keepalive: true })
}

window.addEventListener('pagehide', flush)

还有一个自己打自己的问题。接口监控如果包了 XHR,上报请求自己也会被记成一条慢接口,然后这条再触发上报。上报地址直接排除掉,不要靠“发的时候加个标志”去赌时序。

接到 Vue 项目里

放在 main.js,new Vue / createApp 之前。用户 id 登录之后才有的话,先传空,登录成功再补一次配置,别为了等用户把整页监控推迟。

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { monitor } from './monitor'

const app = createApp(App)
app.use(router)
app.use(monitor, { endpoint: '/collect', appName: 'shop-admin' })
app.mount('#app')
// monitor.js 里跟一下路由,SPA 不能只在 load 时记一次 PV
export const monitor = {
install(app, options) {
attachVueError(app, (log) => pushLog({ ...log, app: options.appName }))
listenGlobalError()
watchPaint()

const router = app.config.globalProperties.$router
router.afterEach((to) => {
pushLog({ from: 'pv', path: to.path })
})
},
}

Vue 2 没有 app.config,errorHandler 在 Vue.config 上,把上面的 app 换成 Vue 构造函数即可。

本地怎么验,比接生产重要。我一般在 demo 里故意做几件事:组件里抛个错、请求一个 500、把根节点选择器配错看会不会误报白屏、来回切两次路由看 PV 和 LCP 有没有串页。这四条过了,再谈采样率。

做的时候记住这几条

  1. 上报地址没配就不要假装监控已经接上。安装时缺 endpoint 直接在控制台提醒,别默默丢掉。
  2. 模块顶层不要碰 window、navigator。Nuxt 这类项目 import 的时候还在服务端,一碰就炸。采集放到 init 之后。
  3. 劫持过的 pushState、XMLHttpRequest,销毁时还回去。页面卸载也要自动销毁,刷新残留的定时器会在下一次进来重复上报。
  4. 白屏、卡顿、错误去重都要节流。监控最怕的不是漏一条,是把上报服务打挂。
  5. 先把错误和慢接口做准。LCP、INP 这些可以后补,但一条带组件名和堆栈的 Vue 错误,比一堆看不懂的性能分有用。

监控不是为了收集更多数字。它要回答的是:这个错出在哪个页面、哪个组件、用户当时在干什么。这三样对上了,后面的看板怎么做都好说。

2026 年独立开发者技术栈

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×