前言
想象我们是城市交通指挥中心的大屏维护人员,屏幕上显示着全市交通状况:数万辆车的实时位置、几百个监控摄像头的画面、不断刷新的拥堵指数...突然,大屏开始卡顿,鼠标拖拽地图要等 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 // 事故数量
}
}
性能问题表现
| 指标 | 正常预期 | 实际情况 | 用户感受 |
|---|---|---|---|
| 帧率 | 60fps | 15-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
- 监控内存泄漏:定期检查内存增长
结语
当我们看到大屏流畅运行一整天,内存稳定,用户满意地站在屏幕前,我们就会知道这些优化值了!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!