页面点了个按钮,差不多五秒才弹窗。打开 DevTools,Network 扫了一圈,Performance 也录了段,还是看不出毛病。本地跑上几十遍,那个偶发白屏死活不出现。然后 PM 丢过来一张线上报错截图——「TypeError: Cannot read properties of undefined」,堆栈只指向 bundle.js:1。哪个组件炸的,完全没线索。

这种场景我经历太多次了。刚开始手动埋点,每个页面挂 onMounted 记个时间戳,每个接口包一层 try-catch 再塞一个上报函数。团队七八个人,两周发一版,埋点代码散得到处都是。上线前没人说得清到底哪些地方有监控,哪些地方漏了。后来我想,脏活就该交给工具干。

Vite 插件系统天然适合干这个——构建阶段注入代码,业务开发不用在每个组件里重复写监控逻辑。今年 Vite 8 已经正式发布了(2026 年 3 月官宣),Rolldown 驱动的构建更快,插件 API 也更稳。干脆把自动化性能监控和错误追踪做成插件,塞进项目里一劳永逸。

插件骨架:resolveId 和 load 怎么把监控脚本塞进去

Vite 插件的钩子不少,但做代码注入只靠几个就够了。resolveIdload 是核心搭档:前者拦截模块路径,后者返回虚拟模块的内容。先搭个最简结构:

// vite-plugin-monitor.ts
import type { Plugin } from 'vite'

export function viteMonitor(): Plugin {
  const VIRTUAL_MODULE_ID = 'virtual:monitor'
  const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID

  return {
    name: 'vite-plugin-monitor',
    resolveId(id) {
      if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
    },
    load(id) {
      if (id === RESOLVED_VIRTUAL_MODULE_ID) {
        return `
          import { setupPerf } from './src/monitor/perf'
          import { setupError } from './src/monitor/error'
          setupPerf()
          setupError()
        `
      }
    }
  }
}

然后在入口文件顶部加一行 import 'virtual:monitor'——这行代码不会被打到产物里吗?会的,但虚拟模块的内容完全由插件控制。你可以在 buildStart 阶段根据环境变量决定是否注入空模块。生产构建才塞真正的监控代码,开发环境只留一个空导出,不影响 HMR 体验。

团队里有人问「这个 import 哪来的,我没写过啊」——确实不用写。插件在 钩子里自动把脚本标签插进 index.html,业务代码一行都不用改。这也是我偏爱 Vite 插件的原因:侵入性低到几乎没有。

Vite plugin code structure

性能数据采集:首屏、组件渲染、还有那个 50ms 的坎

PerformanceObserver 是浏览器给前端监控的礼物。FCP、LCP、CLS 这些 Core Web Vitals 指标,直接用 监听:

// src/monitor/perf.ts
export function setupPerf() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.entryType === 'largest-contentful-paint') {
        report({ type: 'LCP', value: entry.startTime })
      }
      if (entry.entryType === 'first-contentful-paint') {
        report({ type: 'FCP', value: entry.startTime })
      }
    }
  })
  observer.observe({ type: 'largest-contentful-paint', buffered: true })
  observer.observe({ type: 'first-contentful-paint', buffered: true })
}

但光有浏览器指标不够。Vue 3 组件渲染耗时怎么抓?在插件注入的代码里,可以在 app.mount 前后挂勾子:

import { nextTick } from 'vue'

export function trackRenderTime(app) {
  let start = performance.now()
  app.mixin({
    mounted() {
      nextTick(() => {
        const elapsed = performance.now() - start
        report({ type: 'COMPONENT_MOUNT', name: this.$options.name, time: elapsed })
        start = performance.now()
      })
    }
  })
}

说实话,这个 mixin 方案有个坑:组件嵌套层次深的话,mounted 触发的顺序会导致时间统计偏移。更好的做法是在 vnode 打标记,onMounted 里用 回调再取值。我踩过这个坑,修了两版才稳定。

长任务(long tasks)也值得收——那些阻塞主线程超过 50ms 的脚本段,往往就是用户感知卡顿的元凶。PerformanceObserver 的 'longtask' 类型可以直接捕获,配合 attribution 属性还能知道是哪个 iframe 或脚本引起的。这个阈值是浏览器定的,但你可以根据业务调整上报策略。

PerformanceObserver monitoring web vitals

错误追踪:从 Vue errorHandler 到 source map 反解

Vue 3 的 能捕获组件渲染和事件处理器里的同步错误,但异步错误和未被 Vue 包裹的全局报错还得靠 window.addEventListener('unhandledrejection')

// src/monitor/error.ts
export function setupError(app) {
  app.config.errorHandler = (err, vm, info) => {
    report({
      type: 'VUE_ERROR',
      message: err.message,
      stack: err.stack,
      info,
      componentName: vm?.$options?.name
    })
  }

  window.addEventListener('unhandledrejection', (event) => {
    report({ type: 'UNHANDLED_REJECTION', message: event.reason?.message, stack: event.reason?.stack })
  })

  window.onerror = (message, source, lineno, colno, error) => {
    report({ type: 'GLOBAL_ERROR', message, source, lineno, colno, stack: error?.stack })
  }
}

堆栈里都是压缩后的文件名和行列号,不解析 source map 等于白收。生产环境通常不会把 source map 部署到 CDN,但可以在构建阶段把 map 文件上传到错误追踪服务。Vite 插件在 closeBundle 钩子里拿到 bundle,遍历生成的 chunk,把 .map 文件单独挑出来上传:

closeBundle() {
  const mapFiles = glob.sync('dist/**/*.js.map')
  mapFiles.forEach(file => uploadToErrorService(file))
}

注意别把 map 文件暴露到公网——Vite 的 设为 'hidden' 可以生成 map 但不往产物里写 sourceMappingURL,浏览器不会自动加载,只有上传脚本能拿到。

上报与看板:数据别烂在浏览器里

采集到的数据总要有个去处。设计上报接口时考虑三点:批量发送、去重、重试。用 Set 缓存 10 条或 5 秒内数据,一次 POST 出去。相同 type + message + componentName 的错误在 1 分钟内只上报一次。重试用指数退避,最多 3 次,避免刷爆用户流量。

Vite 插件在构建阶段注入上报 SDK,可以把上报地址和采样率做成插件选项:

// vite.config.ts
export default defineConfig({
  plugins: [
    viteMonitor({
      reportUrl: 'https://monitor.example.com/api/log',
      sampleRate: 0.1,
      sourceMapUpload: true
    })
  ]
})

采样率很重要——全量采集性能数据对高并发站点压力不小,大多数用户的指标是正常的,异常值才有分析价值。我一般把采样率设在 5%~20%,错误数据则全量采集。

看板方面,Grafana 接 Elasticsearch 或 ClickHouse 都行。核心看三个面板:错误趋势(按 type 堆叠)、页面加载耗时分布(P50/P90/P99)、组件渲染耗时排行榜。每周扫一眼哪个组件慢了,直接找对应的 owner 去修。

持续迭代:插件不是写完就完了

把插件发布到 npm 之后,CI/CD 流水线里加一步构建监控注入的验证——用 vitest 跑一个集成测试,启动 Vite 构建,检查产物里是否包含了监控模块的代码片段。不然哪天升级了 Vite 版本,插件钩子签名变了导致注入失效,你完全不知道。

根据实际数据来调整采集阈值这件事,很多人会忽略。比方说,LCP 的默认红线是 2.5 秒,但你做的后台管理系统,用户平均 LCP 才 1.2 秒——那不如把告警线压到 2 秒。不调的话,满屏告警全是噪音。告警太多,等于没有告警。

从 Vite 5 一路踩到现在的 Vite 8,中间折腾过三轮数据格式,还干过一次协议替换。核心反倒越来越简单——脏活累活全甩给构建工具,开发者就专心写业务。凌晨三点被叫起来查“偶发白屏”?那活儿留给插件干吧。