
某次大促期间,运维 Dashboard 突然卡死,页面白屏长达 30 秒。排查发现是 ECharts 同时渲染 8 个大图表导致内存溢出。那次事故让我意识到,运维前端不是简单堆组件,需要从架构层面做性能优化和实时数据流管控。
我负责的 AIOps Dashboard 需要同时展示 10+ 个服务的实时指标、告警列表和任务状态,架构设计是确保性能的基础。


组件 | 类型 | 职责 | 数据来源 |
|---|---|---|---|
MetricCard | 展示组件 | 单指标卡片(CPU/内存/QPS) | metricStore |
AlertTimeline | 展示组件 | 告警时间线 | alertStore |
RealtimeChart | 展示组件 | ECharts 实时折线图 | useMetric |
TaskBoard | 展示组件 | 任务看板(待执行/执行中/已完成) | taskStore |
ServiceMap | 展示组件 | 服务拓扑图 | metricStore |
DashboardLayout | 布局组件 | 响应式栅格布局 | - |

图:Vue3 AIOps Dashboard 完整效果图,展示服务拓扑、实时指标卡片、ECharts 趋势图、告警列表和任务看板
为什么需要画这张时序图?WebSocket 数据从前端连接到最终渲染,涉及多层流转:浏览器→Vue App→WebSocket Client→后端→Pinia Store→ECharts。理清这条链路,才能准确定位性能瓶颈和断线重连的切入点。

为什么把 WebSocket 逻辑抽成 Composable?多个页面组件都需要 WebSocket 连接,抽成 Composable 可以复用连接逻辑、统一管理断线重连,避免每个组件重复实现。
/**
* WebSocket 组合函数
* 封装连接、心跳、断线重连逻辑
*/
import {ref, onUnmounted} from 'vue'
import {useWsStore} from '@/stores/wsStore'
interface WsOptions {
url: string
heartbeatInterval?: number // 心跳间隔,默认 30s
reconnectDelay?: number // 重连延迟,默认 5s
maxRetries?: number // 重试次数,默认 10
}
export function useWebSocket(options: WsOptions) {
const {
url,
heartbeatInterval = 30000,
reconnectDelay = 5000,
maxRetries = 10,
} = options
const wsStore = useWsStore()
const connected = ref(false)
const retryCount = ref(0)
let ws: WebSocket | null = null
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
function connect() {
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
retryCount.value = 0
wsStore.setStatus('connected')
startHeartbeat()
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'alert') {
wsStore.dispatchAlert(data.payload)
} else if (data.type === 'metric') {
wsStore.dispatchMetric(data.payload)
} else if (data.type === 'heartbeat') {
return
}
}
ws.onclose = () => {
connected.value = false
wsStore.setStatus('disconnected')
stopHeartbeat()
attemptReconnect()
}
ws.onerror = () => {
ws?.close()
}
}
function startHeartbeat() {
heartbeatTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}))
}
}, heartbeatInterval)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
function attemptReconnect() {
if (retryCount.value >= maxRetries) {
wsStore.setStatus('failed')
return
}
retryCount.value++
setTimeout(connect, reconnectDelay)
}
function disconnect() {
stopHeartbeat()
ws?.close()
ws = null
}
onUnmounted(() => {
disconnect()
})
return {connected, retryCount, connect, disconnect}
}
为什么用 Composable 管理 ECharts 而不是直接在组件里写?指标计算逻辑(滑动窗口、聚合、格式化)与图表渲染解耦后,可以在不同图表类型间复用计算逻辑。
import {ref, computed, watch} from 'vue'
import type {EChartsOption} from 'echarts'
import {useMetricStore} from '@/stores/metricStore'
interface MetricConfig {
serviceName: string
metricName: string
windowSize?: number
}
export function useMetric(config: MetricConfig) {
const {serviceName, metricName, windowSize = 60} = config
const metricStore = useMetricStore()
const dataPoints = ref<{ time: number; value: number }[]>([])
watch(
() => metricStore.getLatest(serviceName, metricName),
(newPoint) => {
if (!newPoint) return
dataPoints.value.push(newPoint)
if (dataPoints.value.length > windowSize) {
dataPoints.value.shift()
}
}
)
const chartOption = computed<EChartsOption>(() => ({
tooltip: {trigger: 'axis'},
grid: {left: 50, right: 20, top: 20, bottom: 30},
xAxis: {
type: 'category',
data: dataPoints.value.map((p) =>
new Date(p.time).toLocaleTimeString()
),
},
yAxis: {type: 'value', scale: true},
series: [{
type: 'line',
data: dataPoints.value.map((p) => p.value),
smooth: true,
showSymbol: false,
lineStyle: {width: 2},
areaStyle: {opacity: 0.1},
}],
animation: false,
}))
return {dataPoints, chartOption}
}
为什么关闭 ECharts 动画?实时数据每秒更新一次,开启动画会导致图表闪烁和 CPU 飙升,关闭后渲染更流畅。
<template>
<div class="realtime-chart">
<div class="chart-header">
<span class="chart-title">{{ title }}</span>
<el-tag :type="statusType" size="small">
{{ currentValue }}
</el-tag>
</div>
<div ref="chartRef" class="chart-container"/>
</div>
</template>
<script setup lang="ts">
import {ref, onMounted, onUnmounted, watch} from 'vue'
import * as echarts from 'echarts'
import {useMetric} from '@/composables/useMetric'
const props = defineProps<{
title: string
serviceName: string
metricName: string
unit?: string
}>()
const chartRef = ref<HTMLDivElement>()
let chartInstance: echarts.ECharts | null = null
const {chartOption, dataPoints} = useMetric({
serviceName: props.serviceName,
metricName: props.metricName,
})
const currentValue = computed(() => {
const last = dataPoints.value[dataPoints.value.length - 1]
return last ? `${last.value}${props.unit || ''}` : '-'
})
const statusType = computed(() => {
const last = dataPoints.value[dataPoints.value.length - 1]
if (!last) return 'info'
if (last.value > 80) return 'danger'
if (last.value > 60) return 'warning'
return 'success'
})
onMounted(() => {
if (chartRef.value) {
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption(chartOption.value)
}
})
watch(chartOption, (newOption) => {
chartInstance?.setOption(newOption)
})
onUnmounted(() => {
chartInstance?.dispose()
})
</script>
<style scoped>
.chart-container {
width: 100%;
height: 200px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
</style>
为什么告警 Store 要维护已读/未读状态?AIOps Dashboard 需要在标题栏显示未读告警数量,同时告警列表需要标记已读,这需要集中管理状态。
import {defineStore} from 'pinia'
import {ref, computed} from 'vue'
import type {Alert} from '@/types/alert'
export const useAlertStore = defineStore('alert', () => {
const alerts = ref<Alert[]>([])
const readIds = ref<Set<string>>(new Set())
const severityFilter = ref<string>('all')
const unreadCount = computed(() => {
return alerts.value.filter(
(a) => !readIds.value.has(a.id)
).length
})
const filteredAlerts = computed(() => {
if (severityFilter.value === 'all') return alerts.value
return alerts.value.filter(
(a) => a.severity === severityFilter.value
)
})
function addAlert(alert: Alert) {
alerts.value.unshift(alert)
if (alerts.value.length > 200) {
alerts.value.pop()
}
}
function markAsRead(id: string) {
readIds.value.add(id)
}
function markAllAsRead() {
alerts.value.forEach((a) => readIds.value.add(a.id))
}
return {
alerts, readIds, severityFilter,
unreadCount, filteredAlerts,
addAlert, markAsRead, markAllAsRead,
}
})
为什么用 CSS Grid 而不是 Element Plus 的 el-row/el-col?AIOps Dashboard 有复杂的跨列跨行布局,CSS Grid 的 grid-template-areas 更直观,维护成本更低。
<template>
<div class="dashboard-grid">
<div class="area-cpu">
<MetricCard title="CPU 使用率" :value="cpuValue" unit="%"/>
</div>
<div class="area-memory">
<MetricCard title="内存使用率" :value="memValue" unit="%"/>
</div>
<div class="area-qps">
<MetricCard title="请求量 QPS" :value="qpsValue"/>
</div>
<div class="area-chart">
<RealtimeChart
title="服务指标趋势"
service-name="order-service"
metric-name="cpu_usage"
unit="%"
/>
</div>
<div class="area-alerts">
<AlertTimeline/>
</div>
<div class="area-tasks">
<TaskBoard/>
</div>
</div>
</template>
<style scoped>
.dashboard-grid {
display: grid;
grid-template-areas:
"cpu memory qps"
"chart chart alerts"
"tasks tasks alerts";
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto 1fr 1fr;
gap: 16px;
padding: 16px;
min-height: calc(100vh - 60px);
}
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-areas:
"cpu memory"
"qps qps"
"chart chart"
"alerts tasks";
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.dashboard-grid {
grid-template-areas:
"cpu"
"memory"
"qps"
"chart"
"alerts"
"tasks";
grid-template-columns: 1fr;
}
}
.area-cpu {
grid-area: cpu;
}
.area-memory {
grid-area: memory;
}
.area-qps {
grid-area: qps;
}
.area-chart {
grid-area: chart;
}
.area-alerts {
grid-area: alerts;
}
.area-tasks {
grid-area: tasks;
}
</style>

图:AIOps Dashboard 移动端自适应效果,竖屏模式下卡片、图表、告警列表纵向排列
现象:Dashboard 打开 1 小时后,Chrome 内存从 200MB 飚到 1.5GB,页面卡顿。
原因:切换路由时 ECharts 实例未销毁,每次进入页面创建新实例,旧实例的定时器和 DOM 引用持续占用内存。
解决:在 onUnmounted 中确保销毁实例,并用 WeakMap 做实例缓存:
const chartInstances = new WeakMap<HTMLDivElement, echarts.ECharts>()
function getOrCreateChart(el: HTMLDivElement) {
let chart = chartInstances.get(el)
if (!chart) {
chart = echarts.init(el)
chartInstances.set(el, chart)
}
return chart
}
onUnmounted(() => {
const chart = chartInstances.get(chartRef.value!)
chart?.dispose()
chartInstances.delete(chartRef.value!)
})
提醒:Vue 的
<keep-alive>也会保留组件实例,配合onDeactivated暂停图表更新。
现象:WebSocket 断线重连后,Dashboard 出现重复告警,同一条告警出现 2-3 次。
原因:重连后后端会补发断线期间的告警,但前端 Store 没有做去重处理,新数据和已有数据叠加。
解决:在 addAlert 时增加去重校验:
function addAlert(alert: Alert) {
const exists = alerts.value.some((a) => a.id === alert.id)
if (exists) return
alerts.value.unshift(alert)
if (alerts.value.length > 200) {
alerts.value.pop()
}
}
提醒:批量补发场景下,建议后端接口支持
sinceTimestamp参数,只返回断线后的增量数据。
现象:侧边栏折叠/展开后,图表区域变为空白,需要手动刷新页面才恢复。
原因:ECharts 在容器尺寸变化时不会自动 resize,渲染还是按旧尺寸。
解决:监听 ResizeObserver 触发 ECharts resize:
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
const chart = getOrCreateChart(chartRef.value!)
resizeObserver = new ResizeObserver(() => {
chart.resize()
})
if (chartRef.value) {
resizeObserver.observe(chartRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
提醒:多个图表同时 resize 会造成性能问题,建议用
requestAnimationFrame做节流。
优化项 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
ECharts 动画 | 开启(60fps 占用 15% CPU) | 关闭(CPU 占用 3%) | 降低 80% |
告警列表渲染 | 200 条全量渲染 | 虚拟滚动(可视区 20 条) | 内存降低 60% |
WebSocket 心跳 | 无(SLB 频繁断开) | 30s 心跳 | 连接稳定率 99% |
图表 resize | 手动刷新 | ResizeObserver 自动 | 零操作修复 |
测试环境:Chrome 126 | MacBook Pro M2 | Dashboard 同时展示 6 个图表 + 告警列表 + 任务看板

图:Vue3 AIOps Dashboard 性能优化效果对比,优化后各项指标显著提升
本文覆盖了 AIOps Dashboard 前端开发的四个核心环节:
适用场景:需要搭建运维监控 Dashboard 的前端开发者
不适用场景:已有 Grafana 等成熟监控面板且无需定制的团队