智慧园区数字孪生大屏总结

【前端实战笔记】智慧园区数字孪生大屏总记录:从技术选型到性能治理的完整落地记录

个人真实开发总结

做数字孪生大屏的你,是否也经历过这样的绝望:

  • 需求方说"要大屏、要炫、要 3D",但没人说得清到底要什么
  • 大屏跑 2 小时浏览器直接卡死,内存只涨不跌,找不到凶手
  • 5 万点折线拖一下卡半天,tooltip 跟手都难
  • 3D 场景一上量就掉帧,低端设备直接黑脸
  • AI 面板接入后,现场演示动不动就白屏,讲不完一段话

如果你正在做、或即将接手一个"3D + 大屏 + AI"类项目,这篇文章就是为你写的。我用一个真实的智慧园区数字孪生大屏项目为主线,完整记录从需求拆解、技术选型、架构落地,到 3D 场景、统计看板、AI 接入、实时数据、性能治理、验收交付的全过程。

这篇文章你能拿到什么:

  1. 技术选型会的完整决策过程------当时为什么选 ECharts + Three.js,而不是 AntV / D3 / Babylon.js
  2. 每一层架构的完整可运行代码 + 参数为什么这么配
  3. 我在项目里真实踩过、并排查解决的 20+ 个坑,每个都带"现象 → 根因 → 解法 → 预防"
  4. 一套可以直接抄走的性能基线表、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 }
}

注意事项:

  1. setPixelRatio 必须限 2x------4K 屏不限就是 4 倍显存
  2. powerPreference: 'high-performance' 明确要独显,避免浏览器默认集显
  3. 园区大场景不开阴影(shadowMap 开销巨大);真需要阴影只给近景重点对象开
  4. 卸载时必须 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 还在显存里。大屏连续运行,内存只涨不跌,最后浏览器卡死、页面变白。

当时排查的过程(还原现场):

  1. 现象:大屏跑 2 小时后,切页越来越卡,最终白屏
  2. 第一步:Chrome DevTools → Memory → 拍两个堆快照对比,发现 WebGLBuffer/ArrayBuffer 数量持续增长,确认是 GPU 资源泄漏
  3. 第二步:逐个模块卸载后拍快照,二分定位到"设备详情弹窗"------每次开关弹窗都泄漏一组几何体+纹理
  4. 第三步:检查弹窗组件,发现只调了 scene.remove(mesh),没调 dispose
  5. 修复:写统一释放工具,强制 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 生成释放代码必查三点):

  1. 材质可能是数组 (多材质 mesh),要 Array.isArray 兜底
  2. 纹理不止 map,normalMap/roughnessMap/emissiveMap/aoMap 全要释放------用 for...in 遍历材质属性,而不是手写每个属性名
  3. 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 倍。

注意事项:

  1. DrawCall 预算是第一约束 :renderer.info.render.calls >100 需要干预,>500 低端机必崩。场景加载后把它打印到控制台,养成习惯
  2. 只有同几何体 + 同材质才能合并实例------树有 3 种颜色就要 3 个 InstancedMesh
  3. 每帧改实例矩阵要 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)
}

注意事项:

  1. 闪烁/动画用"注册表 + 定时器 + 按需渲染",别在常驻 RAF 里判断------静态场景常驻渲染是移动端发热主因
  2. Raycaster 的 intersectObjects 第二参传 false(不递归),命中列表必须是已筛选的 Mesh 列表,否则性能崩
  3. 交互走事件总线,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() })))
}

注意事项:

  1. dispose 是硬红线:不销毁 = 内存泄漏,大屏连续运行几小时直接卡死
  2. resize 必须防抖;容器 display:none 时 resize() 读到 0 宽高会画崩------页面级切换用 v-if(隐藏即销毁重建),不要 v-show
  3. 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 的三个原因:

  1. EventSource 只支持 GET,大模型接口要 POST 带上下文
  2. fetch 能拿到中途 HTTP 错误(401/429/500),EventSource 只有 error 事件没状态码
  3. 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 { /* 跳过不完整帧 */ }
    }
  }
}

注意事项:

  1. 流式必须容错:网络抖动会出半截帧,try/catch 跳过,buffer 机制兜底
  2. 网关/代理可能切断长连接------代理超时调大(≥60s),前端必须有 catch 兜底
  3. 中文多字节在分块边界可能被切断,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>

注意事项(安全三条):

  1. AI 输出永远当不可信输入 :默认 {``{ }} 文本渲染(天然转义),不要直接 v-html;确需富文本(代码块/加粗)用白名单 markdown 渲染器
  2. 限流保护:热门问题本地缓存(Map 超 200 条清空),重复问题秒回不耗 token;连续请求加 1s 最小间隔
  3. 首字超 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 ?? {})
}

注意事项:

  1. action 必须白名单校验(只允许已定义动作),防提示词注入让 AI 触发未授权操作
  2. 3D 相机聚焦用动画过渡(见 2.6 focusCamera),别瞬移
  3. 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 }

协议三条规定:

  1. type 用"域/动作"命名(device/status),禁止裸字符串
  2. data 必须是 JSON 对象,禁止数组裸传(类型约束没法做)
  3. 前端维护一份 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()
  }
}

注意事项:

  1. 重连必须指数退避 + 封顶------同时断网几十个客户端一起重连,网关直接雪崩
  2. 连接状态可视化(已连接/重连中/离线)放看板角落------调试全靠它,别省
  3. 心跳业务帧(ping/pong)不占协议 type,展示层忽略
  4. 组件卸载要 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

运维三件事:

  1. 前端埋点:页面 JS 错误(window.onerror)、WS 断线次数、AI 失败率------错误上报表
  2. 长稳测试:上线前跑 8 小时连续运行脚本,盯内存曲线
  3. 看板自愈:WS 断线自动重连 + 数据陈旧告警(超 30s 无数据,看板角标变黄提醒)

当然,技术没有绝对完美,如果有更好的思路或方案,非常欢迎大家一起交流探讨。

相关推荐
Highcharts.js20 小时前
2026年,AI正在重写技术:“工具思维”正在失效?
人工智能·echarts·客户端·highcharts·可视化图表·企业级图表
paopaokaka_luck2 天前
宠物门店经营管理系统(销售与库存联动、进货自动记账、 退换与退款关联、员工个人业绩、ECharts 经营分析、滞销识别与客户分层、宠物档案年龄计算)
前端·javascript·spring boot·mysql·echarts·mybatis·1024程序员节
paopaokaka_luck2 天前
心理咨询管理系统(WebSocket实时沟通、基于用户的协同过滤、 ECharts多维分析、 预约业务流程、预约校验与定时处理)
java·vue.js·spring boot·mysql·spring·echarts·1024程序员节
Shimeri2 天前
越卷越像:我用遗传算法做了个"内卷"实验
算法·three.js·数据可视化·遗传算法·进化算法
Sakura_j_h_2 天前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild
paopaokaka_luck3 天前
智慧电玩店管理系统(空闲机台随机分配、游玩时长概率模拟、积分自动结算、积分兑换闭环、 ECharts经营分析、 腾讯地图选址)
java·人工智能·mysql·echarts·推荐算法
用户621007587023 天前
Web 3D 开发实践:BIM+GIS 融合需要解决哪些技术问题?
前端·three.js
总要学点什么3 天前
第75篇:右键菜单控件——把一张会做事的菜单钉在鼠标落下的那点
前端·信息可视化·echarts·cesium·空间分析
paopaokaka_luck3 天前
美食推荐系统(协同过滤算法推荐、AI助手、HanLP中文分词、 ECharts可视化分析、菜谱学习与话题交流)
java·spring boot·mysql·tomcat·echarts