案例分析:大屏可视化项目的卡顿排查与解决

14 阅读6分钟

前言

想象我们是城市交通指挥中心的大屏维护人员,屏幕上显示着全市交通状况:数万辆车的实时位置、几百个监控摄像头的画面、不断刷新的拥堵指数...突然,大屏开始卡顿,鼠标拖拽地图要等 2 秒才有反应。2 小时后,浏览器内存飙升到 2GB,页面直接崩溃。领导站在大屏前,脸色铁青地质问我们:"怎么回事?"

这就是我们要解决的问题。

场景描述 - 一个真实的性能噩梦

业务背景

某城市智慧交通指挥中心的大屏,需要监控全市交通流量、事故预警、拥堵指数等:

// 这个数据量有多大?
const dashboardData = {
  // 实时路况地图 - 50,000+ 个车辆轨迹点
  trafficMap: {
    vehicles: [],     // 5万个实时车辆位置
    cameras: [],      // 2000个监控摄像头
    sensors: [],      // 3000个地磁感应器
    incidents: []     // 实时事故/拥堵点
  },
  
  // 流量趋势图 - 7天数据,1440个时间点
  flowTrend: {
    hourly: new Array(24),      // 每小时流量
    daily: new Array(7),        // 每日流量
    weekly: new Array(168)      // 每周趋势(7×24)
  },
  
  // 实时指标 - 每秒更新
  metrics: {
    totalFlow: 0,        // 总车流量
    avgSpeed: 0,         // 平均速度
    congestionIndex: 0,  // 拥堵指数
    accidentCount: 0     // 事故数量
  }
}

性能问题表现

指标正常预期实际情况用户感受
帧率60fps15-25fps明显卡顿
地图交互即时响应延迟1-2秒拖不动
内存占用500MB持续增长至2GB+2小时后崩溃
CPU使用率30%85-100%风扇狂转

初始代码(问题代码)

<!-- ❌ 问题代码 -->
<template>
  <div class="dashboard">
    <!-- 地图容器 -->
    <div id="map-container"></div>
    
    <!-- 多个图表 -->
    <div v-for="chart in charts" :key="chart.id">
      <div :id="`chart-${chart.id}`"></div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'
import * as L from 'leaflet'

let ws = null
let map = null
const trafficData = ref(null)

onMounted(() => {
  // 初始化地图
  map = L.map('map-container').setView([39.9, 116.4], 10)
  
  // 连接 WebSocket
  ws = new WebSocket('ws://traffic-api.example.com')
  ws.onmessage = (event) => {
    trafficData.value = JSON.parse(event.data)  // 每秒更新
  }
})

// 监听数据更新
watch(trafficData, (newData) => {
  // ❌ 每次更新都清空并重新添加所有标记点
  map.eachLayer(layer => {
    if (layer instanceof L.Marker) {
      map.removeLayer(layer)
    }
  })
  
  // 5万个点全部重新添加
  newData.vehicles.forEach(vehicle => {
    L.marker([vehicle.lat, vehicle.lng]).addTo(map)
  })
  
  // 所有图表全部重新渲染
  Object.values(charts).forEach(chart => {
    chart.setOption({
      series: [{ data: newData.charts[key] }]
    })
  })
})
</script>

性能问题诊断

使用 Chrome 分析工具

// 添加帧率监控
class FPSMonitor {
  constructor() {
    this.frames = 0
    this.lastTime = performance.now()
    this.fpsElement = document.createElement('div')
    this.fpsElement.style.cssText = `
      position: fixed; top: 10px; right: 10px;
      background: rgba(0,0,0,0.7); color: white;
      padding: 5px 10px; border-radius: 4px;
      font-family: monospace; z-index: 9999;
    `
    document.body.appendChild(this.fpsElement)
    this.start()
  }
  
  start() {
    const measure = () => {
      const now = performance.now()
      const delta = now - this.lastTime
      
      if (delta >= 1000) {
        const fps = Math.round((this.frames * 1000) / delta)
        this.fpsElement.textContent = `FPS: ${fps}`
        this.frames = 0
        this.lastTime = now
      }
      
      this.frames++
      requestAnimationFrame(measure)
    }
    
    requestAnimationFrame(measure)
  }
}

const monitor = new FPSMonitor()
// 观察 FPS 只有 15-25

火焰图分析

Performance Timeline 分析结果:

├─ 长任务检测 (Long Task > 50ms)
│  ├─ 第1秒: 185ms (图表渲染)
│  ├─ 第2秒: 220ms (地图更新)
│  └─ 第3秒: 168ms (数据处理)
│
├─ 重排重绘
│  └─ 每次地图更新触发全量重排
│
└─ JavaScript 执行时间
   ├─ ECharts setOption: 85ms
   ├─ 地图标记点更新: 120ms
   └─ 数据处理: 45ms

内存泄漏分析

Memory Snapshot 对比(运行5分钟后):

├─ Detached DOM 节点
│  ├─ 初始: 0 个
│  └─ 5分钟后: 2,345 个 ← 地图标记点未清理
│
├─ 总体内存增长
   ├─ 初始: 280MB
   └─ 5分钟后: 520MB  ← 增长240MB,异常!

图表渲染优化

复用 ECharts 实例

// ❌ 优化前:每次更新都重新创建图表
watch(data, () => {
  Object.keys(charts).forEach(key => {
    if (chartMap.has(key)) {
      chartMap.get(key).dispose()  // 销毁
    }
    // 创建新实例(慢!)
    const chart = echarts.init(document.getElementById(`chart-${key}`))
    chart.setOption(option)
    chartMap.set(key, chart)
  })
})

// ✅ 优化后:复用实例,只更新数据
const chartMap = new Map()

// 初始化时创建一次
onMounted(() => {
  Object.keys(charts).forEach(key => {
    const chart = echarts.init(document.getElementById(`chart-${key}`))
    chart.setOption(option)
    chartMap.set(key, chart)
  })
})

// 数据更新时只更新数据
watch(data, (newData) => {
  Object.keys(charts).forEach(key => {
    const chart = chartMap.get(key)
    // 增量更新,只更新变化的数据
    chart.setOption({
      series: [{ data: newData.charts[key] }]
    }, { notMerge: false })  // 不合并配置,只更新数据
  })
})

关闭不必要的动画

// ✅ 优化:大数据量时关闭动画
const chartOption = {
  // 关闭动画
  animation: false,
  
  // 或者设置阈值,数据量大时自动关闭
  animationThreshold: 2000,  // 超过2000个点禁用动画
  
  series: [{
    type: 'line',
    smooth: false,           // 关闭平滑曲线
    showSymbol: false,       // 不显示数据点
    lineStyle: { width: 1 }  // 细线,渲染更快
  }],
  
  // 渐进式渲染
  progressive: 500,          // 每帧渲染500个点
  progressiveThreshold: 3000 // 超过3000个点启用渐进式渲染
}

大数据量优化

数据降采样

// 实现 LTTB 算法(保留视觉特征的降采样)
// 把5万个点降采样到2000个,保留趋势

class Downsampler {
  static lttb(data, threshold) {
    if (threshold >= data.length) return data
    
    const bucketSize = (data.length - 2) / (threshold - 2)
    const sampled = [data[0]]  // 保留第一个点
    
    let a = 0
    let nextA = 0
    
    for (let i = 0; i < threshold - 2; i++) {
      const avgStart = Math.floor((i + 1) * bucketSize) + 1
      const avgEnd = Math.floor((i + 2) * bucketSize) + 1
      
      // 计算桶的平均值
      let avgX = 0, avgY = 0, count = 0
      for (let j = avgStart; j < avgEnd && j < data.length; j++) {
        avgX += data[j][0]
        avgY += data[j][1]
        count++
      }
      avgX /= count
      avgY /= count
      
      // 找面积最大的点
      let maxArea = -1
      let maxAreaPoint = data[avgStart]
      
      for (let j = avgStart; j < avgEnd && j < data.length; j++) {
        const area = Math.abs(
          (data[a][0] - avgX) * (data[j][1] - data[a][1]) -
          (data[a][0] - data[j][0]) * (avgY - data[a][1])
        )
        if (area > maxArea) {
          maxArea = area
          maxAreaPoint = data[j]
          nextA = j
        }
      }
      
      sampled.push(maxAreaPoint)
      a = nextA
    }
    
    sampled.push(data[data.length - 1])  // 保留最后一个点
    return sampled
  }
}

// 使用降采样
function updateChart(rawData) {
  // 根据图表宽度决定保留点数
  const chartWidth = 1200
  const targetPoints = Math.min(rawData.length, chartWidth / 2)
  const sampledData = Downsampler.lttb(rawData, targetPoints)
  
  chart.setOption({ series: [{ data: sampledData }] })
}

地图按需加载

// ✅ 只渲染可视区域内的点
class MapViewportOptimizer {
  constructor(map, allPoints) {
    this.map = map
    this.allPoints = allPoints
    
    // 监听地图移动/缩放
    map.on('moveend', () => this.updateVisiblePoints())
    map.on('zoomend', () => this.updateVisiblePoints())
  }
  
  updateVisiblePoints() {
    const bounds = this.map.getBounds()
    
    // 只获取可视区域内的点
    const visiblePoints = this.allPoints.filter(point => 
      bounds.contains([point.lat, point.lng])
    )
    
    console.log(`渲染点数: ${visiblePoints.length} / ${this.allPoints.length}`)
    // 从 50,000 减少到 500-2,000
    this.renderPoints(visiblePoints)
  }
}

使用 Web Worker

// worker/data-processor.js
// 在另一个线程处理数据,不阻塞主线程

self.addEventListener('message', (e) => {
  const { data } = e.data
  
  // 复杂的聚合计算
  const result = processTrafficData(data)
  
  // 返回结果
  self.postMessage({ data: result })
})

function processTrafficData(rawData) {
  // 按区域聚合
  const byRegion = new Map()
  
  rawData.forEach(point => {
    const region = getRegion(point.lat, point.lng)
    if (!byRegion.has(region)) {
      byRegion.set(region, { count: 0, avgSpeed: 0 })
    }
    const stats = byRegion.get(region)
    stats.count++
    stats.avgSpeed = (stats.avgSpeed + point.speed) / 2
  })
  
  return Array.from(byRegion.entries())
}

// 主线程
const worker = new Worker('./worker/data-processor.js')

worker.addEventListener('message', (e) => {
  // 收到处理后的数据,更新图表
  updateChart(e.data)
})

function handleNewData(rawData) {
  // 发送到 Worker 处理
  worker.postMessage({ data: rawData })
}

内存泄漏排查

清理事件监听器

// ❌ 优化前:未清理
onMounted(() => {
  window.addEventListener('resize', handleResize)
  map.on('moveend', handleMapMove)
})

// ✅ 优化后:记录并清理
const cleanups = []

onMounted(() => {
  const resizeHandler = () => handleResize()
  window.addEventListener('resize', resizeHandler)
  cleanups.push(() => window.removeEventListener('resize', resizeHandler))
  
  const mapMoveHandler = () => handleMapMove()
  map.on('moveend', mapMoveHandler)
  cleanups.push(() => map.off('moveend', mapMoveHandler))
})

onUnmounted(() => {
  cleanups.forEach(cleanup => cleanup())
})

正确销毁 ECharts

// ❌ 优化前:未销毁
const charts = new Map()

function destroyChart(id) {
  charts.delete(id)  // 只删除了引用,图表实例还在内存中
}

// ✅ 优化后:调用 dispose
class ChartManager {
  constructor() {
    this.charts = new Map()
  }
  
  create(id, option) {
    if (this.charts.has(id)) {
      this.destroy(id)
    }
    const chart = echarts.init(document.getElementById(id))
    chart.setOption(option)
    this.charts.set(id, chart)
  }
  
  destroy(id) {
    const chart = this.charts.get(id)
    if (chart) {
      chart.dispose()  // 关键!释放资源
      this.charts.delete(id)
    }
  }
  
  destroyAll() {
    this.charts.forEach(chart => chart.dispose())
    this.charts.clear()
  }
}

清理 WebSocket

// ✅ 完整清理 WebSocket
class WebSocketManager {
  constructor() {
    this.ws = null
    this.reconnectTimer = null
  }
  
  connect(url) {
    this.disconnect()  // 先断开旧连接
    this.ws = new WebSocket(url)
    this.ws.onmessage = (event) => this.handleMessage(event)
  }
  
  disconnect() {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer)
      this.reconnectTimer = null
    }
    
    if (this.ws) {
      this.ws.onmessage = null
      this.ws.onclose = null
      this.ws.onerror = null
      this.ws.close()
      this.ws = null
    }
  }
}

动画性能优化

使用 requestAnimationFrame

// ❌ 优化前:使用 setInterval
setInterval(() => {
  updateRealTimeData()
}, 16)  // 即使页面不可见也在执行

// ✅ 优化后:使用 requestAnimationFrame
let animationId
let lastTimestamp = 0

function animate(timestamp) {
  if (timestamp - lastTimestamp >= 16) {  // 约60fps
    updateRealTimeData()
    lastTimestamp = timestamp
  }
  animationId = requestAnimationFrame(animate)
}

// 页面可见性优化
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    cancelAnimationFrame(animationId)  // 页面不可见时停止
  } else {
    animationId = requestAnimationFrame(animate)  // 恢复
  }
})

CSS 动画代替 JS

/* ✅ 使用 CSS 动画,由 GPU 加速 */
.vehicle-marker {
  transition: transform 0.3s ease;
  will-change: transform;  /* 提示浏览器优化 */
}

/* 使用 transform 代替 left/top */
.vehicle-marker.moving {
  transform: translate3d(10px, 20px, 0);  /* 3D transform 启用GPU加速 */
}

/* 启用硬件加速 */
.map-container {
  transform: translate3d(0, 0, 0);
  backface-visibility: hidden;
}

优化检查清单

图表优化

  • 复用 ECharts 实例,避免频繁创建/销毁
  • 使用增量更新(notMerge: false)
  • 大数据量时关闭动画
  • 使用渐进式渲染

数据优化

  • 使用 LTTB 算法降采样
  • 地图按需加载(只渲染可视区域)
  • Web Worker 处理复杂计算
  • 分批渲染大量数据点

内存管理

  • 正确销毁 ECharts 实例(dispose())
  • 清理事件监听器
  • 清理定时器和动画
  • 正确关闭 WebSocket

动画优化

  • 使用 requestAnimationFrame
  • CSS 动画代替 JS 动画
  • 使用 transform3d 启用 GPU 加速
  • 页面不可见时暂停动画

大屏优化的核心原则

  • 减少不必要的渲染:只更新变化的部分
  • 降低数据量级:降采样、按需加载
  • 分离计算与渲染:Worker 处理数据,主线程负责渲染
  • 善用 GPU 加速:CSS transform、WebGL
  • 监控内存泄漏:定期检查内存增长

结语

当我们看到大屏流畅运行一整天,内存稳定,用户满意地站在屏幕前,我们就会知道这些优化值了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!