前端性能监控折腾手记

发版前 Lighthouse 跑分 95,产品经理反馈首页还是卡。本地实验室数据和用户真实网络完全是两回事,后面才补了 RUM,把 FCP、LCP 这些指标接到线上环境里看。

Lighthouse 监控

本地测试

# 使用 Chrome DevTools
# 或者使用命令行
npx lighthouse https://example.com --view

CI/CD 集成

# .github/workflows/performance.yml
name: Performance

on:
  pull_request:
    branches: [ main ]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v3
        with:
          urls: |
            https://staging.example.com
          uploadArtifacts: true
          temporaryPublicStorage: true

性能预算

// package.json
{
  "name": "myapp",
  "scripts": {
    "lighthouse": "lighthouse https://example.com --budget-path=budget.json --output=json"
  }
}

// budget.json
[
  {
    "path": "build/*.js",
    "timings": [
      {
        "metric": "first-contentful-paint",
        "budget": 2000
      },
      {
        "metric": "interactive",
        "budget": 5000
      }
    ],
    "resourceSizes": [
      {
        "resourceType": "script",
        "budget": 200
      },
      {
        "resourceType": "stylesheet",
        "budget": 50
      }
    ]
  }
]

核心指标

function getNavigationTiming() {
  const timing = performance.getEntriesByType('navigation')[0];
  
  return {
    // DNS 查询时间
    dns: timing.domainLookupEnd - timing.domainLookupStart,
    
    // TCP 连接时间
    tcp: timing.connectEnd - timing.connectStart,
    
    // 请求时间
    request: timing.responseStart - timing.requestStart,
    
    // 响应时间
    response: timing.responseEnd - timing.responseStart,
    
    // DOM 解析时间
    dom: timing.domComplete - timing.domInteractive,
    
    // 资源加载时间
    resources: timing.loadEventStart - timing.domContentLoadedEventEnd,
    
    // 总时间
    total: timing.loadEventEnd - timing.navigationStart
  };
}

Web Vitals

// First Contentful Paint (FCP)
const observerFCP = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const fcp = entries[0].startTime;
  
  // 上报到监控系统
  sendMetrics({ type: 'FCP', value: fcp });
  
  observerFCP.disconnect();
});

observerFCP.observe({ type: 'paint', buffered: true });

// Largest Contentful Paint (LCP)
const observerLCP = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lcp = entries[entries.length - 1].startTime;
  
  sendMetrics({ type: 'LCP', value: lcp });
});

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

// First Input Delay (FID)
const observerFID = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const fid = entries[0].processingStart - entries[0].startTime;
  
  sendMetrics({ type: 'FID', value: fid });
  
  observerFID.disconnect();
});

observerFID.observe({ type: 'first-input', buffered: true });

// Cumulative Layout Shift (CLS)
let clsValue = 0;
let clsEntries = [];

const observerCLS = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value;
      clsEntries.push(entry);
    }
  }
  
  sendMetrics({ type: 'CLS', value: clsValue });
});

observerCLS.observe({ type: 'layout-shift', buffered: true });

Resource Timing API

资源性能

function getResourceTiming() {
  const resources = performance.getEntriesByType('resource');
  
  return resources.map(resource => ({
    name: resource.name,
    type: resource.initiatorType,
    duration: resource.duration,
    transferSize: resource.transferSize,
    decodedBodySize: resource.decodedBodySize
  }));
}

// 慢资源监控
function monitorSlowResources() {
  const resources = performance.getEntriesByType('resource');
  const slowResources = resources.filter(r => r.duration > 1000);
  
  if (slowResources.length > 0) {
    sendMetrics({
      type: 'slow_resources',
      resources: slowResources
    });
  }
}

// 监控错误资源
window.addEventListener('error', (event) => {
  if (event.target !== window) {
    sendMetrics({
      type: 'resource_error',
      resource: event.target.src || event.target.href,
      tag: event.target.tagName
    });
  }
}, true);

Long Tasks API

长任务监控

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    sendMetrics({
      type: 'long_task',
      duration: entry.duration,
      startTime: entry.startTime
    });
  }
});

observer.observe({ entryTypes: ['longtask'] });

错误监控

JavaScript 错误

window.addEventListener('error', (event) => {
  sendMetrics({
    type: 'js_error',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
    colno: event.colno,
    stack: event.error?.stack
  });
});

window.addEventListener('unhandledrejection', (event) => {
  sendMetrics({
    type: 'promise_rejection',
    reason: event.reason
  });
});

React 错误边界

class ErrorBoundary extends React.Component {
  componentDidCatch(error, errorInfo) {
    sendMetrics({
      type: 'react_error',
      error: error.toString(),
      errorInfo: errorInfo.componentStack
    });
  }

  render() {
    return this.props.children;
  }
}

用户行为监控

点击监控

document.addEventListener('click', (event) => {
  const target = event.target;
  
  sendMetrics({
    type: 'click',
    tag: target.tagName,
    id: target.id,
    className: target.className,
    text: target.textContent?.slice(0, 50)
  });
});

路由监控

// React Router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function RouteMonitor() {
  const location = useLocation();
  
  useEffect(() => {
    const startTime = performance.now();
    
    return () => {
      const duration = performance.now() - startTime;
      
      sendMetrics({
        type: 'route',
        path: location.pathname,
        duration
      });
    };
  }, [location]);
  
  return null;
}

监控数据上报

使用 sendBeacon

function sendMetrics(data) {
  const blob = new Blob([JSON.stringify(data)], {
    type: 'application/json'
  });
  
  navigator.sendBeacon('/api/metrics', blob);
}

使用 Image

function sendMetrics(data) {
  const params = new URLSearchParams(data);
  const img = new Image();
  img.src = `/api/metrics?${params.toString()}`;
}

使用 XMLHttpRequest

function sendMetrics(data) {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/api/metrics', true);
  xhr.setRequestHeader('Content-Type', 'application/json');
  xhr.send(JSON.stringify(data));
}

监控数据处理

服务端处理

// Express 示例
app.use(express.json());

app.post('/api/metrics', async (req, res) => {
  const metrics = req.body;
  
  // 存储到数据库
  await db.insert('metrics', {
    type: metrics.type,
    value: metrics.value,
    userId: req.userId,
    timestamp: new Date(),
    userAgent: req.headers['user-agent'],
    url: metrics.url
  });
  
  res.sendStatus(200);
});

数据聚合

// 按小时聚合
async function aggregateMetrics() {
  const startTime = new Date(Date.now() - 3600000);
  const endTime = new Date();
  
  const metrics = await db.query(`
    SELECT type, AVG(value) as avg_value, COUNT(*) as count
    FROM metrics
    WHERE timestamp BETWEEN ? AND ?
    GROUP BY type
  `, [startTime, endTime]);
  
  // 存储聚合结果
  await db.insert('metrics_hourly', {
    hour: startTime,
    data: JSON.stringify(metrics)
  });
}

// 每小时执行一次
setInterval(aggregateMetrics, 3600000);

踩过的坑

坑一:采样率不当

一开始 100% 采样,数据太多,服务器扛不住。

解决:根据流量调整采样率。

const SAMPLING_RATE = 0.1;  // 10% 采样

function sendMetrics(data) {
  if (Math.random() > SAMPLING_RATE) {
    return;
  }
  
  // 上报数据
  ...
}

坑二:敏感信息泄露

监控数据中包含了用户信息。

解决:过滤敏感信息。

function sanitizeData(data) {
  const sensitiveKeys = ['password', 'token', 'ssn', 'creditCard'];
  
  const sanitized = { ...data };
  
  for (const key in sanitized) {
    for (const sensitiveKey of sensitiveKeys) {
      if (key.toLowerCase().includes(sensitiveKey.toLowerCase())) {
        sanitized[key] = '***REDACTED***';
      }
    }
  }
  
  return sanitized;
}

坑三:网络环境影响

用户网络环境差异大,监控数据不准确。

解决:记录网络信息,分组分析。

function getNetworkInfo() {
  if (navigator.connection) {
    return {
      type: navigator.connection.effectiveType,
      downlink: navigator.connection.downlink,
      rtt: navigator.connection.rtt
    };
  }
  return null;
}

function sendMetrics(data) {
  const networkInfo = getNetworkInfo();
  
  return {
    ...data,
    network: networkInfo
  };
}

写在最后

前端性能监控这东西,不是技术问题,是用户体验问题。

监控了

  • 页面加载时间
  • 交互响应时间
  • 错误率
  • 用户行为

带来了

  • 运维成本
  • 数据分析成本
  • 隐私问题

实施之前先评估:

  • 业务需求
  • 用户规模
  • 预算
  • 隐私要求

不是所有监控指标都重要,先关注影响用户体验的关键指标。


这次前端性能监控改造花了三周,从 Lighthouse 到 RUM。改造完成后,问题发现时间从 24 小时降到 1 小时,用户体验提升明显。

版权声明: 本文首发于 指尖魔法屋-前端性能监控折腾手记https://blog.thinkmoon.cn/post/71-frontend-performance-monitoring-lighthouse-rum/) 转载或引用必须申明原指尖魔法屋来源及源地址!