可视化大屏开发实战
制造业数据大屏全攻略 — 从布局到部署
大屏开发概述
数据大屏(数据可视化大屏)是将企业关键业务指标以图表、数字、地图等形式集中展示在大屏幕上的系统。 在制造业场景中,大屏通常部署在车间墙壁上的大尺寸电视或 LED 屏上,实时监控产线状态。
普通网站 / 手机APP 就像你手机上的天气APP — 你主动打开,滚动查看,随时操作。
数据大屏 就像工厂车间墙上挂的那块大屏幕 — 永远亮着,不用你操作,抬头就能看到 OEE、产量、告警等关键信息。
再想一个:普通网页 = 你在工位上用 Excel 看报表;数据大屏 = 老板会议室墙上那块实时跳动的仪表盘。
大屏的核心理念:一眼看到全局,数据自己说话。
普通网页
- 响应式布局,适配各种屏幕
- 支持滚动,内容不限
- 浅色/暗色主题随意
- 用户交互为主(点击、表单)
- 字体 14-16px
- 数据按需加载
- 使用 React/Vue 等框架
数据大屏
- 固定分辨率(1920×1080 为主)
- 一屏展示,禁止滚动
- 暗色主题(科技感/深色系)
- 数据展示为主(少交互)
- 大字体(标题 20-36px)
- 数据实时轮询 / WebSocket
- 可不用框架,纯 HTML + ECharts
| 特性 | 普通网页 | 数据大屏 |
|---|---|---|
| 分辨率 | 响应式 | 固定 1920×1080(scale 适配) |
| 滚动 | 支持 | 禁止,所有内容一屏展示 |
| 主题 | 浅色为主 | 暗色主题(深蓝/黑色背景) |
| 交互 | 点击、表单、路由跳转 | 极少交互,主要是数据展示 |
| 刷新 | 用户手动刷新 | 自动轮询(5-30秒)或 WebSocket |
| 字体 | 14-16px | 标题 24-48px,数据 20-36px |
| 运行环境 | 浏览器 | 大屏 TV / LED 显示器 |
| 技术栈 | Vue/React + 组件库 | ECharts + 原生 CSS/Canvas |
页面布局方案
制造业大屏的布局几乎都是「上-中-下」三段式,中间分「左-中-右」三列。
| 区域 | 内容 | 宽度占比 |
|---|---|---|
| Header 标题栏 | 工厂名称、实时时钟、日期 | 100% |
| Left 左侧面板 | 2-3 个指标卡(OEE、直通率、计划达成率) | ~25% |
| Center 中央区域 | 主图表:产线产量趋势 / 车间地图 / 综合仪表 | ~50% |
| Right 右侧面板 | 停机时长 TOP5、能耗趋势 | ~25% |
| Footer 底部 | 滚动告警信息(设备故障、质量异常等) | 100% |
/* 大屏核心布局:1920×1080 固定尺寸 + scale 缩放 */
<!DOCTYPE html>
<html>
<head>
<style>
/* 设计稿基准尺寸 */
body {
margin: 0;
background: #0a0e27;
color: #e0e6ff;
font-family: 'Microsoft YaHei', sans-serif;
overflow: hidden; /* 禁止滚动 */
}
/* 缩放容器:以 1920×1080 为基准 */
.screen-wrapper {
width: 1920px;
height: 1080px;
transform-origin: left top;
position: absolute;
top: 50%; left: 50%;
}
/* Header:标题栏 */
.header {
height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
background: linear-gradient(180deg, rgba(0,242,254,0.1), transparent);
border-bottom: 1px solid #7ec8f0;
}
.header .title {
font-size: 28px;
font-weight: bold;
letter-spacing: 6px;
background: linear-gradient(90deg, #00f2fe, #4facfe);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 主体三列布局 */
.main-body {
display: flex;
height: calc(1080px - 70px - 44px);
padding: 12px;
gap: 12px;
}
.panel-left, .panel-right {
width: 460px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-center {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
/* 卡片通用样式 */
.chart-card {
background: rgba(13,21,58,0.8);
border: 1px solid #7ec8f0;
border-radius: 8px;
padding: 16px;
flex: 1;
}
/* Footer:底部告警滚动条 */
.footer {
height: 44px;
background: rgba(0,0,0,0.3);
border-top: 1px solid #7ec8f0;
display: flex;
align-items: center;
padding: 0 20px;
font-size: 14px;
color: #ffd93d;
overflow: hidden;
}
</style>
</head>
<body>
<div class="screen-wrapper">
<!-- Header -->
<div class="header">
<span class="title">XX工厂 智能制造数据中心</span>
<span id="clock">2026-06-10 14:30:00</span>
</div>
<!-- 主体 -->
<div class="main-body">
<div class="panel-left">
<div class="chart-card"> OEE 指标卡 </div>
<div class="chart-card"> 直通率 </div>
<div class="chart-card"> 计划达成率 </div>
</div>
<div class="panel-center">
<div class="chart-card"> 产线产量趋势 </div>
</div>
<div class="panel-right">
<div class="chart-card"> 停机时长TOP5 </div>
<div class="chart-card"> 能耗趋势 </div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<span>⚠ [14:25] 3号线焊装工位温度异常 </span>
</div>
</div>
</body>
</html>
关键技术点
大屏永远以 1920×1080 为设计基准,但实际屏幕可能尺寸不同。三种适配方案:
| 方案 | 原理 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| scale 缩放 | 固定 1920×1080 设计,用 CSS transform: scale() 等比缩放 |
最简单,像素级还原设计稿 | 极端比例下会留白 | 推荐 |
| rem + font-size | 根据屏幕宽度动态设置 html font-size,所有尺寸用 rem |
灵活,不留白 | 需要换算,字体可能模糊 | 可选 |
| vw/vh | 所有尺寸用 vw / vh 单位 |
原生支持,不需要 JS | 非等比屏幕会变形 | 备选 |
// scale 适配:以 1920×1080 为基准,等比缩放
function autoScale() {
const designWidth = 1920;
const designHeight = 1080;
const wrapper = document.querySelector('.screen-wrapper');
const scaleX = window.innerWidth / designWidth;
const scaleY = window.innerHeight / designHeight;
const scale = Math.min(scaleX, scaleY);
wrapper.style.transform = `scale(${scale})`;
wrapper.style.transformOrigin = 'left top';
// 居中
wrapper.style.left = (window.innerWidth - designWidth * scale) / 2 + 'px';
wrapper.style.top = (window.innerHeight - designHeight * scale) / 2 + 'px';
}
window.addEventListener('resize', autoScale);
autoScale();
// rem 方案:根据屏幕宽度设置 html 的 font-size
function setRem() {
const baseWidth = 1920;
const baseFontSize = 100; // 1rem = 100px @1920
const scale = document.documentElement.clientWidth / baseWidth;
document.documentElement.style.fontSize = baseFontSize * scale + 'px';
}
// CSS 中:width: 4.6rem (=460px @1920), height: 1.08rem
/* vw/vh 方案:所有尺寸用视口单位 */
/* 1920px = 100vw, 1080px = 100vh */
/* 所以 460px = 23.96vw */
.panel-left {
width: 23.96vw; /* 460 / 1920 * 100 */
}
.header {
height: 6.48vh; /* 70 / 1080 * 100 */
font-size: 1.46vw; /* 28 / 1920 * 100 */
}
setInterval 轮询
// 每10秒请求一次
const timer = setInterval(async () => {
const res = await fetch('/api/oee');
const data = await res.json();
updateCharts(data);
}, 10000);
// 页面销毁时清除
clearInterval(timer);
- 实现简单,不需要后端额外支持
- 适合数据变化不频繁的场景(5-30秒更新)
- 浪费带宽(即使数据没变也会请求)
WebSocket 推送
// 建立 WebSocket 连接
const ws = new WebSocket('ws://server/oee');
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
updateCharts(data);
};
ws.onerror = () => {
// 断线重连
setTimeout(connect, 3000);
};
- 实时性高,服务端有变化就推送
- 适合秒级更新、告警推送
- 需要后端 WebSocket 支持
大屏上数字从旧值滚动到新值,是制造科技感的重要细节。
/**
* 数字滚动动画
* @param {HTMLElement} el - 目标 DOM 元素
* @param {number} start - 起始值
* @param {number} end - 目标值
* @param {number} duration - 动画时长(ms)
* @param {number} decimals - 小数位数
*/
function animateNumber(el, start, end, duration = 1000, decimals = 1) {
const range = end - start;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeOutExpo 缓动函数
const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
const current = start + range * eased;
el.textContent = current.toFixed(decimals) + '%';
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// 使用示例
const oeeEl = document.getElementById('oee-value');
animateNumber(oeeEl, 82.5, 85.2, 1500, 1);
// 数字会从 82.5% 平滑滚动到 85.2%
大屏卡片的边框不是简单的 border,而是带有发光、渐变、角标的科技感边框。
/* 科技感边框:渐变边框 + 角标装饰 */
.sci-fi-border {
position: relative;
background: linear-gradient(135deg, rgba(13,21,58,0.9), rgba(6,10,31,0.95));
border-radius: 4px;
padding: 16px;
}
/* 外发光边框 */
.sci-fi-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: 4px;
padding: 1px;
background: linear-gradient(135deg, #00f2fe33, #4facfe22, #d2a8ff33);
-webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* 四角装饰线 */
.sci-fi-border .corner {
position: absolute;
width: 12px; height: 12px;
border-color: #00f2fe;
border-style: solid;
}
.corner.tl { top:0;left:0;border-width:2px 0 0 2px; }
.corner.tr { top:0;right:0;border-width:2px 2px 0 0; }
.corner.bl { bottom:0;left:0;border-width:0 0 2px 2px; }
.corner.br { bottom:0;right:0;border-width:0 2px 2px 0; }
大屏的暗色主题不是随便选个深色背景就行,需要一套完整的色彩体系。
#0a0e27#00f2fe#4facfe#ffd93d| 用途 | 颜色 | 说明 |
|---|---|---|
| 背景 | #0a0e27 | 深蓝黑色,不刺眼 |
| 卡片背景 | rgba(13,21,58,0.8) | 半透明深蓝,有层次感 |
| 主文字 | #e0e6ff | 柔白色,高对比但不刺眼 |
| 强调文字 | #00f2fe | 青色,科技感强 |
| 数据色1 | #4facfe | 蓝色系图表 |
| 数据色2 | #22c55e | 绿色系(达标/正常) |
| 数据色3 | #ffd93d | 黄色系(警告/注意) |
| 数据色4 | #ff6b6b | 红色系(告警/异常) |
| 边框 | #7ec8f0 | 深蓝灰,若隐若现 |
#e0e6ff 这种带一点蓝调的柔白色,长时间盯屏不会刺眼。
完整示例:OEE 智能制造看板
打开下面的完整 HTML 文件,你会看到一个包含以下内容的制造业数据大屏:
oee-dashboard.html,直接用浏览器打开即可看到完整大屏效果。依赖 ECharts CDN,需要联网。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>OEE 智能制造看板</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #050a1a;
color: #e0e6ff;
font-family: 'Microsoft YaHei', sans-serif;
overflow: hidden;
}
/* === Scale 适配容器 === */
.screen {
width: 1920px; height: 1080px;
position: absolute; top: 0; left: 0;
transform-origin: left top;
display: flex; flex-direction: column;
}
/* === Header === */
.header {
height: 70px; display: flex;
align-items: center; justify-content: center;
position: relative;
background: linear-gradient(180deg,
rgba(0,242,254,0.08) 0%, transparent 100%);
border-bottom: 1px solid #1a2550;
}
.header h1 {
font-size: 30px; letter-spacing: 8px; font-weight: 700;
background: linear-gradient(90deg, #00f2fe, #4facfe, #d2a8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .clock {
position: absolute; right: 30px;
font-size: 18px; color: #4facfe;
font-family: 'Consolas', monospace;
}
.header .date-info {
position: absolute; left: 30px;
font-size: 14px; color: #bac2de;
}
/* === KPI Cards === */
.kpi-row {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 12px; padding: 14px 16px 0;
}
.kpi-card {
background: linear-gradient(135deg, rgba(13,21,58,0.9), rgba(6,10,31,0.95));
border: 1px solid #1a2550; border-radius: 8px;
padding: 16px 20px; position: relative; overflow: hidden;
}
.kpi-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0;
height: 2px;
}
.kpi-card:nth-child(1)::before { background: linear-gradient(90deg, #00f2fe, transparent); }
.kpi-card:nth-child(2)::before { background: linear-gradient(90deg, #22c55e, transparent); }
.kpi-card:nth-child(3)::before { background: linear-gradient(90deg, #4facfe, transparent); }
.kpi-card:nth-child(4)::before { background: linear-gradient(90deg, #ffd93d, transparent); }
.kpi-label { font-size: 13px; color: #bac2de; margin-bottom: 6px; }
.kpi-value { font-size: 32px; font-weight: 700; font-family: 'Consolas', monospace; }
.kpi-value.cyan { color: #00f2fe; }
.kpi-value.green { color: #22c55e; }
.kpi-value.blue { color: #4facfe; }
.kpi-value.yellow { color: #ffd93d; }
.kpi-trend { font-size: 12px; margin-top: 4px; }
.kpi-trend.up { color: #22c55e; }
.kpi-trend.down { color: #ff6b6b; }
/* === Chart Grid === */
.chart-grid {
flex: 1; display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 12px; padding: 12px 16px;
}
.chart-box {
background: linear-gradient(135deg, rgba(13,21,58,0.9), rgba(6,10,31,0.95));
border: 1px solid #1a2550; border-radius: 8px;
padding: 12px; display: flex; flex-direction: column;
}
.chart-title {
font-size: 14px; color: #bac2de;
margin-bottom: 8px; padding-left: 8px;
border-left: 3px solid #4facfe;
}
.chart-container { flex: 1; }
/* === Footer === */
.footer {
height: 40px; display: flex; align-items: center;
background: rgba(0,0,0,0.3);
border-top: 1px solid #1a2550;
padding: 0 20px; font-size: 13px; color: #ffd93d;
overflow: hidden;
}
.footer .alert-tag {
background: rgba(255,107,107,0.2);
color: #ff6b6b; padding: 2px 8px;
border-radius: 3px; font-size: 11px;
margin-right: 10px; flex-shrink: 0;
}
.marquee {
white-space: nowrap;
animation: marquee 30s linear infinite;
}
@keyframes marquee {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
</style>
</head>
<body>
<div class="screen" id="screen">
<!-- Header -->
<div class="header">
<span class="date-info">星期三</span>
<h1>XX工厂 智能制造 OEE 看板</h1>
<span class="clock" id="clock"></span>
</div>
<!-- KPI Cards -->
<div class="kpi-row">
<div class="kpi-card">
<div class="kpi-label">设备综合效率 OEE</div>
<div class="kpi-value cyan" id="kpi-oee">--</div>
<div class="kpi-trend up">↑ 较昨日 +1.2%</div>
</div>
<div class="kpi-card">
<div class="kpi-label">直通率 FPY</div>
<div class="kpi-value green" id="kpi-fpy">--</div>
<div class="kpi-trend up">↑ 较昨日 +0.3%</div>
</div>
<div class="kpi-card">
<div class="kpi-label">计划达成率</div>
<div class="kpi-value blue" id="kpi-plan">--</div>
<div class="kpi-trend down">↓ 较昨日 -0.8%</div>
</div>
<div class="kpi-card">
<div class="kpi-label">停机时长 (h)</div>
<div class="kpi-value yellow" id="kpi-downtime">--</div>
<div class="kpi-trend down">↓ 较昨日 +0.5h</div>
</div>
</div>
<!-- Charts -->
<div class="chart-grid">
<div class="chart-box">
<div class="chart-title">各产线产量 (件)</div>
<div class="chart-container" id="chart-bar"></div>
</div>
<div class="chart-box">
<div class="chart-title">综合 OEE 仪表盘</div>
<div class="chart-container" id="chart-gauge"></div>
</div>
<div class="chart-box">
<div class="chart-title">24h OEE 趋势</div>
<div class="chart-container" id="chart-line"></div>
</div>
<div class="chart-box">
<div class="chart-title">停机原因分布</div>
<div class="chart-container" id="chart-pie"></div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<span class="alert-tag">告警</span>
<span class="marquee">
[14:25] 3号线焊装工位温度异常 (82.3°C) |
[14:18] 1号线 CNC-05 刀具磨损预警 |
[13:52] B车间能效偏低 (PUE=1.65) |
[13:30] 2号线AOI检测良率下降至94.2%
</span>
</div>
</div>
<script>
// ===== 1. Scale 自适应 =====
function autoScale() {
const el = document.getElementById('screen');
const sx = window.innerWidth / 1920;
const sy = window.innerHeight / 1080;
const s = Math.min(sx, sy);
el.style.transform = `scale(${s})`;
el.style.left = (window.innerWidth - 1920 * s) / 2 + 'px';
el.style.top = (window.innerHeight - 1080 * s) / 2 + 'px';
}
window.addEventListener('resize', autoScale);
autoScale();
// ===== 2. 实时时钟 =====
function updateClock() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
document.getElementById('clock').textContent =
`${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} `
+ `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
}
setInterval(updateClock, 1000);
updateClock();
// ===== 3. 数字滚动动画 =====
function animateNum(el, start, end, dur, dec, suffix='%') {
const t0 = performance.now();
(function step(t) {
const p = Math.min((t - t0) / dur, 1);
const ease = 1 - Math.pow(2, -10 * p);
el.textContent = (start + (end - start) * ease).toFixed(dec) + suffix;
if (p < 1) requestAnimationFrame(step);
})(t0);
}
animateNum(document.getElementById('kpi-oee'), 0, 85.2, 2000, 1);
animateNum(document.getElementById('kpi-fpy'), 0, 96.8, 2000, 1);
animateNum(document.getElementById('kpi-plan'), 0, 92.1, 2000, 1);
animateNum(document.getElementById('kpi-downtime'), 0, 2.5, 2000, 1, 'h');
// ===== 4. ECharts 通用配色 =====
const COLORS = ['#4facfe', '#00f2fe', '#22c55e', '#ffd93d',
'#d2a8ff', '#ff6b6b', '#06b6d4', '#f97316'];
// ===== 5. 柱状图:各产线产量 =====
const barChart = echarts.init(document.getElementById('chart-bar'));
barChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, top: 30, bottom: 30 },
xAxis: {
type: 'category',
data: ['1号线', '2号线', '3号线', '4号线', '5号线', '6号线'],
axisLine: { lineStyle: { color: '#1a2550' } },
axisLabel: { color: '#bac2de' }
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#0f1535' } },
axisLabel: { color: '#bac2de' }
},
series: [{
type: 'bar',
data: [1280, 1156, 1342, 1089, 1198, 1245],
barWidth: '40%',
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0,0,0,1,[
{offset:0, color:'#4facfe'},
{offset:1, color:'#00f2fe22'}
])
}
}]
});
// ===== 6. 仪表盘:综合 OEE =====
const gaugeChart = echarts.init(document.getElementById('chart-gauge'));
gaugeChart.setOption({
series: [{
type: 'gauge',
startAngle: 220, endAngle: -40,
min: 0, max: 100,
radius: '85%',
progress: { show: true, width: 14,
itemStyle: { color: '#00f2fe' } },
axisLine: { lineStyle: { width: 14, color: [[1, '#1a2550']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { distance: 20, color: '#bac2de', fontSize: 11 },
pointer: { show: false },
title: { offsetCenter: [0, '70%'], color: '#bac2de', fontSize: 13 },
detail: {
valueAnimation: true,
offsetCenter: [0, '35%'],
fontSize: 36, fontWeight: 'bold',
color: '#00f2fe',
formatter: '{value}%'
},
data: [{ value: 85.2, name: '综合 OEE' }]
}]
});
// ===== 7. 折线图:24h OEE 趋势 =====
const hours = Array.from({length:24}, (_,i) => `${String(i).padStart(2,'0')}:00`);
const oeeData = [78,76,75,74,73,72,80,85,87,88,
86,84,83,85,86,88,87,86,85,83,82,80,79,78];
const lineChart = echarts.init(document.getElementById('chart-line'));
lineChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 45, right: 15, top: 20, bottom: 30 },
xAxis: {
type: 'category', data: hours, boundaryGap: false,
axisLine: { lineStyle: { color: '#1a2550' } },
axisLabel: { color: '#bac2de', fontSize: 10,
interval: 3 }
},
yAxis: {
type: 'value', min: 60, max: 100,
splitLine: { lineStyle: { color: '#0f1535' } },
axisLabel: { color: '#bac2de' }
},
series: [{
type: 'line', data: oeeData, smooth: true,
symbol: 'none', lineStyle: { color: '#d2a8ff', width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0,0,0,1,[
{offset:0, color:'rgba(168,85,247,0.3)'},
{offset:1, color:'rgba(168,85,247,0)'}
])
}
}]
});
// ===== 8. 饼图:停机原因分布 =====
const pieChart = echarts.init(document.getElementById('chart-pie'));
pieChart.setOption({
tooltip: { trigger: 'item' },
legend: {
orient: 'vertical', right: 10, top: 'center',
textStyle: { color: '#bac2de', fontSize: 11 }
},
series: [{
type: 'pie', radius: ['40%', '70%'],
center: ['35%', '50%'],
label: { show: false },
data: [
{ value: 35, name: '设备故障', itemStyle: {color:'#ff6b6b'} },
{ value: 25, name: '换线调整', itemStyle: {color:'#ffd93d'} },
{ value: 20, name: '物料等待', itemStyle: {color:'#4facfe'} },
{ value: 12, name: '计划停机', itemStyle: {color:'#22c55e'} },
{ value: 8, name: '其他', itemStyle: {color:'#d2a8ff'} }
]
}]
});
// ===== 9. 窗口缩放自适应 =====
window.addEventListener('resize', () => {
barChart.resize();
gaugeChart.resize();
lineChart.resize();
pieChart.resize();
});
</script>
</body>
</html>
性能优化
ECharts 实例就像你雇的工人。每创建一个实例 = 雇一个工人。
如果你不辞退(dispose)离职的工人,他们还在领工资(占内存)。
一个大屏 6-8 个图表 = 6-8 个工人,不多。但如果你在 SPA 路由切换中反复创建不销毁,内存就爆了。
// 图表管理器:统一创建、更新、销毁
const ChartManager = {
// 存储所有图表实例
instances: new Map(),
// 创建或获取已有实例
getOrCreate(containerId) {
if (this.instances.has(containerId)) {
return this.instances.get(containerId);
}
const dom = document.getElementById(containerId);
const chart = echarts.init(dom);
this.instances.set(containerId, chart);
return chart;
},
// 更新数据(不重建实例)
updateOption(containerId, option) {
const chart = this.getOrCreate(containerId);
chart.setOption(option, { notMerge: false });
},
// 全部 resize
resizeAll() {
this.instances.forEach(chart => chart.resize());
},
// 销毁所有(页面卸载时调用)
disposeAll() {
this.instances.forEach(chart => chart.dispose());
this.instances.clear();
}
};
// 使用示例
ChartManager.updateOption('chart-bar', barOption);
// 页面销毁时
window.addEventListener('beforeunload', () => {
ChartManager.disposeAll();
});
// 1. 数据轮询节流:如果上一次请求还没回来,不发起下一次
let isFetching = false;
setInterval(async () => {
if (isFetching) return; // 跳过
isFetching = true;
try {
const data = await fetchData();
updateDashboard(data);
} finally {
isFetching = false;
}
}, 10000);
// 2. Resize 防抖:避免窗口拖拽时疯狂 resize
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const debouncedResize = debounce(() => {
autoScale();
ChartManager.resizeAll();
}, 200);
window.addEventListener('resize', debouncedResize);
// 3. requestAnimationFrame 优化动画
// 大量 DOM 动画放在 rAF 中,与浏览器刷新率同步
function smoothUpdate(el, newValue) {
requestAnimationFrame(() => {
el.textContent = newValue;
});
}
| 优化项 | 做法 | 效果 |
|---|---|---|
| 图表过多 | 用 ChartManager 统一管理,避免重复 init | 减少内存占用 |
| 频繁更新 | setOption 时用 notMerge: false,只更新数据 | 避免整个图表重绘 |
| 数据量大 | 折线图开启 large: true,大数据模式 | 万级数据不卡顿 |
| 定时器泄漏 | 页面卸载前 clearInterval + dispose | 避免内存泄漏 |
| 窗口 resize | 200ms 防抖,避免高频触发 | CPU 占用降低 |
部署流程
- 1. 本地开发:在
localhost:8080开发调试,确认所有图表正常渲染、数据轮询正常 - 2. 打包构建:
npm run build或直接把 HTML/CSS/JS 文件放到dist/目录 - 3. 服务器部署:把
dist/上传到服务器,配置 Nginx - 4. 大屏 TV:用 TV 内置浏览器或连接迷你主机,全屏打开大屏 URL
# /etc/nginx/conf.d/dashboard.conf
server {
listen 80;
server_name dashboard.factory.local;
# 静态文件
root /usr/share/nginx/html/dashboard;
index index.html;
# SPA 路由 — 所有路径都返回 index.html
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /api/ {
proxy_pass http://192.168.1.100:3000/;
proxy_set_header Host $host;
}
# 缓存静态资源(大屏不变的前端文件)
location ~* \.(js|css|png|jpg|woff2)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
# deploy.sh — 一键部署大屏到生产服务器
# 用法: ./deploy.sh
# 1. 打包
npm run build
# 2. 压缩
tar -czf dist.tar.gz dist/
# 3. 上传到服务器
scp dist.tar.gz root@192.168.1.200:/tmp/
# 4. 远程解压部署
ssh root@192.168.1.200 "cd /usr/share/nginx/html/dashboard \
&& rm -rf * \
&& tar -xzf /tmp/dist.tar.gz --strip-components=1 \
&& nginx -s reload"
echo "部署完成! 访问: http://dashboard.factory.local"
chrome --kiosk URL)。自动刷新:每天凌晨自动刷新页面,防止长时间运行导致内存泄漏 →
setTimeout(() => location.reload(), 24*3600*1000)
面试考点 — Top 6 大屏问题
(1) scale 缩放(推荐):固定 1920×1080 设计,
transform: scale(ratio) 等比缩放。优点是像素级还原、最简单。(2) rem 方案:动态设置 html font-size,所有尺寸用 rem。灵活但需要换算。
(3) vw/vh:用视口单位。简单但非等比屏幕会变形。
回答模板:「我们项目用的是 scale 方案。因为大屏 TV 都是标准 16:9 分辨率,scale 最简单稳定。核心代码就是计算 scaleX 和 scaleY 取最小值,设置 transform。」
(1) setInterval 轮询:每 10-30 秒请求一次 API,适合大多数制造业场景(OEE、产量不需要秒级更新)。
(2) WebSocket:服务端主动推送,适合告警场景(设备故障需要秒级通知)。
实际项目:通常两者结合 — 轮询更新 KPI 数据,WebSocket 推送告警。轮询要加 请求锁(防止上一次请求未完成就发起下一次),页面卸载时
clearInterval 防止内存泄漏。
(2) 懒初始化:不在 mounted 时一次性 init 所有图表,用 IntersectionObserver 在图表进入可视区域时再 init。
(3) 增量更新:
setOption(option, { notMerge: false }) 只更新变化的数据,不整个重绘。(4) 大数据开启 large 模式:ECharts 的
large: true 模式用 Canvas 批量绘制,万级数据也不卡。(5) 降采样:折线图数据超过 500 个点时,前端做降采样(取最大最小值),减少渲染压力。
(2) ECharts large 模式:
series.large = true,底层用 Canvas 批量绘制。(3) 数据窗口:用
dataZoom 组件,只渲染当前可视范围的数据。(4) Web Worker:大量数据计算(排序、聚合)放到 Worker 线程,不阻塞主线程。
(5) 虚拟列表:如果是表格展示,用虚拟滚动只渲染可见行。
(2) 避免白屏:刷新时先合并数据再渲染,不要先清空再填充。
(3) 数字动画:用 requestAnimationFrame 做 countUp 动画,从旧值平滑过渡到新值。
(4) 每日重载:大屏 7×24 运行,每天凌晨自动
location.reload(),防止长时间运行导致的内存泄漏。(5) 断线处理:请求失败时显示上次数据 + 「数据同步中」提示,不要整个大屏报错。
(1) 需求确认:和业务方确认展示哪些指标(OEE、直通率、停机时长等)、数据来源(MES/SCADA/ERP)。
(2) UI 设计:UI 设计师出 1920×1080 设计稿(Figma),确认布局、配色、图表类型。
(3) 技术选型:纯 HTML + ECharts(简单大屏)或 Vue + ECharts(复杂大屏)。
(4) 前端开发:scale 适配 + 暗色主题 + 图表开发 + 数据轮询 + 数字动画。
(5) 后端接口:开发 API 接口,返回 KPI 数据、图表数据、告警数据。
(6) 联调测试:对接真实数据,验证数据正确性、刷新稳定性。
(7) 部署上墙:Nginx 部署 + 大屏 TV 全屏浏览 + kiosk 模式 + 每日自动刷新。
可视化大屏开发实战 — 制造业数据大屏全攻略
ECharts + Scale 适配 + 暗色主题 + 实时数据 + 数字动画 + 性能优化 + Nginx 部署