【前端实战笔记】智慧园区数字孪生大屏总记录:从技术选型到性能治理的完整落地记录
个人真实开发总结
做数字孪生大屏的你,是否也经历过这样的绝望:
- 需求方说"要大屏、要炫、要 3D",但没人说得清到底要什么
- 大屏跑 2 小时浏览器直接卡死,内存只涨不跌,找不到凶手
- 5 万点折线拖一下卡半天,tooltip 跟手都难
- 3D 场景一上量就掉帧,低端设备直接黑脸
- AI 面板接入后,现场演示动不动就白屏,讲不完一段话
如果你正在做、或即将接手一个"3D + 大屏 + AI"类项目,这篇文章就是为你写的。我用一个真实的智慧园区数字孪生大屏项目为主线,完整记录从需求拆解、技术选型、架构落地,到 3D 场景、统计看板、AI 接入、实时数据、性能治理、验收交付的全过程。
这篇文章你能拿到什么:
- 技术选型会的完整决策过程------当时为什么选 ECharts + Three.js,而不是 AntV / D3 / Babylon.js
- 每一层架构的完整可运行代码 + 参数为什么这么配
- 我在项目里真实踩过、并排查解决的 20+ 个坑,每个都带"现象 → 根因 → 解法 → 预防"
- 一套可以直接抄走的性能基线表、CI 检查脚本、发版自检清单
一、项目启动:从需求到技术选型
1.1 需求场景复盘(用户到底要什么)
为什么这么用:大屏类项目 90% 的返工,都是第一周没把需求聊透。需求方说"要炫",翻译过来往往只是"领导视察时能讲出东西"。我复盘后的真实需求拆解:
原始需求:"园区要一个可视化大屏,要 3D 的,要炫"
↓ 逐层追问拆解
真实需求:
① 领导视角:一眼看懂园区整体运行状态(能耗、人流、设备、告警)
② 运营视角:能下钻到单栋楼/单设备的实时数据
③ 演示视角:能讲 15 分钟的故事(先看全貌 → 再看数据 → 再互动)
④ 未来视角:要能接 AI 问答,让领导自己"问数据"
注意事项 :接大屏项目第一天,先把上面这张表做出来,跟需求方逐条确认。没确认就动手,后面每次演示都是事故现场。这条经验值回票价。
1.2 技术选型会(为什么不是 AntV / D3 / Babylon)
为什么这么用:选型不是比"哪个技术牛",是比"哪个技术能让我们项目按期交付"。
当时选型会的决策记录:
| 维度 | ECharts | AntV | D3 |
|---|---|---|---|
| 统计图表(折线/饼图/仪表盘) | 开箱即用 | 也不错 | 全手写 |
| 团队经验积累 | 深(工业大屏事实标准) | 中 | 浅 |
| AI 生成代码可靠性 | 高(配置模式固定) | 中 | 低 |
| 维护成本 | 低 | 中 | 高 |
结论:统计层用 ECharts。 理由不是 ECharts 最好,是"团队用它出活最快、出错最可查"。
3D 层选型记录:
| 维度 | Three.js | Babylon.js | PlayCanvas |
|---|---|---|---|
| 生态/社区 | 最大 | 中 | 中 |
| AI 训练数据量 | 最多 | 少 | 少 |
| 出问题时检索成本 | 最低 | 高 | 高 |
| 团队熟悉度 | 有基础 | 无 | 无 |
结论:3D 层用 Three.js。 这里有个现实但重要的决策因素:AI 辅助开发时代,生态大 = 训练数据多 = AI 生成代码可靠 = 出问题查得到。这个理由写进了选型文档。
注意事项:选型结论要记录"不选什么"和"为什么不选",三个月后有人问"为什么不用 X"时,你不用重新吵一遍。
1.3 版本锁定与项目宪法
为什么这么用:Three.js 的 addons 路径随版本变,ECharts 大版本 API 有差异。AI 生成代码最爱混版本,本地正常、上线随机白屏。
bash
npm i three@0.160.0 --save-exact # 锁死补丁版本
npm i echarts@5.4.3 --save-exact
项目宪法(写进 README 第一屏):
md
# 可视化项目版本宪法
- Three.js: 0.160.0(锁定,addons 路径以官网 r160 文档为准)
- ECharts: 5.4.3(锁定主版本,renderer 用 canvas)
- 禁止 AI 直接"升级版本"类建议,升级必须走评审
- 新增依赖必须评估体积/兼容性,评审通过才可引入
注意事项 :renderer: 'canvas' 是默认也是我们的强制配置------SVG 渲染在数据量大时性能断崖式下降,除非小数据 + 强交互需求,否则不切 SVG。
1.4 目录结构与分层落地
为什么这么用:数字孪生项目 80% 的稳定性问题来自"职责不分、跨层互调"。代码全堆一个组件,改一处崩三处,出了事没人说得清在哪层。最终落地的目录:
bash
src/
├── charts/ # ECharts 封装层
│ ├── base/
│ │ └── useECharts.ts # init/resize/dispose/大数据默认配置
│ └── variants/ # 业务图表(PieChart/TrendChart/GaugeChart)
├── three/ # Three.js 场景层
│ ├── core/
│ │ ├── createScene.ts # 场景/相机/渲染器/按需渲染
│ │ └── dispose.ts # 资源释放铁律
│ ├── loaders/
│ │ └── createLoaders.ts # GLTF/KTX2/DRACO 管线
│ ├── objects/ # 场景对象工厂(设备模型/树/标注)
│ └── controllers/ # OrbitControls/相机动画/拾取
├── bridge/ # 2D/3D 联动层(事件总线,单向主导)
│ └── bus.ts
├── data/ # 数据层(WS/mock/协议/节流)
├── ai/ # AI 服务层(SSE/降级/缓存)
├── platform/ # 渲染器选择/WebGPU 降级判断
└── views/ # 页面组件(大屏布局)
注意事项 :层与层之间是单向依赖:views → bridge → charts/three;data 只发事件到 bridge;ai 只被 views 调用。谁违反这条依赖规则,Review 打回。
二、Three.js 3D 园区(实战完整版)
2.1 场景封装完整实现
为什么这么用 :静态园区场景没有持续动画,满帧渲染 = GPU 空转发热,移动端尤其致命。统一封装后业务只调 render(),不知道渲染循环细节。所有业务组件共用一份场景,不重复创建。
ts
// src/three/core/createScene.ts
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
export interface SceneHandle {
scene: THREE.Scene
camera: THREE.PerspectiveCamera
renderer: THREE.WebGLRenderer
controls: OrbitControls
render: () => void
resize: () => void
dispose: () => void
}
export function createScene(container: HTMLElement): SceneHandle {
// ------ 渲染器:高优先级 GPU + 像素比限 2x(防 4K 屏显存爆炸) ------
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: 'high-performance',
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(container.clientWidth, container.clientHeight)
renderer.shadowMap.enabled = false // 园区大场景不开阴影,性能优先
container.appendChild(renderer.domElement)
// ------ 场景 ------
const scene = new THREE.Scene()
scene.background = new THREE.Color(0x0a1428) // 大屏深色底,省 GPU 且护眼
// ------ 相机:45° 透视,视距 0.1~5000 ------
const camera = new THREE.PerspectiveCamera(
45,
container.clientWidth / container.clientHeight,
0.1,
5000
)
camera.position.set(120, 80, 160) // 园区鸟瞰初始位
// ------ 控制器:阻尼手感,但限制缩放/俯仰范围防"穿地" ------
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.08
controls.minDistance = 20
controls.maxDistance = 500
controls.maxPolarAngle = Math.PI / 2.2 // 不允许绕到地面以下
// ------ 按需渲染核心:非持续动画不跑 RAF ------
let rafId: number | null = null
const render = () => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
controls.update()
renderer.render(scene, camera)
})
}
controls.addEventListener('change', render) // 用户操作才渲染
// ------ resize:窗口变化重设比例 ------
const resize = () => {
const w = container.clientWidth
const h = container.clientHeight
if (w === 0 || h === 0) return // display:none 时读不到尺寸,跳过
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
render()
}
window.addEventListener('resize', resize)
// ------ 销毁:事件、控制器、渲染器上下文全清 ------
const dispose = () => {
window.removeEventListener('resize', resize)
if (rafId !== null) cancelAnimationFrame(rafId)
controls.dispose()
renderer.dispose()
container.removeChild(renderer.domElement)
}
return { scene, camera, renderer, controls, render, resize, dispose }
}
注意事项:
setPixelRatio必须限 2x------4K 屏不限就是 4 倍显存powerPreference: 'high-performance'明确要独显,避免浏览器默认集显- 园区大场景不开阴影(shadowMap 开销巨大);真需要阴影只给近景重点对象开
- 卸载时必须
dispose(),否则 GPU 上下文泄漏,开发环境看不出,跑几小时才崩
2.2 光照与相机(实战配置)
为什么这么用:大屏场景的美观度主要来自光照,但光照数量直接决定渲染成本。园区用的是一套"三灯方案":
ts
// 三灯方案:环境光打底 + 主光塑形 + 补光去黑
const ambient = new THREE.AmbientLight(0xffffff, 0.6) // 打底,不能没有
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) // 主光
dirLight.position.set(50, 100, 50)
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x0a1428, 0.4) // 天光+地光,补暗部
scene.add(ambient, dirLight, hemiLight)
注意事项 :灯光超过 5 盏就开始明显掉帧;工业风格大屏常用 MeshLambertMaterial/MeshBasicMaterial(不吃光照或少吃),别全场景 MeshStandardMaterial(PBR 计算重)。
相机初始位和控制器是"讲解动线"的一部分------初始位要能看到全园,方便开场白:"这是园区全景"。我当时为了 15 分钟讲解动线,相机初始位调了 3 轮(第一版离太近开场看不到全貌,第二版太远没有细节冲击力,第三版 45° 鸟瞰 + 可下钻才合格)。这条被记录成经验:相机初始位 = 开场镜头,要按讲解脚本调,不是随便摆个坐标。
2.3 模型加载管线(GLTF + KTX2 + DRACO)
为什么这么用:园区模型(建筑/设备/车辆)动辄 100MB+,不压缩用户等到走人。KTX2 压缩纹理 + DRACO 压缩几何,体积通常砍 70% 以上,加载时间从 30s 级降到 5s 级。
ts
// src/three/loaders/createLoaders.ts
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
export function createLoaders(renderer: THREE.WebGLRenderer) {
// DRACO:几何压缩解压器
const draco = new DRACOLoader()
draco.setDecoderPath('/libs/draco/') // 必须把 node_modules 里的 wasm 拷到 public!
// KTX2:纹理压缩(Basis Universal 转码)
const ktx2 = new KTX2Loader()
ktx2.setTranscoderPath('/libs/basis/') // 同上,basis_transcoder.js/.wasm
ktx2.detectSupport(renderer)
const loader = new GLTFLoader()
loader.setDRACOLoader(draco)
loader.setKTX2Loader(ktx2)
return loader
}
// 加载封装:进度 + 失败重试
export function loadModel(loader: GLTFLoader, url: string) {
return new Promise<THREE.Group>((resolve, reject) => {
loader.load(
url,
(gltf) => resolve(gltf.scene),
(ev) => {
const pct = Math.round((ev.loaded / ev.total) * 100)
bus.emit('model:progress', { url, pct }) // 进度条由业务层监听
},
(err) => {
console.error('[model] 加载失败', url, err)
reject(err)
}
)
})
}
这个坑我是怎么踩的、事后怎么防的:
坑 :模型加载后页面白屏,控制台报 KTX2Loader: KTX2 support not found。
根因 :node_modules/three/examples/jsm/libs/basis/ 下的 basis_transcoder.js/.wasm 没有拷到 public/libs/,运行时 404。
我的解决方案 :加载前先 curl 验证静态资源可达;并把"拷贝命令"写进构建脚本:
bash
# scripts/copy-three-libs.mjs
cp -r node_modules/three/examples/jsm/libs/draco public/libs/
cp -r node_modules/three/examples/jsm/libs/basis public/libs/
# 并在 package.json build 前置执行
预防 :CI 里加一条检查------public/libs/ 下的 wasm 文件存在且版本与锁定的 three 一致;升级 Three.js 版本时,全量回归模型加载。
2.4 资源释放铁律(内存泄漏排查实录)
为什么这么用 :Three.js 不会自动回收 GPU 资源。只从场景移除对象,geometry/material/texture 还在显存里。大屏连续运行,内存只涨不跌,最后浏览器卡死、页面变白。
当时排查的过程(还原现场):
- 现象:大屏跑 2 小时后,切页越来越卡,最终白屏
- 第一步:Chrome DevTools → Memory → 拍两个堆快照对比,发现
WebGLBuffer/ArrayBuffer数量持续增长,确认是 GPU 资源泄漏 - 第二步:逐个模块卸载后拍快照,二分定位到"设备详情弹窗"------每次开关弹窗都泄漏一组几何体+纹理
- 第三步:检查弹窗组件,发现只调了
scene.remove(mesh),没调dispose - 修复:写统一释放工具,强制 dispose
ts
// src/three/core/dispose.ts ------ 资源释放铁律
import * as THREE from 'three'
export function disposeObject(root: THREE.Object3D) {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh
if (mesh.isMesh) {
mesh.geometry?.dispose()
disposeMaterial(mesh.material)
}
})
}
function disposeMaterial(material: THREE.Material | THREE.Material[]) {
const list = Array.isArray(material) ? material : [material]
for (const mat of list) {
// 遍历材质上所有纹理属性:map/normalMap/roughnessMap/emissiveMap/aoMap...
for (const key in mat) {
const value = (mat as Record<string, unknown>)[key]
if (value instanceof THREE.Texture) {
value.dispose()
value.source?.data?.close?.() // ImageBitmap 必须显式 close,否则又漏
}
}
mat.dispose()
}
}
注意事项(AI 生成释放代码必查三点):
- 材质可能是数组 (多材质 mesh),要
Array.isArray兜底 - 纹理不止
map,normalMap/roughnessMap/emissiveMap/aoMap全要释放------用for...in遍历材质属性,而不是手写每个属性名 ImageBitmap必须.close()------纹理的source.data如果是 ImageBitmap,dispose()不会回收它
预防(架构前置) :封装层强制------所有场景对象必须通过对象工厂创建,工厂返回的对象带 dispose() 方法,组件卸载时统一调。禁止业务直接 scene.add 裸对象。
2.5 大规模实例化(5000 棵树,DrawCall 从 5000 到 5)
为什么这么用 :园区 5000 棵树、3000 盏路灯、2000 个指示牌,每个独立 Mesh = 一次 DrawCall。独立 10000 次 DrawCall,低端机直接 5fps。InstancedMesh 把同几何体+同材质的对象合并为一次 DrawCall。
ts
// src/three/objects/createTrees.ts
import * as THREE from 'three'
export function createTreeInstances(count = 5000) {
// 同一棵树的几何+材质,实例化 5000 次
const geometry = new THREE.ConeGeometry(0.5, 2, 6) // 低面数:6 段圆锥足够
const material = new THREE.MeshLambertMaterial({ color: 0x2e8b57 })
const trees = new THREE.InstancedMesh(geometry, material, count)
const dummy = new THREE.Object3D()
for (let i = 0; i < count; i++) {
// 园区边界 400x400 内随机分布,间距不能太近(防穿插)
dummy.position.set(
(Math.random() - 0.5) * 400,
1,
(Math.random() - 0.5) * 400
)
dummy.rotation.y = Math.random() * Math.PI * 2
dummy.scale.setScalar(0.8 + Math.random() * 0.6) // 大小随机,避免"复制粘贴感"
dummy.updateMatrix()
trees.setMatrixAt(i, dummy.matrix)
}
trees.instanceMatrix.needsUpdate = true
return trees
}
当时的性能数字(基线记录):
- 改造前:5000 棵树独立 Mesh →
renderer.info.render.calls≈ 5010,低端机帧率 8-12fps - 改造后:InstancedMesh → calls ≈ 5(加灯光/地面等基础对象),帧率 50-60fps
- 一个改造,帧率翻了 5 倍。
注意事项:
- DrawCall 预算是第一约束 :
renderer.info.render.calls>100 需要干预,>500 低端机必崩。场景加载后把它打印到控制台,养成习惯 - 只有同几何体 + 同材质才能合并实例------树有 3 种颜色就要 3 个 InstancedMesh
- 每帧改实例矩阵要
instanceMatrix.needsUpdate = true;随机位置预计算在加载时,别在动画循环里遍历几千实例
2.6 状态驱动与交互(数字孪生的"活")
为什么这么用 :数字孪生的核心不是"模型好看",是状态驱动------设备开关、能耗高低、车辆位置,实时反映到 3D 模型上。模型是"死"的,状态是"活"的。
ts
// 设备状态 → 模型颜色
const STATUS_COLOR = {
online: 0x2ecc71,
alarm: 0xe74c3c,
offline: 0x95a5a6,
} as const
bus.on('device/status', (d: { id: string; status: keyof typeof STATUS_COLOR }) => {
const mesh = deviceMap.get(d.id) // 设备对象用 Map 按 id 索引,不遍历场景
if (!mesh) return
;(mesh.material as THREE.MeshStandardMaterial).color.setHex(STATUS_COLOR[d.status])
if (d.status === 'alarm') startBlink(mesh) // 告警闪烁
sceneHandle.render() // 状态变化才渲染
})
告警闪烁(用时间驱动渲染,注意别常驻动画):
ts
function startBlink(mesh: THREE.Mesh) {
const blink = () => {
if (blinkSet.has(mesh) === false) return // 外部移除后自动停
mesh.visible = !mesh.visible
sceneHandle.render()
}
blinkSet.add(mesh)
const id = setInterval(blink, 500) // 闪烁用 setInterval + 按需渲染,不占 RAF
// mesh 被移除时:clearInterval + blinkSet.delete(防泄漏)
}
点击拾取(Raycaster 命中检测):
ts
// 3D 点击 → 触发 ECharts 高亮(通过事件总线,单向)
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
renderer.domElement.addEventListener('click', (e) => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hits = raycaster.intersectObjects(deviceMeshes, false)
if (hits.length) {
bus.emit('device:click', { id: hits[0].object.userData.deviceId })
focusCamera(hits[0].object.position) // 相机平滑聚焦
}
})
相机聚焦动画(缓动插值,别瞬移):
ts
function focusCamera(target: THREE.Vector3) {
const from = camera.position.clone()
const duration = 800
const start = performance.now()
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1)
const ease = t * (2 - t) // easeOutQuad
camera.position.lerpVectors(from, target, ease)
camera.lookAt(target)
sceneHandle.render()
if (t < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
注意事项:
- 闪烁/动画用"注册表 + 定时器 + 按需渲染",别在常驻 RAF 里判断------静态场景常驻渲染是移动端发热主因
- Raycaster 的
intersectObjects第二参传false(不递归),命中列表必须是已筛选的 Mesh 列表,否则性能崩 - 交互走事件总线,3D 点击 → 图表高亮是单向的;图表操作不回灌 3D(死循环见 3.5)
三、ECharts 统计看板(实战完整版)
3.1 为什么统计层必须用 ECharts(不搞 3D 柱状图)
为什么这么用 :能耗、人流、设备率是二维统计信息(时间序列/分类占比),ECharts 配置驱动、开箱即用、tooltip/图例/刷选齐全。用 Three.js 做 3D 柱状图"为了好看"------渲染成本翻倍、维护翻三倍、交互还难做。
决策规则(写进团队文档):
统计信息(时间/分类/占比)→ ECharts
空间关系(位置/形态/布局)→ Three.js
混合场景 → 各司其职 + 事件总线联动
如果数据本身是 2D 的,不要为了"炫"用 3D 表达
3.2 useECharts 封装完整实现(为什么必须统一封装)
为什么这么用 :不封装 = 每个组件各自 echarts.init,没人记得 dispose、没人记得 resize 防抖、大数据配置各写各的。大屏几十个图表实例,跑 2 小时内存直接爆。
ts
// src/charts/base/useECharts.ts
import { onMounted, onUnmounted, shallowRef, type Ref } from 'vue'
import * as echarts from 'echarts'
// 大数据量默认配置:业务无需每次手动配
const LARGE_DATA_DEFAULTS = {
large: true,
largeThreshold: 2000, // 超过 2000 点启用大数据优化
sampling: 'lttb', // 降采样:保留趋势,去掉抖动
animation: false, // 大数据量下动画会抵消采样优化,必须关
}
// 全局实例注册表:用于调试审计、统一 dispose
const chartRegistry = new Set<echarts.ECharts>()
export function useECharts(
containerRef: Ref<HTMLElement | null>,
options: { largeData?: boolean } = {}
) {
const { largeData = true } = options
const chart = shallowRef<echarts.ECharts>()
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
// 容器 display:none 时 resize 读到 0 宽高,调用方保证可见后再调
chart.value?.resize()
}, 200)
}
onMounted(() => {
if (!containerRef.value) return
chart.value = echarts.init(containerRef.value)
chartRegistry.add(chart.value)
window.addEventListener('resize', onResize)
})
function setOption(option: echarts.EChartsOption) {
const merged = largeData ? { ...LARGE_DATA_DEFAULTS, ...option } : option
// notMerge: 旧 series 残留 = 幽灵曲线;lazyUpdate: 高频更新合并渲染
chart.value?.setOption(merged, { notMerge: true, lazyUpdate: true })
}
onUnmounted(() => {
chart.value?.dispose() // 硬约束:必须销毁
chartRegistry.delete(chart.value!)
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
return { chart, setOption }
}
// 调试工具:查当前存活实例数与状态
export function auditCharts() {
console.table([...chartRegistry].map((c) => ({ 存活: !!c, 宽高: c.getWidth() })))
}
注意事项:
- dispose 是硬红线:不销毁 = 内存泄漏,大屏连续运行几小时直接卡死
- resize 必须防抖;容器
display:none时resize()读到 0 宽高会画崩------页面级切换用v-if(隐藏即销毁重建),不要v-show setOption固定notMerge: true,否则切换数据源后旧 series 残留(幽灵曲线);高频更新用lazyUpdate合并渲染
3.3 业务图表实战(四类高频图表 + 注意点)
① 能耗趋势折线(双 Y 轴对齐)
这个坑我是怎么踩的 :
坑 :能耗折线(左侧"度")和温度折线(右侧"℃")两条线,左侧正常右侧被压成一条直线。
根因 :两个 Y 轴没统一 min/max,右侧轴刻度范围被自动缩放成窄区间,温度波动 1-2℃ 被放大成剧烈锯齿。
我的解决方案:
ts
option = {
yAxis: [
{ type: 'value', name: '能耗(度)', min: 0, max: 500, splitNumber: 5 },
{ type: 'value', name: '温度(℃)', min: 0, max: 40, splitNumber: 4 }, // 显式对齐
],
series: [
{ name: '能耗', type: 'line', yAxisIndex: 0, data: energyData },
{ name: '温度', type: 'line', yAxisIndex: 1, data: tempData },
],
}
预防 :双 Y 轴一律显式 min/max/splitNumber,写进组件规范;验收时人工目检两轴刻度。
② 楼栋能耗占比饼图(交互联动)
ts
option = {
tooltip: { trigger: 'item', formatter: '{b}: {c} 度 ({d}%)' },
series: [{
type: 'pie',
radius: ['45%', '70%'], // 环形:中间可放总数
label: { formatter: '{b}\n{d}%' },
data: pieData,
}],
}
// 点击饼图 → 3D 聚焦对应楼栋(单向联动)
chart.on('click', (p: any) => bus.emit('scene:focus', { buildingId: p.name }))
③ 设备运行率仪表盘(自定义指针色)
ts
option = {
series: [{
type: 'gauge',
min: 0, max: 100,
progress: { show: true, width: 18 }, // 进度条
axisLine: { lineStyle: { width: 18 } },
detail: { formatter: '{value}%', fontSize: 28, offsetCenter: [0, '70%'] },
data: [{ value: 92, name: '设备运行率' }],
}],
}
④ 园区地图散点(建筑定位 + 状态色)
ts
option = {
series: [{
type: 'scatter', // 园区平面坐标散点,X/Y 与 3D 场景坐标同源
coordinateSystem: 'custom',
data: buildings.map((b) => [b.x, b.y, b.alarmCount]),
symbolSize: (val) => Math.max(6, val[2] * 3), // 按告警数放大
itemStyle: { color: (p) => (p.value[2] > 0 ? '#e74c3c' : '#2ecc71') },
}],
}
注意事项 :园区地图散点坐标与 3D 场景坐标必须同源 (同一份建筑数据表),否则"点击散点聚焦 3D 楼栋"永远对不上;所有图表类型下沉为 variants/ 下的组件,业务不直接写 option。
3.4 大数据量性能(为什么前端只是兜底,根治靠后端)
为什么这么用 :5 万点折线,前端再优化,传输和解析成本都在。前端优化只是兜底,后端聚合才是根治。
接口约定:GET /api/energy?range=1d&bucket=1m
后端按 1 分钟桶聚合,返回 [ts, avg, min, max],点数 ≤1440
前端:large + lttb 采样兜底 + 折线画 avg,min/max 放 tooltip
lttb(Largest-Triangle-Three-Buckets)降采样原理一句话:把数据分段,每段只保留"和相邻两点构成最大三角形的点",趋势保留、毛刺去掉。sampling: 'lttb' 是 ECharts 内置,别自己写。
当时的性能数字(基线记录):
- 优化前:50000 点全量下发 → 接口 3.2s、首绘 1.8s、拖拽卡顿
- 后端聚合后:1440 点 → 接口 120ms、首绘 200ms、拖拽 60fps
- 体验从"卡到放弃"变成"顺滑"------问题 90% 在后端,前端只解决 10%
注意事项 :三件套必须同时生效(large + sampling + animation:false)------AI 生成代码常只写 large: true 却保留 animation: true 和复杂 tooltip.formatter,两者叠加把优化抵消掉;后端不聚合,前端采样只是"看着不卡",内存和流量照样爆。
3.5 实时数据更新(为什么必须节流 + 增量更新)
为什么这么用 :传感器每秒推 10 帧,每帧都 setOption 全量重绘,图表闪瞎、CPU 打满。
ts
// src/data/throttle.ts ------ 500ms 合并一帧
let latest: unknown = null
let timer: ReturnType<typeof setTimeout> | null = null
export function throttledSet(emit: (data: unknown) => void) {
return (d: unknown) => {
latest = d
if (timer) return
timer = setTimeout(() => {
emit(latest)
latest = null
timer = null
}, 500)
}
}
// 使用:只更新 series.data,别整图重绘
socket.on('device/metric', throttledSet((d) => {
buffer.push(d)
if (buffer.length > MAX_POINTS) buffer.shift() // 环形缓冲,固定点数控内存
chart.setOption({ series: [{ data: buffer }] }, { notMerge: false, lazyUpdate: true })
}))
注意事项 :只更新 series.data 别整图重绘(notMerge: false);数据必须限长(shift 旧数据),否则跑 8 小时点数无限涨;实时图表 symbol: 'none' 别画点。
3.6 2D/3D 联动(为什么单向主导)
为什么这么用:看板 brush 筛选 ↔ 3D 场景显隐,双向互相触发就是死循环刷屏(A 改 → B 响应 → A 再响应...)。
规则:单向主导。 3D 是主角,图表跟随;图表操作不反向驱动 3D 场景逻辑(最多驱动"详情展示"这类无害动作):
ts
// ✅ 允许:3D 点击设备 → 看板高亮
bus.on('device:click', ({ id }) => {
chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idIndex.get(id) })
})
// ✅ 允许:看板数据缩放 → 3D 对象显隐(跟随,不回灌)
chart.on('datazoom', (params: any) => {
bus.emit('chart:filter', params)
})
// ❌ 禁止:3D 收到 chart:filter 后再触发 chart 的事件 → 成环
注意事项:事件总线是唯一联动通道,组件间禁止直接 import 互调;每条联动链路写注释标明"主导方 → 跟随方";Review 时重点检查是否有环。
四、AI 助手接入(实战完整版)
4.1 为什么用 SSE 流式(而不是等完整响应)
为什么这么用:园区 AI 助手是 C 端对话场景,必须打字机式流式输出,等完整生成再返回(10-30s 无反馈)用户直接关页面。
用 fetch + ReadableStream 而不用 EventSource 的三个原因:
- EventSource 只支持 GET,大模型接口要 POST 带上下文
- fetch 能拿到中途 HTTP 错误(401/429/500),EventSource 只有 error 事件没状态码
- fetch 能通过 AbortSignal 主动中断(用户点"停止")
ts
// src/ai/stream.ts ------ SSE 流式完整实现
export async function streamChat(
prompt: string,
onDelta: (text: string) => void,
signal?: AbortSignal
) {
const res = await fetch('/api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal,
})
if (!res.ok || !res.body) throw new Error(`AI 接口失败: ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true }) // stream:true 处理多字节边界
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // 最后一段可能不完整,留到下一帧
for (const line of lines) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trim()
if (data === '[DONE]') return
try {
onDelta(JSON.parse(data).content ?? '')
} catch { /* 跳过不完整帧 */ }
}
}
}
注意事项:
- 流式必须容错:网络抖动会出半截帧,
try/catch跳过,buffer机制兜底 - 网关/代理可能切断长连接------代理超时调大(≥60s),前端必须有 catch 兜底
- 中文多字节在分块边界可能被切断,
decoder.decode(value, { stream: true })必须开
4.2 AI 面板完整实现(为什么必须降级)
为什么这么用 :AI 是外部服务,会挂、会限流、要花钱。演示现场 AI 罢工不能白屏------降级话术 + 本地缓存是底线。
vue
<!-- src/views/ai/AiPanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { streamChat } from '../../ai/stream'
const input = ref('')
const reply = ref('')
const streaming = ref(false)
let ctrl: AbortController | null = null
async function send() {
const q = input.value.trim()
if (!q || streaming.value) return
reply.value = ''
streaming.value = true
ctrl = new AbortController()
try {
await streamChat(q, (t) => { reply.value += t }, ctrl.signal)
} catch {
reply.value = 'AI 服务暂时不可用,请稍后再试。' // 降级话术
} finally {
streaming.value = false
}
}
function stop() { ctrl?.abort(); streaming.value = false }
</script>
<template>
<div class="ai-panel">
<div class="ai-reply">{{ reply }}</div> <!-- 文本渲染,天然转义 -->
<div class="ai-input">
<input v-model="input" placeholder="问点什么...(例:今天园区能耗多少)" @keyup.enter="send" />
<button :disabled="streaming" @click="send">发送</button>
<button v-if="streaming" @click="stop">停止</button>
</div>
</div>
</template>
注意事项(安全三条):
- AI 输出永远当不可信输入 :默认
{``{ }}文本渲染(天然转义),不要直接v-html;确需富文本(代码块/加粗)用白名单 markdown 渲染器 - 限流保护:热门问题本地缓存(Map 超 200 条清空),重复问题秒回不耗 token;连续请求加 1s 最小间隔
- 首字超 15s 主动中断 + 降级话术,别让用户干等
4.3 AI + 图表 / AI + 3D(结构化指令协议)
为什么这么用 :AI 的自然语言能力 + 结构化指令 = "问一句,图表出数据、3D 转视角"。关键设计:AI 返回结构化指令(action + params),前端白名单执行,而不是让 AI 直接操作图表(不可控、不安全)。
action 协议(写进 ai/actions.ts):
ts
// 白名单:AI 只能触发这里列出的动作
const ACTION_MAP = {
'chart:switch': (params: { id: string }) => bus.emit('chart:switch', params),
'chart:highlight': (params: { id: string }) => bus.emit('chart:highlight', params),
'scene:camera': (params: { target: string }) => bus.emit('scene:camera', params),
'scene:device': (params: { id: string }) => bus.emit('scene:device', params),
} as const
type ActionKey = keyof typeof ACTION_MAP
// AI 返回示例:
// { "action": "chart:switch", "params": { "id": "energy" } }
// { "action": "scene:camera", "params": { "target": "bld-01" } }
export function execAiAction(payload: { action: string; params?: Record<string, any> }) {
const fn = ACTION_MAP[payload.action as ActionKey]
if (!fn) {
console.warn('[ai] 未注册动作,已拦截:', payload.action)
return
}
fn(payload.params ?? {})
}
注意事项:
- action 必须白名单校验(只允许已定义动作),防提示词注入让 AI 触发未授权操作
- 3D 相机聚焦用动画过渡(见 2.6 focusCamera),别瞬移
- AI 返回的结构化指令要走
JSON.parse+ 容错;解析失败降级为纯文本回复
五、数据链路与实时性(实战完整版)
5.1 消息协议设计(为什么必须标准消息)
为什么这么用 :园区传感器/门禁/能耗/摄像头是持续推送,设备协议五花八门。接入层统一翻译成标准消息 { type, data, ts },展示层永远只认一种格式,换设备不改业务代码。
json
// 标准消息格式(写入团队协议文档)
{ "type": "device/status", "data": { "id": "bld-01", "status": "alarm" }, "ts": 1729750000000 }
{ "type": "energy/real", "data": { "kwh": 123.4 }, "ts": 1729750000000 }
{ "type": "vitals/alert", "data": { "id": "sensor-12", "level": "high" }, "ts": 1729750000000 }
协议三条规定:
type用"域/动作"命名(device/status),禁止裸字符串data必须是 JSON 对象,禁止数组裸传(类型约束没法做)- 前端维护一份
EventType枚举,bus.on(type)只接受枚举成员,拼错编译期就报
5.2 WebSocket 封装完整实现(心跳 + 重连 + 多订阅)
为什么这么用:网关会空闲切断连接(默认 60s 无数据就断);网络抖动会断开。没有心跳和重连,大屏跑一会儿就静默断线,数据全停、图表冻住,人还看不出来。
ts
// src/data/ws.ts ------ 完整实现
export class RealtimeSocket {
private ws: WebSocket | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectCount = 0
private handlers = new Map<string, Set<(data: any) => void>>()
private manualClosed = false
private connectTimer: ReturnType<typeof setTimeout> | null = null
constructor(private url: string) {}
connect() {
this.manualClosed = false
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectCount = 0
this.startHeartbeat()
bus.emit('ws:status', 'connected') // 连接状态可视化
}
this.ws.onmessage = (e) => this.dispatch(e.data)
this.ws.onclose = () => this.handleClose()
this.ws.onerror = () => this.ws?.close()
}
private startHeartbeat() {
// 30s 心跳:防网关空闲切断
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send('ping') // 业务帧心跳,不走协议层
}
}, 30_000)
}
private handleClose() {
bus.emit('ws:status', 'reconnecting')
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
if (this.manualClosed) return
// 指数退避重连:1s→2s→4s→8s...封顶 30s,避免几十客户端同时重连打爆网关
const delay = Math.min(1000 * 2 ** this.reconnectCount, 30_000)
this.reconnectCount++
this.connectTimer = setTimeout(() => this.connect(), delay)
}
// 多类型订阅:一个连接分发多个业务
on(type: string, fn: (data: any) => void) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set())
this.handlers.get(type)!.add(fn)
}
off(type: string, fn: (data: any) => void) {
this.handlers.get(type)?.delete(fn)
}
private dispatch(raw: string) {
try {
const msg = JSON.parse(raw)
if (msg.type === 'pong') return // 服务端心跳应答
this.handlers.get(msg.type)?.forEach((fn) => fn(msg.data))
} catch { /* 忽略非 JSON 帧 */ }
}
close() {
this.manualClosed = true
if (this.connectTimer) clearTimeout(this.connectTimer)
this.ws?.close()
}
}
注意事项:
- 重连必须指数退避 + 封顶------同时断网几十个客户端一起重连,网关直接雪崩
- 连接状态可视化(已连接/重连中/离线)放看板角落------调试全靠它,别省
- 心跳业务帧(ping/pong)不占协议 type,展示层忽略
- 组件卸载要
off解绑 +close(),否则监听器泄漏
5.3 mock 降级系统(为什么必须有 + 完整实现)
为什么这么用 :现场断网、设备离线是常态。没有本地数据源,演示就是事故。mock 与真实数据源必须同构(同一套事件),切换只改配置不碰业务代码。
ts
// src/data/mock.ts ------ 一键切换
export function createMockSource(handle: SceneHandle) {
const timer = setInterval(() => {
bus.emit('device/status', { id: 'bld-01', status: Math.random() > 0.8 ? 'alarm' : 'online' })
bus.emit('energy/real', { kwh: 100 + Math.random() * 50 })
bus.emit('person/flow', { count: 1200 + Math.random() * 300 })
}, 1000)
return () => clearInterval(timer)
}
// 入口处一行切换:
// const source = USE_MOCK
// ? createMockSource(handle)
// : new RealtimeSocket(WS_URL)
// const stopMock = typeof source === 'function' ? source : () => {}
注意事项 :mock 必须覆盖异常场景(掉线、告警、超阈值),别只 mock 顺利数据------否则真出问题没演练过;演示前必做"断网演练"(直接拔网线跑一遍);mock 数据要有随机性 + 周期性(模拟真实趋势),不要纯随机乱跳。
5.4 后端聚合与接口规范(前端提需求模板)
为什么这么用:大屏数据接口的瓶颈 90% 在后端------全量下发 5 万点,前端怎么优化都白搭。前端需要把接口需求说清楚:
【大数据接口需求模板】
接口:GET /api/energy
参数:range=1d|1w|1m、bucket=1m|1h|1d、buildingId(可选)
返回:{ "ts": 1729750000000, "avg": 12.3, "min": 10.1, "max": 15.2 }[]
约束:单次返回点数 ≤ 5000;超范围必须分页或加重采样
场景:默认 1440 点(1d/1m);zoom 到小时级再按分钟返
注意事项:验收时核对接口返回点数是否真的降了(F12 看响应体);后端加不了聚合,前端至少要求"按需分页",禁止无限全量。
六、性能治理与验收交付
6.1 性能基线表(为什么必须量化)
为什么这么用:"流畅"不可验收,数字才可以。没有基线,优化不可验证、回归不可发现。这是项目验收唯一的争议终结者。
| 指标 | 工具 | 预算 | 测法 |
|---|---|---|---|
| DrawCall | renderer.info.render.calls |
<100 理想,>500 必崩 | 场景加载后控制台打印 |
| 内存 | Chrome Memory 快照 | 切页后回落基线 | 连续开关页 10 次对比 |
| 首屏 | Performance 面板 | 中端机 ≤3s | Lighthouse + 真机 |
| 帧率 | 真机 Performance | 低端机 ≥30fps | 真机测试,非模拟器 |
| 数据延迟 | 打点(设备→屏幕) | ≤2s | 埋点统计 |
| bundle 体积 | CI 脚本 | JS ≤500KB/入口 | 见 6.2 |
6.2 CI:bundle 体积检查(防"加个依赖体积翻倍")
为什么这么用:Three.js 全量引入和按需引入体积差数倍,ECharts 自定义构建可砍掉未用图表类型。人工记不住,交给 CI 自动拦。
js
// scripts/check-bundle-size.mjs
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
const dist = 'dist/assets'
const LIMIT = 500 * 1024 // 500KB,按项目实际调
let failed = false
for (const file of readdirSync(dist)) {
if (!file.endsWith('.js')) continue
const size = readFileSync(join(dist, file)).length
if (size > LIMIT) {
console.error(`[bundle] ${file} ${(size / 1024).toFixed(0)}KB 超阈值`)
failed = true
}
}
if (failed) process.exit(1)
console.log('[bundle] 体积检查通过')
配套优化(砍体积三板斧):
ts
// ① ECharts 按需注册(用啥注册啥,别 import * as echarts)
import * as echarts from 'echarts/core'
import { LineChart, PieChart, GaugeChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([LineChart, PieChart, GaugeChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
// ② Three.js 按模块引入(别 import * as THREE 全量)
import { WebGLRenderer, Scene, PerspectiveCamera } from 'three'
// ③ 构建 gzip + 拆 chunk(动态加载的页面单独分包)
6.3 发版自检清单(上线前逐项过)
- 图表/3D 资源切页后正确释放(Memory 快照对比)
- DrawCall 在预算内(renderer.info 打印确认)
- 后端聚合生效(接口响应点数已降)
- 高频数据节流生效(无逐帧重绘)
- WS 断线重连正常(断网 10s 内恢复 + 状态角标正确)
- mock 降级演练通过(断网大屏不白板)
- AI 降级验证过(接口失败显示兜底话术)
- 低端真机帧率 ≥30fps
- bundle 体积未超限(CI 通过)
- 验收数字与需求方书面确认(防扯皮)
6.4 团队 Review 清单(可视化专项)
| # | 检查项 | 怎么查 |
|---|---|---|
| 1 | ECharts 是否有 dispose cleanup | 搜业务是否直接 echarts.init;onUnmounted 是否 dispose |
| 2 | resize 防抖、监听器解绑 | 封装层实现;addEventListener 是否配对 remove |
| 3 | Three 资源全释放 | disposeObject 是否遍历纹理 + close ImageBitmap |
| 4 | DrawCall 在预算内 | 加载后打印 renderer.info.render.calls |
| 5 | 大数据采样开启 | large + sampling + animation:false 齐全 |
| 6 | 联动无循环 | 事件总线 on/emit 是否成环 |
| 7 | 数据源可切换 | mock/ws 同构,配置一处切换 |
| 8 | AI 降级存在 | 接口失败有兜底话术,不白屏 |
| 9 | 版本一致性 | 锁版本、addons 路径正确 |
| 10 | AI 代码标记 | commit 是否 ai-assisted,重点核对 dispose/优化配置 |
6.5 交付与运维(大屏上线后不是结束)
为什么这么用:大屏上线后跑在展厅/指挥中心,没人盯着刷新,稳定性靠自动手段。
bash
# 交付物清单(随源码一起给)
├── README.md # 版本宪法 + 启动 + 环境变量
├── docs/
│ ├── 架构图.md # 四层架构 + 依赖规则
│ ├── 消息协议.md # type 枚举 + payload schema
│ └── 性能基线.md # 每次优化记录数字
└── scripts/ # copy-three-libs / check-bundle-size
运维三件事:
- 前端埋点:页面 JS 错误(window.onerror)、WS 断线次数、AI 失败率------错误上报表
- 长稳测试:上线前跑 8 小时连续运行脚本,盯内存曲线
- 看板自愈:WS 断线自动重连 + 数据陈旧告警(超 30s 无数据,看板角标变黄提醒)
当然,技术没有绝对完美,如果有更好的思路或方案,非常欢迎大家一起交流探讨。