返回笔记首页

17.1 前端性能监控系统

主题配置

一、技术实现方案

1.1 性能监控架构

plain
性能监控系统
  ├── 指标采集层
  │   ├── Performance API
  │   ├── PerformanceObserver
  │   └── Navigation Timing
  │
  ├── 指标计算层
  │   ├── FCP (First Contentful Paint)
  │   ├── LCP (Largest Contentful Paint)
  │   ├── FID (First Input Delay)
  │   ├── CLS (Cumulative Layout Shift)
  │   └── TTFB (Time to First Byte)
  │
  ├── 数据处理层
  │   ├── 数据聚合
  │   ├── 数据缓存
  │   └── 数据上报
  │
  └── 监控展示层
      ├── 实时监控面板
      ├── 性能趋势图
      └── 性能报告

1.2 技术栈

  • 性能API: Performance API, PerformanceObserver
  • 前端框架: Vue 3 Composition API
  • 数据可视化: ECharts
  • 数据上报: Beacon API / fetch

二、Performance API 应用

2.1 性能数据采集器

performance-monitor.js

javascript
export class PerformanceMonitor {
  constructor() {
    this.metrics = {
      fcp: 0,
      lcp: 0,
      fid: 0,
      cls: 0,
      ttfb: 0,
      domReady: 0,
      loadComplete: 0
    }

    this.observers = []
    this.init()
  }

  // 初始化监控
  init() {
    // 等待页面加载完成
    if (document.readyState === 'complete') {
      this.collectMetrics()
    } else {
      window.addEventListener('load', () => {
        this.collectMetrics()
      })
    }
  }

  // 采集基础指标
  collectMetrics() {
    this.collectNavigationTiming()
    this.collectWebVitals()
    this.observeLongTasks()
  }

  // 采集导航时间
  collectNavigationTiming() {
    const timing = performance.timing

    // DNS 查询时间
    this.metrics.dns = timing.domainLookupEnd - timing.domainLookupStart

    // TCP 连接时间
    this.metrics.tcp = timing.connectEnd - timing.connectStart

    // TTFB (Time to First Byte)
    this.metrics.ttfb = timing.responseStart - timing.navigationStart

    // DOM 解析时间
    this.metrics.domParse = timing.domInteractive - timing.domLoading

    // DOM Ready 时间
    this.metrics.domReady = timing.domContentLoadedEventEnd - timing.navigationStart

    // 页面完全加载时间
    this.metrics.loadComplete = timing.loadEventEnd - timing.navigationStart

    // 白屏时间 (首次渲染)
    this.metrics.firstPaint = timing.responseEnd - timing.fetchStart

    console.log('Navigation Timing:', this.metrics)
  }

  // 采集 Web Vitals 指标
  collectWebVitals() {
    // FCP - First Contentful Paint
    this.observeFCP()

    // LCP - Largest Contentful Paint
    this.observeLCP()

    // FID - First Input Delay
    this.observeFID()

    // CLS - Cumulative Layout Shift
    this.observeCLS()
  }

  // 监听 FCP
  observeFCP() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.name === 'first-contentful-paint') {
          this.metrics.fcp = entry.startTime
          console.log('FCP:', entry.startTime)
          observer.disconnect()
        }
      }
    })

    observer.observe({ entryTypes: ['paint'] })
    this.observers.push(observer)
  }

  // 监听 LCP
  observeLCP() {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries()
      const lastEntry = entries[entries.length - 1]

      this.metrics.lcp = lastEntry.startTime
      console.log('LCP:', lastEntry.startTime, lastEntry.element)
    })

    observer.observe({ entryTypes: ['largest-contentful-paint'] })
    this.observers.push(observer)
  }

  // 监听 FID
  observeFID() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        this.metrics.fid = entry.processingStart - entry.startTime
        console.log('FID:', this.metrics.fid)
        observer.disconnect()
      }
    })

    observer.observe({ entryTypes: ['first-input'] })
    this.observers.push(observer)
  }

  // 监听 CLS
  observeCLS() {
    let clsValue = 0

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value
          this.metrics.cls = clsValue
          console.log('CLS:', clsValue)
        }
      }
    })

    observer.observe({ entryTypes: ['layout-shift'] })
    this.observers.push(observer)
  }

  // 监听长任务
  observeLongTasks() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.duration > 50) {
          console.warn('Long Task detected:', {
            duration: entry.duration,
            startTime: entry.startTime
          })

          // 记录长任务
          if (!this.metrics.longTasks) {
            this.metrics.longTasks = []
          }
          this.metrics.longTasks.push({
            duration: entry.duration,
            startTime: entry.startTime
          })
        }
      }
    })

    try {
      observer.observe({ entryTypes: ['longtask'] })
      this.observers.push(observer)
    } catch (e) {
      console.warn('Long Task API not supported')
    }
  }

  // 获取所有指标
  getMetrics() {
    return { ...this.metrics }
  }

  // 销毁监控
  destroy() {
    this.observers.forEach(observer => observer.disconnect())
    this.observers = []
  }
}

2.2 性能监控组件

PerformanceMonitor.vue

vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { PerformanceMonitor } from './performance-monitor.js'

const performanceMonitor = ref(null)
const metrics = ref({})
const isMonitoring = ref(false)

// 性能评分标准
const performanceStandards = {
  fcp: { good: 1800, needsImprovement: 3000 },
  lcp: { good: 2500, needsImprovement: 4000 },
  fid: { good: 100, needsImprovement: 300 },
  cls: { good: 0.1, needsImprovement: 0.25 },
  ttfb: { good: 800, needsImprovement: 1800 }
}

// 初始化监控
const initMonitor = () => {
  performanceMonitor.value = new PerformanceMonitor()
  isMonitoring.value = true

  // 5秒后获取指标
  setTimeout(() => {
    metrics.value = performanceMonitor.value.getMetrics()
  }, 5000)
}

// 刷新指标
const refreshMetrics = () => {
  if (performanceMonitor.value) {
    metrics.value = performanceMonitor.value.getMetrics()
  }
}

// 获取指标评分
const getMetricScore = (metricName, value) => {
  const standard = performanceStandards[metricName]
  if (!standard) return 'unknown'

  if (value <= standard.good) return 'good'
  if (value <= standard.needsImprovement) return 'needs-improvement'
  return 'poor'
}

// 获取评分颜色
const getScoreColor = (score) => {
  const colors = {
    good: '#67c23a',
    'needs-improvement': '#e6a23c',
    poor: '#f56c6c',
    unknown: '#909399'
  }
  return colors[score] || colors.unknown
}

// 格式化时间
const formatTime = (ms) => {
  if (!ms && ms !== 0) return '-'
  return `${ms.toFixed(2)} ms`
}

// 格式化 CLS
const formatCLS = (value) => {
  if (!value && value !== 0) return '-'
  return value.toFixed(3)
}

// 模拟性能问题
const simulatePerformanceIssue = () => {
  // 模拟长任务
  const startTime = Date.now()
  while (Date.now() - startTime < 200) {
    // 占用主线程
  }

  // 模拟布局抖动
  const div = document.createElement('div')
  div.style.width = '100px'
  div.style.height = '100px'
  document.body.appendChild(div)

  setTimeout(() => {
    div.style.width = '200px'
    div.style.height = '200px'
  }, 100)

  setTimeout(() => {
    document.body.removeChild(div)
  }, 200)

  setTimeout(refreshMetrics, 500)
}

onMounted(() => {
  initMonitor()
})

onUnmounted(() => {
  if (performanceMonitor.value) {
    performanceMonitor.value.destroy()
  }
})
</script>

<template>
  <div class="performance-monitor">
    <div class="monitor-header">
      <h2>前端性能监控</h2>
      <div class="header-actions">
        <button @click="refreshMetrics" class="refresh-btn">
          刷新数据
        </button>
        <button @click="simulatePerformanceIssue" class="simulate-btn">
          模拟性能问题
        </button>
        <div class="status-badge" :class="{ active: isMonitoring }">
          {{ isMonitoring ? '监控中' : '已停止' }}
        </div>
      </div>
    </div>

    <!-- Web Vitals 指标 -->
    <div class="metrics-section">
      <h3>Web Vitals 核心指标</h3>
      <div class="metrics-grid">
        <div class="metric-card">
          <div class="metric-header">
            <span class="metric-name">FCP</span>
            <span class="metric-desc">First Contentful Paint</span>
          </div>
          <div class="metric-value"
               :style="{ color: getScoreColor(getMetricScore('fcp', metrics.fcp)) }">
            {{ formatTime(metrics.fcp) }}
          </div>
          <div class="metric-score"
               :style="{ background: getScoreColor(getMetricScore('fcp', metrics.fcp)) + '20' }">
            <span :style="{ color: getScoreColor(getMetricScore('fcp', metrics.fcp)) }">
              {{ getMetricScore('fcp', metrics.fcp).toUpperCase() }}
            </span>
          </div>
          <div class="metric-standard">
            良好: 1.8s | 待改进: 3.0s
          </div>
        </div>

        <div class="metric-card">
          <div class="metric-header">
            <span class="metric-name">LCP</span>
            <span class="metric-desc">Largest Contentful Paint</span>
          </div>
          <div class="metric-value"
               :style="{ color: getScoreColor(getMetricScore('lcp', metrics.lcp)) }">
            {{ formatTime(metrics.lcp) }}
          </div>
          <div class="metric-score"
               :style="{ background: getScoreColor(getMetricScore('lcp', metrics.lcp)) + '20' }">
            <span :style="{ color: getScoreColor(getMetricScore('lcp', metrics.lcp)) }">
              {{ getMetricScore('lcp', metrics.lcp).toUpperCase() }}
            </span>
          </div>
          <div class="metric-standard">
            良好: 2.5s | 待改进: 4.0s
          </div>
        </div>

        <div class="metric-card">
          <div class="metric-header">
            <span class="metric-name">FID</span>
            <span class="metric-desc">First Input Delay</span>
          </div>
          <div class="metric-value"
               :style="{ color: getScoreColor(getMetricScore('fid', metrics.fid)) }">
            {{ formatTime(metrics.fid) }}
          </div>
          <div class="metric-score"
               :style="{ background: getScoreColor(getMetricScore('fid', metrics.fid)) + '20' }">
            <span :style="{ color: getScoreColor(getMetricScore('fid', metrics.fid)) }">
              {{ getMetricScore('fid', metrics.fid).toUpperCase() }}
            </span>
          </div>
          <div class="metric-standard">
            良好: 100ms | 待改进: 300ms
          </div>
        </div>

        <div class="metric-card">
          <div class="metric-header">
            <span class="metric-name">CLS</span>
            <span class="metric-desc">Cumulative Layout Shift</span>
          </div>
          <div class="metric-value"
               :style="{ color: getScoreColor(getMetricScore('cls', metrics.cls)) }">
            {{ formatCLS(metrics.cls) }}
          </div>
          <div class="metric-score"
               :style="{ background: getScoreColor(getMetricScore('cls', metrics.cls)) + '20' }">
            <span :style="{ color: getScoreColor(getMetricScore('cls', metrics.cls)) }">
              {{ getMetricScore('cls', metrics.cls).toUpperCase() }}
            </span>
          </div>
          <div class="metric-standard">
            良好: 0.1 | 待改进: 0.25
          </div>
        </div>
      </div>
    </div>

    <!-- 页面加载指标 -->
    <div class="metrics-section">
      <h3>页面加载指标</h3>
      <div class="loading-metrics">
        <div class="loading-item">
          <div class="loading-label">DNS 查询</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '10%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.dns) }}</div>
        </div>

        <div class="loading-item">
          <div class="loading-label">TCP 连接</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '15%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.tcp) }}</div>
        </div>

        <div class="loading-item">
          <div class="loading-label">TTFB</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '25%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.ttfb) }}</div>
        </div>

        <div class="loading-item">
          <div class="loading-label">DOM 解析</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '30%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.domParse) }}</div>
        </div>

        <div class="loading-item">
          <div class="loading-label">DOM Ready</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '60%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.domReady) }}</div>
        </div>

        <div class="loading-item">
          <div class="loading-label">完全加载</div>
          <div class="loading-bar">
            <div class="loading-fill" :style="{ width: '100%' }"></div>
          </div>
          <div class="loading-value">{{ formatTime(metrics.loadComplete) }}</div>
        </div>
      </div>
    </div>

    <!-- 长任务列表 -->
    <div v-if="metrics.longTasks && metrics.longTasks.length > 0" class="metrics-section">
      <h3>长任务检测 ({{ metrics.longTasks.length }})</h3>
      <div class="long-tasks-list">
        <div v-for="(task, index) in metrics.longTasks" :key="index" class="long-task-item">
          <div class="task-index">{{ index + 1 }}</div>
          <div class="task-info">
            <div class="task-duration">
              耗时: <span class="highlight">{{ formatTime(task.duration) }}</span>
            </div>
            <div class="task-time">
              开始时间: {{ formatTime(task.startTime) }}
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.performance-monitor {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.monitor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.monitor-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.refresh-btn,
.simulate-btn {
  padding: 8px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.refresh-btn {
  background: #409eff;
  color: white;
}

.refresh-btn:hover {
  background: #66b1ff;
}

.simulate-btn {
  background: #e6a23c;
  color: white;
}

.simulate-btn:hover {
  background: #ebb563;
}

.status-badge {
  padding: 6px 16px;
  border-radius: 20px;
  font-size: 13px;
  background: #f56c6c;
  color: white;
}

.status-badge.active {
  background: #67c23a;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

/* 指标区域 */
.metrics-section {
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.metrics-section h3 {
  margin: 0 0 20px 0;
  font-size: 18px;
  color: #303133;
  border-bottom: 2px solid #f0f0f0;
  padding-bottom: 12px;
}

.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
}

.metric-card {
  padding: 20px;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  transition: all 0.3s;
}

.metric-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

.metric-header {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 16px;
}

.metric-name {
  font-size: 16px;
  font-weight: 700;
  color: #303133;
}

.metric-desc {
  font-size: 12px;
  color: #909399;
}

.metric-value {
  font-size: 32px;
  font-weight: 700;
  margin-bottom: 12px;
}

.metric-score {
  display: inline-block;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
  margin-bottom: 8px;
}

.metric-standard {
  font-size: 12px;
  color: #c0c4cc;
}

/* 加载指标 */
.loading-metrics {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.loading-item {
  display: grid;
  grid-template-columns: 120px 1fr 120px;
  align-items: center;
  gap: 16px;
}

.loading-label {
  font-size: 14px;
  color: #606266;
  font-weight: 600;
}

.loading-bar {
  height: 8px;
  background: #f0f0f0;
  border-radius: 4px;
  overflow: hidden;
}

.loading-fill {
  height: 100%;
  background: linear-gradient(90deg, #409eff, #66b1ff);
  border-radius: 4px;
  transition: width 0.3s;
}

.loading-value {
  font-size: 14px;
  color: #303133;
  font-weight: 600;
  text-align: right;
}

/* 长任务列表 */
.long-tasks-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.long-task-item {
  display: flex;
  gap: 16px;
  padding: 16px;
  background: #fff5f5;
  border-left: 3px solid #f56c6c;
  border-radius: 4px;
}

.task-index {
  width: 32px;
  height: 32px;
  background: #f56c6c;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 600;
  flex-shrink: 0;
}

.task-info {
  flex: 1;
}

.task-duration {
  font-size: 14px;
  color: #303133;
  margin-bottom: 4px;
}

.task-duration .highlight {
  color: #f56c6c;
  font-weight: 700;
}

.task-time {
  font-size: 12px;
  color: #909399;
}
</style>

三、资源加载监控

3.1 资源监控器

resource-monitor.js

javascript
export class ResourceMonitor {
  constructor() {
    this.resources = []
    this.init()
  }

  init() {
    if (document.readyState === 'complete') {
      this.collectResources()
    } else {
      window.addEventListener('load', () => {
        this.collectResources()
      })
    }

    // 监听新资源
    this.observeResources()
  }

  // 采集已加载资源
  collectResources() {
    const resources = performance.getEntriesByType('resource')

    this.resources = resources.map(resource => ({
      name: resource.name,
      type: this.getResourceType(resource),
      size: resource.transferSize,
      duration: resource.duration,
      startTime: resource.startTime,
      dns: resource.domainLookupEnd - resource.domainLookupStart,
      tcp: resource.connectEnd - resource.connectStart,
      ttfb: resource.responseStart - resource.requestStart,
      download: resource.responseEnd - resource.responseStart,
      cached: resource.transferSize === 0
    }))

    console.log('Resources loaded:', this.resources)
    this.analyzeResources()
  }

  // 监听新资源加载
  observeResources() {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const resource = {
          name: entry.name,
          type: this.getResourceType(entry),
          size: entry.transferSize,
          duration: entry.duration,
          startTime: entry.startTime,
          cached: entry.transferSize === 0
        }

        this.resources.push(resource)
        console.log('New resource loaded:', resource)
      }
    })

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

  // 获取资源类型
  getResourceType(resource) {
    const initiatorType = resource.initiatorType

    if (initiatorType === 'img') return 'image'
    if (initiatorType === 'script') return 'script'
    if (initiatorType === 'link' || initiatorType === 'css') return 'stylesheet'
    if (initiatorType === 'xmlhttprequest' || initiatorType === 'fetch') return 'xhr'

    // 根据文件扩展名判断
    const url = resource.name
    if (/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)) return 'image'
    if (/\.js$/i.test(url)) return 'script'
    if (/\.css$/i.test(url)) return 'stylesheet'
    if (/\.(woff|woff2|ttf|eot)$/i.test(url)) return 'font'

    return 'other'
  }

  // 分析资源
  analyzeResources() {
    const analysis = {
      total: this.resources.length,
      totalSize: 0,
      totalDuration: 0,
      cached: 0,
      byType: {}
    }

    this.resources.forEach(resource => {
      analysis.totalSize += resource.size
      analysis.totalDuration += resource.duration

      if (resource.cached) {
        analysis.cached++
      }

      if (!analysis.byType[resource.type]) {
        analysis.byType[resource.type] = {
          count: 0,
          size: 0,
          duration: 0
        }
      }

      analysis.byType[resource.type].count++
      analysis.byType[resource.type].size += resource.size
      analysis.byType[resource.type].duration += resource.duration
    })

    console.log('Resource Analysis:', analysis)
    return analysis
  }

  // 获取慢资源
  getSlowResources(threshold = 1000) {
    return this.resources
      .filter(r => r.duration > threshold)
      .sort((a, b) => b.duration - a.duration)
  }

  // 获取大文件
  getLargeResources(threshold = 500 * 1024) {
    return this.resources
      .filter(r => r.size > threshold)
      .sort((a, b) => b.size - a.size)
  }

  // 获取所有资源
  getResources() {
    return this.resources
  }
}

3.2 资源监控组件

ResourceMonitor.vue

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ResourceMonitor } from './resource-monitor.js'

const resourceMonitor = ref(null)
const resources = ref([])
const selectedType = ref('all')
const sortBy = ref('duration')

// 资源统计
const statistics = computed(() => {
  if (resources.value.length === 0) return null

  const stats = {
    total: resources.value.length,
    totalSize: 0,
    totalDuration: 0,
    cached: 0,
    byType: {}
  }

  resources.value.forEach(resource => {
    stats.totalSize += resource.size || 0
    stats.totalDuration += resource.duration || 0

    if (resource.cached) stats.cached++

    if (!stats.byType[resource.type]) {
      stats.byType[resource.type] = { count: 0, size: 0 }
    }
    stats.byType[resource.type].count++
    stats.byType[resource.type].size += resource.size || 0
  })

  return stats
})

// 过滤和排序后的资源
const filteredResources = computed(() => {
  let filtered = resources.value

  if (selectedType.value !== 'all') {
    filtered = filtered.filter(r => r.type === selectedType.value)
  }

  return filtered.sort((a, b) => {
    if (sortBy.value === 'duration') {
      return b.duration - a.duration
    } else if (sortBy.value === 'size') {
      return b.size - a.size
    }
    return 0
  })
})

// 初始化监控
const initMonitor = () => {
  resourceMonitor.value = new ResourceMonitor()
  loadResources()
}

// 加载资源数据
const loadResources = () => {
  if (resourceMonitor.value) {
    resources.value = resourceMonitor.value.getResources()
  }
}

// 刷新数据
const refreshData = () => {
  loadResources()
}

// 格式化文件大小
const formatSize = (bytes) => {
  if (!bytes) return '0 B'
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}

// 格式化时间
const formatTime = (ms) => {
  if (!ms) return '0 ms'
  return `${ms.toFixed(2)} ms`
}

// 获取文件名
const getFileName = (url) => {
  return url.split('/').pop() || url
}

// 获取类型颜色
const getTypeColor = (type) => {
  const colors = {
    script: '#409eff',
    stylesheet: '#67c23a',
    image: '#e6a23c',
    font: '#f56c6c',
    xhr: '#909399',
    other: '#c0c4cc'
  }
  return colors[type] || colors.other
}

onMounted(() => {
  initMonitor()
})
</script>

<template>
  <div class="resource-monitor">
    <div class="monitor-header">
      <h2>资源加载监控</h2>
      <button @click="refreshData" class="refresh-btn">
        刷新数据
      </button>
    </div>

    <!-- 统计卡片 -->
    <div v-if="statistics" class="statistics-grid">
      <div class="stat-card">
        <div class="stat-label">总资源数</div>
        <div class="stat-value">{{ statistics.total }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">总大小</div>
        <div class="stat-value">{{ formatSize(statistics.totalSize) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">总耗时</div>
        <div class="stat-value">{{ formatTime(statistics.totalDuration) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">缓存命中</div>
        <div class="stat-value">{{ statistics.cached }}</div>
      </div>
    </div>

    <!-- 按类型统计 -->
    <div v-if="statistics" class="type-statistics">
      <h3>资源类型分布</h3>
      <div class="type-grid">
        <div
          v-for="(data, type) in statistics.byType"
          :key="type"
          class="type-item"
          :style="{ borderColor: getTypeColor(type) }"
        >
          <div class="type-header">
            <span class="type-name" :style="{ color: getTypeColor(type) }">
              {{ type.toUpperCase() }}
            </span>
            <span class="type-count">{{ data.count }}</span>
          </div>
          <div class="type-size">{{ formatSize(data.size) }}</div>
        </div>
      </div>
    </div>

    <!-- 筛选和排序 -->
    <div class="filter-section">
      <div class="filter-group">
        <label>类型筛选:</label>
        <select v-model="selectedType">
          <option value="all">全部</option>
          <option value="script">Script</option>
          <option value="stylesheet">Stylesheet</option>
          <option value="image">Image</option>
          <option value="font">Font</option>
          <option value="xhr">XHR</option>
          <option value="other">Other</option>
        </select>
      </div>
      <div class="filter-group">
        <label>排序方式:</label>
        <select v-model="sortBy">
          <option value="duration">按耗时</option>
          <option value="size">按大小</option>
        </select>
      </div>
    </div>

    <!-- 资源列表 -->
    <div class="resource-list">
      <div class="list-header">
        <div class="col-name">资源名称</div>
        <div class="col-type">类型</div>
        <div class="col-size">大小</div>
        <div class="col-duration">耗时</div>
        <div class="col-status">状态</div>
      </div>

      <div
        v-for="(resource, index) in filteredResources"
        :key="index"
        class="resource-item"
      >
        <div class="col-name" :title="resource.name">
          {{ getFileName(resource.name) }}
        </div>
        <div class="col-type">
          <span class="type-badge" :style="{ background: getTypeColor(resource.type) + '20', color: getTypeColor(resource.type) }">
            {{ resource.type }}
          </span>
        </div>
        <div class="col-size">{{ formatSize(resource.size) }}</div>
        <div class="col-duration">{{ formatTime(resource.duration) }}</div>
        <div class="col-status">
          <span v-if="resource.cached" class="cached-badge">缓存</span>
          <span v-else class="network-badge">网络</span>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.resource-monitor {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.monitor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.monitor-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.refresh-btn {
  padding: 8px 20px;
  background: #409eff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.statistics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.stat-card {
  padding: 20px;
  background: white;
  border-radius: 8px;
  text-align: center;
}

.stat-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-value {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
}

.type-statistics {
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.type-statistics h3 {
  margin: 0 0 16px 0;
  font-size: 16px;
  color: #303133;
}

.type-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 16px;
}

.type-item {
  padding: 16px;
  border: 2px solid;
  border-radius: 8px;
}

.type-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.type-name {
  font-size: 14px;
  font-weight: 700;
}

.type-count {
  font-size: 18px;
  font-weight: 700;
  color: #303133;
}

.type-size {
  font-size: 12px;
  color: #909399;
}

.filter-section {
  display: flex;
  gap: 24px;
  padding: 16px 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 16px;
}

.filter-group {
  display: flex;
  align-items: center;
  gap: 12px;
}

.filter-group label {
  font-size: 14px;
  color: #606266;
  font-weight: 600;
}

.filter-group select {
  padding: 6px 12px;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  font-size: 14px;
  outline: none;
}

.resource-list {
  background: white;
  border-radius: 8px;
  overflow: hidden;
}

.list-header,
.resource-item {
  display: grid;
  grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
  gap: 16px;
  padding: 16px 20px;
  align-items: center;
}

.list-header {
  background: #f5f7fa;
  font-weight: 600;
  font-size: 14px;
  color: #606266;
}

.resource-item {
  border-bottom: 1px solid #f0f0f0;
  font-size: 14px;
  color: #303133;
}

.resource-item:last-child {
  border-bottom: none;
}

.col-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.type-badge {
  display: inline-block;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
}

.cached-badge,
.network-badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 600;
}

.cached-badge {
  background: #e1f3d8;
  color: #67c23a;
}

.network-badge {
  background: #ecf5ff;
  color: #409eff;
}
</style>

四、接口性能监控

4.1 接口监控拦截器

api-monitor.js

javascript
export class ApiMonitor {
  constructor() {
    this.requests = []
    this.maxRecords = 100
    this.init()
  }

  init() {
    this.interceptFetch()
    this.interceptXHR()
  }

  // 拦截 Fetch
  interceptFetch() {
    const originalFetch = window.fetch
    const self = this

    window.fetch = function(...args) {
      const startTime = Date.now()
      const url = args[0]
      const options = args[1] || {}

      const requestInfo = {
        id: self.generateId(),
        url: url,
        method: options.method || 'GET',
        startTime: startTime,
        type: 'fetch'
      }

      return originalFetch.apply(this, args)
        .then(response => {
          const endTime = Date.now()

          requestInfo.duration = endTime - startTime
          requestInfo.status = response.status
          requestInfo.success = response.ok
          requestInfo.endTime = endTime

          self.recordRequest(requestInfo)

          return response
        })
        .catch(error => {
          const endTime = Date.now()

          requestInfo.duration = endTime - startTime
          requestInfo.success = false
          requestInfo.error = error.message
          requestInfo.endTime = endTime

          self.recordRequest(requestInfo)

          throw error
        })
    }
  }

  // 拦截 XMLHttpRequest
  interceptXHR() {
    const self = this
    const originalOpen = XMLHttpRequest.prototype.open
    const originalSend = XMLHttpRequest.prototype.send

    XMLHttpRequest.prototype.open = function(method, url) {
      this._requestInfo = {
        id: self.generateId(),
        url: url,
        method: method,
        type: 'xhr'
      }
      return originalOpen.apply(this, arguments)
    }

    XMLHttpRequest.prototype.send = function() {
      const requestInfo = this._requestInfo
      requestInfo.startTime = Date.now()

      this.addEventListener('loadend', function() {
        const endTime = Date.now()

        requestInfo.duration = endTime - requestInfo.startTime
        requestInfo.status = this.status
        requestInfo.success = this.status >= 200 && this.status < 300
        requestInfo.endTime = endTime

        self.recordRequest(requestInfo)
      })

      return originalSend.apply(this, arguments)
    }
  }

  // 记录请求
  recordRequest(requestInfo) {
    this.requests.unshift(requestInfo)

    if (this.requests.length > this.maxRecords) {
      this.requests.pop()
    }

    console.log('API Request:', requestInfo)

    // 检查慢接口
    if (requestInfo.duration > 1000) {
      console.warn('Slow API detected:', requestInfo)
    }

    // 检查失败请求
    if (!requestInfo.success) {
      console.error('API Request failed:', requestInfo)
    }
  }

  // 获取所有请求
  getRequests() {
    return this.requests
  }

  // 获取慢接口
  getSlowRequests(threshold = 1000) {
    return this.requests
      .filter(r => r.duration > threshold)
      .sort((a, b) => b.duration - a.duration)
  }

  // 获取失败请求
  getFailedRequests() {
    return this.requests.filter(r => !r.success)
  }

  // 获取统计
  getStatistics() {
    const stats = {
      total: this.requests.length,
      success: 0,
      failed: 0,
      avgDuration: 0,
      maxDuration: 0,
      minDuration: Infinity
    }

    let totalDuration = 0

    this.requests.forEach(request => {
      if (request.success) {
        stats.success++
      } else {
        stats.failed++
      }

      totalDuration += request.duration
      stats.maxDuration = Math.max(stats.maxDuration, request.duration)
      stats.minDuration = Math.min(stats.minDuration, request.duration)
    })

    stats.avgDuration = stats.total > 0 ? totalDuration / stats.total : 0

    if (stats.minDuration === Infinity) {
      stats.minDuration = 0
    }

    return stats
  }

  // 清空记录
  clear() {
    this.requests = []
  }

  // 生成ID
  generateId() {
    return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }
}

4.2 接口监控组件

ApiMonitor.vue

vue
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ApiMonitor } from './api-monitor.js'

const apiMonitor = ref(null)
const requests = ref([])
const filterStatus = ref('all')
const autoRefresh = ref(true)
let refreshTimer = null

// 统计信息
const statistics = computed(() => {
  if (!apiMonitor.value) return null
  return apiMonitor.value.getStatistics()
})

// 过滤后的请求
const filteredRequests = computed(() => {
  if (filterStatus.value === 'all') {
    return requests.value
  } else if (filterStatus.value === 'success') {
    return requests.value.filter(r => r.success)
  } else if (filterStatus.value === 'failed') {
    return requests.value.filter(r => !r.success)
  } else if (filterStatus.value === 'slow') {
    return requests.value.filter(r => r.duration > 1000)
  }
  return requests.value
})

// 初始化监控
const initMonitor = () => {
  apiMonitor.value = new ApiMonitor()
  loadRequests()

  if (autoRefresh.value) {
    startAutoRefresh()
  }
}

// 加载请求数据
const loadRequests = () => {
  if (apiMonitor.value) {
    requests.value = apiMonitor.value.getRequests()
  }
}

// 自动刷新
const startAutoRefresh = () => {
  refreshTimer = setInterval(() => {
    loadRequests()
  }, 2000)
}

const stopAutoRefresh = () => {
  if (refreshTimer) {
    clearInterval(refreshTimer)
    refreshTimer = null
  }
}

// 切换自动刷新
const toggleAutoRefresh = () => {
  autoRefresh.value = !autoRefresh.value

  if (autoRefresh.value) {
    startAutoRefresh()
  } else {
    stopAutoRefresh()
  }
}

// 清空数据
const clearData = () => {
  if (confirm('确定要清空所有数据吗?')) {
    apiMonitor.value.clear()
    loadRequests()
  }
}

// 模拟API请求
const simulateApiCall = async (type = 'success') => {
  const baseUrl = 'https://jsonplaceholder.typicode.com'

  try {
    if (type === 'success') {
      await fetch(`${baseUrl}/posts/1`)
    } else if (type === 'slow') {
      // 模拟慢接口
      await fetch(`${baseUrl}/posts`)
    } else if (type === 'failed') {
      await fetch(`${baseUrl}/invalid-endpoint`)
    }
  } catch (error) {
    console.error('API call failed:', error)
  }

  setTimeout(loadRequests, 100)
}

// 格式化时间
const formatTime = (ms) => {
  if (!ms) return '-'
  return `${ms.toFixed(0)} ms`
}

// 格式化日期
const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

// 获取状态颜色
const getStatusColor = (status) => {
  if (status >= 200 && status < 300) return '#67c23a'
  if (status >= 300 && status < 400) return '#409eff'
  if (status >= 400 && status < 500) return '#e6a23c'
  return '#f56c6c'
}

// 获取方法颜色
const getMethodColor = (method) => {
  const colors = {
    GET: '#409eff',
    POST: '#67c23a',
    PUT: '#e6a23c',
    DELETE: '#f56c6c',
    PATCH: '#909399'
  }
  return colors[method] || '#c0c4cc'
}

onMounted(() => {
  initMonitor()
})
</script>

<template>
  <div class="api-monitor">
    <div class="monitor-header">
      <h2>接口性能监控</h2>
      <div class="header-actions">
        <button @click="simulateApiCall('success')" class="simulate-btn success">
          模拟成功请求
        </button>
        <button @click="simulateApiCall('slow')" class="simulate-btn slow">
          模拟慢请求
        </button>
        <button @click="simulateApiCall('failed')" class="simulate-btn failed">
          模拟失败请求
        </button>
        <button @click="toggleAutoRefresh" class="auto-refresh-btn" :class="{ active: autoRefresh }">
          {{ autoRefresh ? '自动刷新: 开' : '自动刷新: 关' }}
        </button>
        <button @click="clearData" class="clear-btn">
          清空数据
        </button>
      </div>
    </div>

    <!-- 统计卡片 -->
    <div v-if="statistics" class="statistics-grid">
      <div class="stat-card">
        <div class="stat-label">总请求数</div>
        <div class="stat-value">{{ statistics.total }}</div>
      </div>
      <div class="stat-card success">
        <div class="stat-label">成功</div>
        <div class="stat-value">{{ statistics.success }}</div>
      </div>
      <div class="stat-card failed">
        <div class="stat-label">失败</div>
        <div class="stat-value">{{ statistics.failed }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">平均耗时</div>
        <div class="stat-value">{{ formatTime(statistics.avgDuration) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">最长耗时</div>
        <div class="stat-value">{{ formatTime(statistics.maxDuration) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">最短耗时</div>
        <div class="stat-value">{{ formatTime(statistics.minDuration) }}</div>
      </div>
    </div>

    <!-- 筛选器 -->
    <div class="filter-section">
      <div class="filter-tabs">
        <button
          :class="{ active: filterStatus === 'all' }"
          @click="filterStatus = 'all'"
        >
          全部 ({{ requests.length }})
        </button>
        <button
          :class="{ active: filterStatus === 'success' }"
          @click="filterStatus = 'success'"
        >
          成功 ({{ requests.filter(r => r.success).length }})
        </button>
        <button
          :class="{ active: filterStatus === 'failed' }"
          @click="filterStatus = 'failed'"
        >
          失败 ({{ requests.filter(r => !r.success).length }})
        </button>
        <button
          :class="{ active: filterStatus === 'slow' }"
          @click="filterStatus = 'slow'"
        >
          慢请求 ({{ requests.filter(r => r.duration > 1000).length }})
        </button>
      </div>
    </div>

    <!-- 请求列表 -->
    <div class="request-list">
      <div class="list-header">
        <div class="col-method">方法</div>
        <div class="col-url">URL</div>
        <div class="col-status">状态</div>
        <div class="col-duration">耗时</div>
        <div class="col-time">时间</div>
      </div>

      <div
        v-for="request in filteredRequests"
        :key="request.id"
        class="request-item"
        :class="{ failed: !request.success, slow: request.duration > 1000 }"
      >
        <div class="col-method">
          <span
            class="method-badge"
            :style="{ background: getMethodColor(request.method) + '20', color: getMethodColor(request.method) }"
          >
            {{ request.method }}
          </span>
        </div>
        <div class="col-url" :title="request.url">
          {{ request.url }}
        </div>
        <div class="col-status">
          <span
            class="status-badge"
            :style="{ color: getStatusColor(request.status) }"
          >
            {{ request.status || '-' }}
          </span>
        </div>
        <div class="col-duration" :class="{ slow: request.duration > 1000 }">
          {{ formatTime(request.duration) }}
        </div>
        <div class="col-time">
          {{ formatDate(request.startTime) }}
        </div>
      </div>

      <div v-if="filteredRequests.length === 0" class="empty-list">
        <div class="empty-text">暂无请求记录</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.api-monitor {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.monitor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  flex-wrap: wrap;
  gap: 12px;
}

.monitor-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.header-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.simulate-btn,
.auto-refresh-btn,
.clear-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  transition: all 0.3s;
}

.simulate-btn.success {
  background: #67c23a;
  color: white;
}

.simulate-btn.slow {
  background: #e6a23c;
  color: white;
}

.simulate-btn.failed {
  background: #f56c6c;
  color: white;
}

.auto-refresh-btn {
  background: #ecf5ff;
  color: #409eff;
  border: 1px solid #d9ecff;
}

.auto-refresh-btn.active {
  background: #409eff;
  color: white;
}

.clear-btn {
  background: #f4f4f5;
  color: #606266;
}

.statistics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.stat-card {
  padding: 20px;
  background: white;
  border-radius: 8px;
  text-align: center;
}

.stat-card.success {
  border-left: 4px solid #67c23a;
}

.stat-card.failed {
  border-left: 4px solid #f56c6c;
}

.stat-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-value {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
}

.filter-section {
  padding: 16px 20px;
  background: white;
  border-radius: 8px;
  margin-bottom: 16px;
}

.filter-tabs {
  display: flex;
  gap: 12px;
}

.filter-tabs button {
  padding: 8px 20px;
  background: #f5f7fa;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: #606266;
  transition: all 0.3s;
}

.filter-tabs button.active {
  background: #409eff;
  color: white;
}

.request-list {
  background: white;
  border-radius: 8px;
  overflow: hidden;
}

.list-header,
.request-item {
  display: grid;
  grid-template-columns: 100px 2fr 100px 120px 120px;
  gap: 16px;
  padding: 16px 20px;
  align-items: center;
}

.list-header {
  background: #f5f7fa;
  font-weight: 600;
  font-size: 14px;
  color: #606266;
}

.request-item {
  border-bottom: 1px solid #f0f0f0;
  font-size: 14px;
  color: #303133;
}

.request-item.failed {
  background: #fef0f0;
}

.request-item.slow {
  background: #fdf6ec;
}

.col-url {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.method-badge {
  display: inline-block;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
}

.status-badge {
  font-weight: 700;
}

.col-duration.slow {
  color: #e6a23c;
  font-weight: 700;
}

.empty-list {
  padding: 60px 20px;
  text-align: center;
  color: #909399;
  font-size: 14px;
}
</style>

五、长任务监控

5.1 长任务监控器

long-task-monitor.js

javascript
export class LongTaskMonitor {
  constructor(threshold = 50) {
    this.threshold = threshold
    this.longTasks = []
    this.observer = null
    this.init()
  }

  init() {
    if (!('PerformanceObserver' in window)) {
      console.warn('PerformanceObserver not supported')
      return
    }

    try {
      this.observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (entry.duration > this.threshold) {
            this.recordLongTask(entry)
          }
        }
      })

      this.observer.observe({ entryTypes: ['longtask'] })
    } catch (e) {
      console.warn('Long Task API not supported')

      // 降级方案:使用 requestIdleCallback 检测
      this.fallbackMonitor()
    }
  }

  // 记录长任务
  recordLongTask(entry) {
    const task = {
      id: this.generateId(),
      duration: entry.duration,
      startTime: entry.startTime,
      name: entry.name,
      attribution: entry.attribution || [],
      timestamp: Date.now()
    }

    this.longTasks.push(task)

    console.warn('Long Task detected:', task)

    // 只保留最近100个
    if (this.longTasks.length > 100) {
      this.longTasks.shift()
    }
  }

  // 降级监控方案
  fallbackMonitor() {
    let lastTime = performance.now()

    const check = () => {
      const currentTime = performance.now()
      const duration = currentTime - lastTime

      if (duration > this.threshold) {
        this.recordLongTask({
          duration: duration,
          startTime: lastTime,
          name: 'unknown',
          attribution: []
        })
      }

      lastTime = currentTime
      requestIdleCallback(check, { timeout: 1000 })
    }

    requestIdleCallback(check)
  }

  // 获取长任务
  getLongTasks() {
    return this.longTasks
  }

  // 获取统计
  getStatistics() {
    if (this.longTasks.length === 0) {
      return {
        count: 0,
        totalDuration: 0,
        avgDuration: 0,
        maxDuration: 0
      }
    }

    const totalDuration = this.longTasks.reduce((sum, task) => sum + task.duration, 0)
    const maxDuration = Math.max(...this.longTasks.map(t => t.duration))

    return {
      count: this.longTasks.length,
      totalDuration,
      avgDuration: totalDuration / this.longTasks.length,
      maxDuration
    }
  }

  // 清空记录
  clear() {
    this.longTasks = []
  }

  // 销毁监控
  destroy() {
    if (this.observer) {
      this.observer.disconnect()
    }
  }

  // 生成ID
  generateId() {
    return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
  }
}

5.2 长任务监控组件

LongTaskMonitor.vue

vue
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { LongTaskMonitor } from './long-task-monitor.js'

const longTaskMonitor = ref(null)
const longTasks = ref([])
const autoRefresh = ref(true)
let refreshTimer = null

// 统计信息
const statistics = computed(() => {
  if (!longTaskMonitor.value) return null
  return longTaskMonitor.value.getStatistics()
})

// 初始化监控
const initMonitor = () => {
  longTaskMonitor.value = new LongTaskMonitor(50)
  loadLongTasks()

  if (autoRefresh.value) {
    startAutoRefresh()
  }
}

// 加载长任务数据
const loadLongTasks = () => {
  if (longTaskMonitor.value) {
    longTasks.value = longTaskMonitor.value.getLongTasks()
  }
}

// 自动刷新
const startAutoRefresh = () => {
  refreshTimer = setInterval(() => {
    loadLongTasks()
  }, 2000)
}

const stopAutoRefresh = () => {
  if (refreshTimer) {
    clearInterval(refreshTimer)
    refreshTimer = null
  }
}

// 模拟长任务
const simulateLongTask = (duration = 200) => {
  const startTime = Date.now()

  // 占用主线程
  while (Date.now() - startTime < duration) {
    // 空循环
  }

  setTimeout(loadLongTasks, 100)
}

// 格式化时间
const formatTime = (ms) => {
  return `${ms.toFixed(2)} ms`
}

// 格式化日期
const formatDate = (timestamp) => {
  return new Date(timestamp).toLocaleTimeString()
}

// 获取严重程度
const getSeverity = (duration) => {
  if (duration < 100) return { text: '轻微', color: '#e6a23c' }
  if (duration < 200) return { text: '中等', color: '#f56c6c' }
  return { text: '严重', color: '#f56c6c' }
}

onMounted(() => {
  initMonitor()
})

onUnmounted(() => {
  stopAutoRefresh()
  longTaskMonitor.value?.destroy()
})
</script>

<template>
  <div class="long-task-monitor">
    <div class="monitor-header">
      <h2>长任务监控</h2>
      <div class="header-actions">
        <button @click="simulateLongTask(100)" class="simulate-btn light">
          模拟轻微长任务
        </button>
        <button @click="simulateLongTask(200)" class="simulate-btn moderate">
          模拟中等长任务
        </button>
        <button @click="simulateLongTask(500)" class="simulate-btn severe">
          模拟严重长任务
        </button>
      </div>
    </div>

    <!-- 统计卡片 -->
    <div v-if="statistics" class="statistics-grid">
      <div class="stat-card">
        <div class="stat-label">长任务总数</div>
        <div class="stat-value">{{ statistics.count }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">总耗时</div>
        <div class="stat-value">{{ formatTime(statistics.totalDuration) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">平均耗时</div>
        <div class="stat-value">{{ formatTime(statistics.avgDuration) }}</div>
      </div>
      <div class="stat-card">
        <div class="stat-label">最长耗时</div>
        <div class="stat-value">{{ formatTime(statistics.maxDuration) }}</div>
      </div>
    </div>

    <!-- 长任务列表 -->
    <div class="task-list">
      <h3>长任务列表</h3>

      <div
        v-for="task in longTasks"
        :key="task.id"
        class="task-item"
      >
        <div class="task-severity">
          <div
            class="severity-badge"
            :style="{ background: getSeverity(task.duration).color }"
          >
            {{ getSeverity(task.duration).text }}
          </div>
        </div>

        <div class="task-info">
          <div class="task-duration">
            耗时: <span class="highlight">{{ formatTime(task.duration) }}</span>
          </div>
          <div class="task-details">
            开始时间: {{ formatTime(task.startTime) }} |
            记录时间: {{ formatDate(task.timestamp) }}
          </div>
        </div>
      </div>

      <div v-if="longTasks.length === 0" class="empty-list">
        <div class="empty-text">暂未检测到长任务</div>
        <div class="empty-hint">长任务是指执行时间超过50ms的JavaScript任务</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.long-task-monitor {
  padding: 20px;
  background: #f5f7fa;
  min-height: 100vh;
}

.monitor-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  padding: 20px;
  background: white;
  border-radius: 8px;
  flex-wrap: wrap;
  gap: 12px;
}

.monitor-header h2 {
  margin: 0;
  font-size: 24px;
  color: #303133;
}

.header-actions {
  display: flex;
  gap: 12px;
}

.simulate-btn {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  color: white;
}

.simulate-btn.light {
  background: #e6a23c;
}

.simulate-btn.moderate {
  background: #f56c6c;
}

.simulate-btn.severe {
  background: #f56c6c;
}

.statistics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.stat-card {
  padding: 20px;
  background: white;
  border-radius: 8px;
  text-align: center;
}

.stat-label {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.stat-value {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
}

.task-list {
  padding: 20px;
  background: white;
  border-radius: 8px;
}

.task-list h3 {
  margin: 0 0 20px 0;
  font-size: 18px;
  color: #303133;
}

.task-item {
  display: flex;
  gap: 16px;
  padding: 16px;
  margin-bottom: 12px;
  background: #fef0f0;
  border-left: 4px solid #f56c6c;
  border-radius: 4px;
}

.severity-badge {
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 700;
  color: white;
}

.task-info {
  flex: 1;
}

.task-duration {
  font-size: 14px;
  color: #303133;
  margin-bottom: 6px;
}

.task-duration .highlight {
  color: #f56c6c;
  font-weight: 700;
  font-size: 16px;
}

.task-details {
  font-size: 12px;
  color: #909399;
}

.empty-list {
  padding: 60px 20px;
  text-align: center;
}

.empty-text {
  font-size: 14px;
  color: #909399;
  margin-bottom: 8px;
}

.empty-hint {
  font-size: 12px;
  color: #c0c4cc;
}
</style>

六、简历描述模板

前端性能监控系统开发 (2023.05 - 2023.10)

负责公司前端性能监控体系建设,实现Web Vitals指标监控、资源加载分析和接口性能追踪,覆盖10+个核心业务系统。

核心职责

  • 基于Performance API搭建完整的性能监控体系,覆盖FCP/LCP/FID/CLS等核心指标
  • 开发资源加载监控模块,实时追踪JS/CSS/图片等资源加载性能
  • 实现接口性能监控,拦截Fetch和XMLHttpRequest,统计接口响应时间
  • 开发长任务检测功能,识别并记录执行时间超过50ms的JavaScript任务
  • 设计性能数据上报策略,使用Beacon API确保数据可靠上报
技术实现
  • 使用PerformanceObserver监听性能条目,实现零侵入式数据采集
  • 通过Navigation Timing API计算页面加载各阶段耗时
  • 封装性能监控SDK,支持自定义指标和阈值配置
  • 实现性能数据本地缓存和批量上报,降低对页面性能的影响
项目成果
  • 页面加载性能提升35%,FCP从2.8s优化到1.8s
  • 识别并优化了20+个慢接口,平均响应时间降低40%
  • 检测到15个长任务问题,优化后用户交互流畅度提升50%
  • 建立性能评分体系,将性能指标纳入团队KPI考核

七、SOP标准回答

面试问题: 介绍一下你做的性能监控系统

标准回答

"我负责开发了一套完整的前端性能监控系统,主要分为四个模块。

第一个是Web Vitals指标监控。我使用PerformanceObserver API监听paint、largest-contentful-paint、first-input、layout-shift等类型的性能条目,自动计算FCP、LCP、FID、CLS这些谷歌提出的核心指标。比如FCP,我监听first-contentful-paint事件,获取startTime就是FCP值。对于LCP,需要持续监听,因为页面最大内容元素可能会变化,我取最后一个条目的startTime。

第二个是资源加载监控。通过performance.getEntriesByType('resource')可以获取所有已加载资源的详细信息,包括DNS查询、TCP连接、TTFB、下载时间等。我会分析资源类型、大小、耗时,识别出慢资源和大文件。比如某个JS文件加载超过2秒,就会标记为慢资源,推动开发优化。

第三个是接口性能监控。我通过拦截Fetch和XMLHttpRequest实现。保存原始方法,然后在发送前记录开始时间,在响应后计算耗时,同时记录URL、方法、状态码等。这样可以统计每个接口的调用次数、成功率、平均响应时间,快速定位慢接口。

第四个是长任务监控。使用PerformanceObserver监听longtask类型,当JavaScript执行超过50ms就会触发。长任务会阻塞主线程,导致页面卡顿,所以需要重点关注。不过longtask API兼容性不太好,我做了降级方案,用requestIdleCallback定期检测时间间隔。

数据采集后会先缓存在内存,达到一定条数或时间间隔就批量上报,用的Beacon API,即使页面关闭也能保证数据发送成功。

最终效果是,我们的FCP从2.8秒优化到1.8秒,用户可感知的加载速度提升了35%,整体性能评分从60分提升到85分。"


八、难点与亮点分析

难点1: 如何准确计算FID?

问题场景: FID(First Input Delay)是用户首次交互到浏览器响应的延迟,但不是所有交互都应该计算。

解决方案

javascript
observeFID() {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.processingStart - entry.startTime 就是延迟
      const fid = entry.processingStart - entry.startTime

      // 只记录第一次交互
      this.metrics.fid = fid

      // 记录交互类型
      this.metrics.fidType = entry.name // click, keydown等

      console.log('FID:', fid, 'Type:', entry.name)

      // 断开观察器,只测量第一次
      observer.disconnect()
    }
  })

  observer.observe({ entryTypes: ['first-input'], buffered: true })
}
关键点
  • 使用first-input类型,自动捕获首次输入
  • buffered: true 确保不会错过早期事件
  • 立即disconnect避免重复记录

难点2: 如何处理CLS的持续变化?

问题场景: CLS会持续累积,需要在合适的时机获取最终值。

解决方案

javascript
observeCLS() {
  let clsValue = 0
  let sessionValue = 0
  let sessionEntries = []

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 只统计非用户输入导致的布局偏移
      if (!entry.hadRecentInput) {
        const firstSessionEntry = sessionEntries[0]
        const lastSessionEntry = sessionEntries[sessionEntries.length - 1]

        // 如果当前偏移距离上次超过1秒或距离第一次超过5秒,开始新会话
        if (
          sessionValue &&
          entry.startTime - lastSessionEntry.startTime < 1000 &&
          entry.startTime - firstSessionEntry.startTime < 5000
        ) {
          sessionValue += entry.value
          sessionEntries.push(entry)
        } else {
          sessionValue = entry.value
          sessionEntries = [entry]
        }

        // 保留最大会话值
        if (sessionValue > clsValue) {
          clsValue = sessionValue
          this.metrics.cls = clsValue
        }
      }
    }
  })

  observer.observe({ entryTypes: ['layout-shift'] })
}

亮点1: 性能指标评分系统

创新点

  • 参考Google的评分标准
  • 为每个指标设定good/needs-improvement/poor三个档次
  • 自动计算综合性能分数
实现
javascript
calculatePerformanceScore() {
  const scores = {
    fcp: this.getMetricScore('fcp', this.metrics.fcp),
    lcp: this.getMetricScore('lcp', this.metrics.lcp),
    fid: this.getMetricScore('fid', this.metrics.fid),
    cls: this.getMetricScore('cls', this.metrics.cls)
  }

  // 计算总分 (0-100)
  const weights = { fcp: 10, lcp: 25, fid: 25, cls: 25, ttfb: 15 }
  let totalScore = 0

  Object.keys(scores).forEach(metric => {
    const score = scores[metric]
    const weight = weights[metric]

    if (score === 'good') {
      totalScore += weight
    } else if (score === 'needs-improvement') {
      totalScore += weight * 0.5
    }
  })

  return {
    score: Math.round(totalScore),
    grade: this.getGrade(totalScore),
    details: scores
  }
}

getGrade(score) {
  if (score >= 90) return 'A'
  if (score >= 75) return 'B'
  if (score >= 50) return 'C'
  if (score >= 25) return 'D'
  return 'F'
}

亮点2: 智能性能预警

创新点

  • 实时检测性能异常
  • 基于历史数据的动态阈值
  • 分级预警机制
实现
javascript
checkPerformanceAlert() {
  const alerts = []

  // LCP超过4秒 - 严重
  if (this.metrics.lcp > 4000) {
    alerts.push({
      level: 'error',
      metric: 'LCP',
      message: `LCP过高: ${this.metrics.lcp.toFixed(0)}ms,严重影响用户体验`
    })
  }

  // FID超过300ms - 警告
  if (this.metrics.fid > 300) {
    alerts.push({
      level: 'warning',
      metric: 'FID',
      message: `FID过高: ${this.metrics.fid.toFixed(0)}ms,交互响应慢`
    })
  }

  // CLS超过0.25 - 警告
  if (this.metrics.cls > 0.25) {
    alerts.push({
      level: 'warning',
      metric: 'CLS',
      message: `CLS过高: ${this.metrics.cls.toFixed(3)},页面布局不稳定`
    })
  }

  // 长任务过多 - 警告
  if (this.metrics.longTasks && this.metrics.longTasks.length > 5) {
    alerts.push({
      level: 'warning',
      metric: 'LongTask',
      message: `检测到${this.metrics.longTasks.length}个长任务,可能影响交互性能`
    })
  }

  return alerts
}