这套行人摔倒检测系统的前端,我前前后后写了两个版本。第一版主要拿来给算法团队做效果演示,逻辑很简单:一个页面播放视频流,一个表格展示实时告警。但项目进入试点阶段后,场景完全变了——多路摄像头并发、值班人员要第一时间看到告警现场、养护人员还要在手机上处理误报,第一版的代码扛不住这些需求,于是有了第二版的重构。这篇文档记录的就是第二版前端在架构、实时链路、画布渲染、告警处置闭环上的核心设计,以及我在实际联调中踩过的坑。如果你正在做AI应用、视频监控或IoT类前端项目,这篇内容应该能给你一些可落地的参考。
1. 前端架构的第二轮演进:从“能跑就行”到“能扛得住”
1.1 第一版前端留下的作业
第一版前端不是不能跑,而是它的目标决定了它的天花板。当时算法团队刚训练出摔倒检测模型,需要快速验证识别效果,于是前端做成一个演示台:左侧是摄像头实时画面,右侧是近200条告警记录的滚动表格。每一帧检测结果直接覆盖在视频流上,画完即弃,不做任何缓存和状态管理。
进入试点之后,问题暴露得很直接。
告警数据用轮询拉取,每2秒请求一次,值班人员看到的结果能延迟到好几秒,对于摔倒这种需要秒级响应的场景不够用。多路摄像头通过切换视频URL实现,每次切换都要等重新建流,而且A摄像头的检测框偶尔会残留在B摄像头的画面上。代码层面所有逻辑挤在一个大组件里,数据流基本靠组件内共享变量,后期加一个“误报标记”功能,我改了三个文件才把状态接顺。
只能说,第一版是给算法团队看的,第二版是给真实用户用的。
1.2 第二版的模块划分与职责边界
第二版重构时,我按业务模块拆分了前端,没有做微前端,但目录结构完全按模块隔离。五个核心模块分别为告警中心、实时画布、事件回放、设备管理、统计报表。
| 模块 | 核心职责 | 使用者 |
|---|---|---|
| 告警中心 | 实时告警列表、告警详情、人工确认/误报标记 | 值班人员 |
| 实时画布 | 摄像头视频流 + AI检测结果叠加展示 | 值班人员 |
| 事件回放 | 按时间段检索历史告警,回放视频片段 | 管理人员 |
| 设备管理 | 摄像头增删改查、在线状态、算法参数配置 | 运维人员 |
| 统计报表 | 摔倒事件次数、确认率、误报率等指标 | 管理层 |
这个划分不是凭空来的,它对应的是不同角色在系统中的真实使用路径。值班人员的工作流是“看到告警——看现场——确认或标记误报”,所以告警中心和实时画布必须轻快、稳定;管理人员更关心“某个时段发生了什么”,所以事件回放要支持精确检索;运维要处理的是设备层面的异常,所以设备管理要独立出来。
每个模块之间通过一个统一的告警事件对象串联,数据流方向是从平台层到展示层单向流动,跨模块的状态变更通过事件总线解耦。这样改完之后,各模块的开发可以并行推进,互不干扰。
1.3 一个贯穿全局的核心抽象:告警事件
整个前端系统里,最核心的数据结构是 AlertEvent,它是所有模块衔接的公共语言。这个抽象不是一开始就设计好的,而是在重构过程中逐步收敛出来的。
typescript复制interface AlertEvent {
id: string;
cameraId: string;
cameraName: string;
ts: number; // 事件发生时间(毫秒时间戳)
type: 'fall' | 'lingering' | 'intrusion';
confidence: number; // 算法置信度
status: 'pending' | 'confirmed' | 'false_alarm' | 'handled';
thumbnail?: string; // 事件关键帧缩略图
frame?: {
width: number;
height: number;
boxes: Array<{
label: string;
score: number;
// 以下坐标均为归一化坐标(0~1)
x: number;
y: number;
w: number;
h: number;
}>;
keypoints?: Array<{
id: number;
x: number;
y: number;
score: number;
}>;
};
}
把坐标全部用归一化方式存储,是前端项目中一个很重要的决定。因为摄像头分辨率不同、视频流展示尺寸不同、Canvas画布尺寸不同,如果后端直接给像素坐标,前端在画面变化时必须跟着换算,非常容易出错。归一化之后,前端只需要在渲染时乘以画布的实际宽高即可。
frame 里同时包含检测框和关键点,这背后对应不同的展示需求:告警列表只需要缩略图和置信度,实时画布需要完整绘制检测框和骨架,事件回放需要还原当时的检测状态。一个事件对象覆盖了所有场景,避免为不同页面定义多个割裂的数据结构。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 告警中心的实时链路:WebSocket、消息协议与渲染管线
2.1 先和后端对齐消息协议
告警推送这块,前端和后端用WebSocket通信,但老话说得好——协议不先定好,联调就等着吵。第二版刚启动时,我第一件事就是和后端把消息协议定下来,毕竟协议是整个实时链路的根基。
我们用的协议格式如下:
json复制{
"type": "alert",
"payload": {
"id": "alert_20260612_001",
"cameraId": "cam_03",
"cameraName": "3号楼走廊",
"ts": 1781942400000,
"confidence": 0.92,
"status": "pending",
"frame": {
"width": 1920,
"height": 1080,
"boxes": [
{
"label": "person",
"score": 0.95,
"x": 0.35,
"y": 0.42,
"w": 0.18,
"h": 0.45
}
],
"keypoints": [
{ "id": 0, "x": 0.38, "y": 0.45, "score": 0.98 },
{ "id": 1, "x": 0.40, "y": 0.60, "score": 0.96 }
]
}
},
"ts": 1781942400123
}
type 字段除了 alert 之外,还有 heartbeat 和 offline 消息。heartbeat 用于链路探活,offline 用来通知前端某个摄像头断流或算法服务下线。协议里所有时间都统一用毫秒时间戳,避免前后端时区不同造成的解析偏差。
这类协议文档,我建议在项目启动时单独建一份,写清楚每个字段的含义、取值范围和示例,前后端各持一份。不然联调阶段你问后端“这个字段是什么意思”,后端要翻代码,你也要翻代码,两边都浪费时间。
2.2 心跳、断线重连与消息补偿
WebSocket连接在真实网络环境里没那么靠谱。酒店网关、养老院的老旧路由器、4G/5G切换,任何一个环节抖一下连接就可能断开。所以心跳和重连机制必须前端自己搞定,不能指望后端来保障。
我们实现的逻辑是这样的:
typescript复制let retryCount = 0;
let heartbeatTimer: number | null = null;
let ws: WebSocket | null = null;
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
retryCount = 0;
// 每30秒发送一次心跳
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'heartbeat' }));
}
}, 30000);
};
ws.onclose = () => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
// 指数退避重连:1s, 2s, 4s, 8s ... 最大30s
const delay = Math.min(30000, 1000 * Math.pow(2, retryCount));
retryCount++;
setTimeout(connect, delay);
};
ws.onmessage = (event) => {
handleMessage(JSON.parse(event.data));
};
}
指数退避是这里的关键。如果连接断开后立即疯狂重连,后端服务恢复前会把前端自己的事件循环打满,而且会让后端雪上加霜。用指数退避,重连间隔逐步拉大,等后端恢复时,最多也就30秒重连一次,压力可控。
重连成功之后还有个重要的动作——消息补偿。WebSocket断线期间,告警消息已经推过了,前端没收到就会丢数据。所以前端重连后需要主动向服务端要增量数据:
code复制GET /api/alerts/since?cursor=lastReceivedId&cameraId=全部
前端记录最后一条已接收到的事件ID,断线重连后用这个ID去拉取漏掉的数据,回来后继续从最新位置接收推送。这样才能保证告警中心不丢事件。
2.3 告警列表渲染:不能只靠v-for
告警列表的核心痛点是数据量大且更新频繁。试点部署了20路摄像头,高峰时段一天能产生上千条告警,其中大部分是算法误报。如果把这些数据一次性渲染进DOM,页面必卡,这是个简单的性能常识。
做了两层优化。
第一层是虚拟滚动。只渲染可视区域的告警行,而不是把整个数组映射成DOM。我们用了一个轻量级方案:外层容器固定高度,滚动时计算当前可视区对应的数据切片,设置上下padding占位,保证滚动条高度是正确的。这个方案不依赖第三方库,几十行代码就能实现,效果立竿见影。
第二层是批量更新。WebSocket消息到达的瞬时,如果来一条消息更新一次响应式数据,Vue的依赖追踪会频繁触发组件重渲染。而告警消息往往是突发性的,比如一个区域内多人发生碰撞,一秒内可能推送多条。我们用一个队列收集消息,每100毫秒或最多50条合并成一批,再统一更新视图。
注意:批量更新一定要用
requestAnimationFrame或者setTimeout做异步合并,不能在WebSocket回调里直接同步处理,否则渲染调度会被高频消息打乱。
未读与已读状态放在一个独立的 Map<alertId, status> 里管理,不直接改事件对象本身。这样做的好处是列表排序、筛选时不需要反复修改原始数据,而且Map的读写性能比数组的 find 高得多。
3. 实时画布:视频流与AI识别结果的像素级协同
3.1 两套坐标系的故事
实时画布是整个前端最难写的部分,难在它同时要处理两套坐标系:视频流有自己的显示坐标,AI识别结果给的是归一化坐标。
举个例子。算法后端检测到画面中人物摔倒,给出检测框的归一化坐标是 x: 0.35, y: 0.42, w: 0.18, h: 0.45,意思是这个框在画面水平方向从35%开始、宽度占18%,垂直方向从42%开始、高度占45%。前端在Canvas上绘制时,要根据Canvas的实际尺寸把它们映射成像素。
typescript复制const canvasX = box.x * canvasWidth;
const canvasY = box.y * canvasHeight;
const canvasW = box.w * canvasWidth;
const canvasH = box.h * canvasHeight;
ctx.strokeStyle = '#ff4d4f';
ctx.lineWidth = 2;
ctx.strokeRect(canvasX, canvasY, canvasW, canvasH);
关键点坐标同理,直接乘以画布宽高就能得到绘制位置。这里有个很容易被忽略的细节:Canvas的尺寸变化。视频流的分辨率是固定的,但前端画布的尺寸会根据浏览器窗口自适应。当画布尺寸变化时,必须重新计算所有点的像素位置,否则检测框会和人物位置脱节。我们监听 window.resize 事件,触发后强制触发一次重绘。
3.2 骨架关键点与骨骼连线绘制
摔倒检测的算法输出通常会包含人体关键点,比如头部、肩膀、手肘、手腕、髋部、膝盖、脚踝等。把这些点连成线,就能在画布上呈现一个人体骨架。绘制骨架并不复杂,复杂的是点的连接关系要正确。
COCO格式的17个关键点有固定的编号顺序:0鼻子、1左眼、2右眼、3左耳、4右耳、5左肩、6右肩、7左肘、8右肘、9左腕、10右腕、11左髋、12右髋、13左膝、14右膝、15左踝、16右踝。骨骼连线就是按人体的自然结构定义好相邻关系:
typescript复制const SKELETON_LINES = [
[5, 6], // 肩到肩
[5, 7], [7, 9], // 左臂
[6, 8], [8, 10], // 右臂
[5, 11], [6, 12], // 肩到髋
[11, 12], // 髋到髋
[11, 13], [13, 15], // 左腿
[12, 14], [14, 16] // 右腿
];
SKELETON_LINES.forEach(([startId, endId]) => {
const start = keypoints.find((k) => k.id === startId);
const end = keypoints.find((k) => k.id === endId);
if (start && end && start.score > 0.3 && end.score > 0.3) {
ctx.beginPath();
ctx.moveTo(start.x * canvasWidth, start.y * canvasHeight);
ctx.lineTo(end.x * canvasWidth, end.y * canvasHeight);
ctx.strokeStyle = '#00e676';
ctx.lineWidth = 2;
ctx.stroke();
}
});
score > 0.3 这个过滤条件很重要。算法在某些遮挡场景下会输出低置信度的关键点,如果不过滤,会把骨架线画得歪歪扭扭,影响值班人员的判断。
3.3 多路摄像头切换与画布防串台
实时画布默认展示的场景是“当前选中的摄像头画面”。切换摄像头是高频操作,而切换过程中最容易出现的问题就是“串台”——A摄像头的检测框画到了B摄像头的画面上。
串台的根因是异步竞态。当用户快速切换摄像头时,WebSocket仍然在推送每个摄像头的检测消息。如果不加保护,画布渲染函数可能把上一个摄像头的消息绘制到新摄像头界面上。
解决思路其实很朴素——给每条消息和当前选中状态绑定:
typescript复制let activeCameraId = 'cam_01';
function handleDetectionMessage(msg) {
if (msg.cameraId !== activeCameraId) {
return; // 不是当前摄像头的消息,直接丢弃,不绘制
}
renderFrame(msg);
}
function switchCamera(cameraId) {
activeCameraId = cameraId;
clearCanvas(); // 清空画布,重置所有绘制状态
}
这个判断要在消息处理的最前面做,越早丢弃,越少浪费渲染资源。切换摄像头时清空画布,也是防止旧画面残留的直接方法。
3.4 画布性能:从一帧一卡到稳定30帧
试点初期,实时画布在部分老旧电脑上出现了明显的掉帧。检查发现,问题出在渲染策略上:每收到一条消息就清空整个Canvas重绘,而且多个检测框、多套骨架全部一起画,一帧的绘制耗时能超过100毫秒。
优化方案有几条,按优先级排序:
- 合并重绘。WebSocket消息到达后先入队,在
requestAnimationFrame回调里统一处理,而不是每来一条消息就画一次。 - 局部重绘。背景视频画面是不动的,只有检测框和关键点在变化。把Canvas的图层拆成两层:底层是视频流,负责播放;上层是检测结果,负责绘制。每次只清空上层,不动底层。
- 离屏Canvas。当某一路摄像头出现大量检测目标时,检测结果可以先画到离屏Canvas上,再一次性绘制到主Canvas上,减少绘制上下文切换的开销。
注意:Canvas的
clearRect操作虽然简单,但频繁调用仍然有额外开销。可以只在检测框位置附近做区域清除,减少重绘面积。
优化之后,一次画布重绘的耗时基本稳定在20~30毫秒内,在常规办公电脑上可以达到30帧左右。对于视频监控场景,这个帧率已经够值班人员流畅观察了。
4. 告警处置闭环:从弹窗提醒到人工确认、误报标记与升级推送
4.1 为什么必须做人工确认环节
刚开始做这个功能时,产品经理提了一个需求:系统检测到摔倒后,自动通知护理人员上门查看。我一听就觉得不太对劲——算法模型的误报率虽然在持续优化,但在真实场景中依然不低。光线变化、宠物入镜、窗帘飘动、轮椅转弯,都可能触发假阳性。如果系统直接“自动通知”,一天下来,护理人员的手机就会被无意义的推送塞满,真正的紧急事件反而被淹没。
所以告警处置闭环里设计了人工确认环节:系统发出告警后,前端弹出提示,值班人员在10秒内查看现场画面,确认是真实摔倒就点击“确认”,误报则点击“误报标记”。确认后的告警才进入后续处理流程,误报数据则回流到算法平台的标注数据集里,用于后续模型优化。
前端交互上,我做了精简设计:右上角弹出一个占位不大的卡片,显示摄像头名称、时间、检测框缩略图,以及两个按钮“确认处置”和“标记误报”。卡片本身不阻塞其他操作,值班人员可以先看实时画面,再决定怎么处理。卡片在30秒内未操作自动收回,但会在告警中心里保持“待处理”状态,避免漏掉。
4.2 前端侧的误报抑制策略
人工确认环节虽然必要,但也不能让值班人员一天点几百次鼠标。我们从前端角度做了几层误报抑制策略,把需要人工确认的告警数量降了下来。
- 重复告警折叠。同一摄像头、同一区域(检测框中心点距离小于阈值)在10秒内重复触发的摔倒告警,前端只在列表中合并为一条,状态显示“N次连续触发”。这样一次摔倒引发的多次算法响应不会刷屏。
- 低置信度延迟提醒。置信度低于0.6的告警不立即弹窗,只在告警中心列表中展示,值班人员有空时再查看。0.6~0.85的告警弹弱提示,超过0.85才弹强提醒。这个阈值是在试点数据上不断调整出来的。
- 二次校验。前端在收到告警消息后,会立即从后端拉取该摄像头前后各2秒的视频片段,确认在检测框位置是否真的有人体轮廓。如果视频片段里没有对应目标,这条告警自动标记为疑似误报。
这些策略并不复杂,但组合起来效果非常明显。试点期间,值班人员每天需要人工确认的告警数从最初的上百条降到了二三十条,误报带来的疲劳感大幅下降。
4.3 告警升级链路:超时未处理的兜底方案
人工确认有个潜在问题:如果值班人员正好离开工位,或者晚上值班室没人,告警就一直挂在“待处理”状态,关键事件被延误。
我们的方案是给告警设置升级链路。前端每小时扫描一次“待处理”且创建时间超过15分钟的告警,自动将状态从待处理更新为待升级,并推送给管理人员的移动端。如果超过30分钟仍未处理,告警再次升级到项目经理和安防负责人的终端。这个升级规则写在前端定时任务里,后台管理系统也有同类逻辑做兜底。
前端实现升级链路,难点在于避免误升级。比如某条告警已经通过其他渠道(比如电话)处理了,但值班人员忘了在系统里标记,此时再升级就是打扰。所以每次升级之前,前端会向后端确认该告警的最新状态,只有确认仍然处于待处理状态才执行升级推送。
4.4 隐私遮罩:只画骨架,不显示面部
摔倒检测系统涉及人员隐私,试点部署时用户非常在意摄像头画面会不会拍到人脸。我们在前端做了一个专门的隐私模式:开启后,实时画布上不再显示原始视频画面,而是只显示关键点骨架图,用人体轮廓代替真实画面。
这个设计在告警处置时尤其有用。护理人员查看告警现场时,只需要看到“有人摔倒了”这个事实,不需要看到具体是谁、长什么样。骨架图已经能够完整表达动作姿态,同时最小化隐私暴露。
前端实现时,视频流仍然在播放,但通过Canvas遮罩处理:在绘制视频帧时做高斯模糊,然后再叠加检测框和骨架。模糊的视频画面既保留了现场基本信息,又保护了个人隐私。这个遮罩开关放在设置面板里,由管理员统一控制,普通值班人员无法自行关闭。
5. 工程化配置与部署:代理、Nginx、环境变量与日志
5.1 开发环境的跨域与代理配置
项目的前端是Vue 3 + TypeScript + Vite,开发环境最烦人的问题就是跨域。后端接口在 http://192.168.1.100:8080,前端跑在 http://localhost:5173,直接请求必然被CORS拦。
Vite dev server 的 proxy 配置可以优雅地解决这个问题,开发环境的所有请求都走 /api 前缀,本地代理转发到后端真实地址。
typescript复制// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://192.168.1.100:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/ws': {
target: 'ws://192.168.1.100:8080',
ws: true,
changeOrigin: true,
},
},
},
});
WebSocket代理要单独配置,加 ws: true 才会把WebSocket连接也代理到后端。这在日常开发中很容易漏掉,漏掉之后前端连接WebSocket大概率是404或403,排查起来还比较费劲。
5.2 生产环境的Nginx部署
生产环境部署时,前端和后端是分离的。我这里用Nginx做静态服务加反向代理,配置分两部分:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态资源
root /var/www/frontend;
index index.html;
# 前端路由(vue-router history模式需要)
location / {
try_files $uri $uri/ /index.html;
}
# 反向代理API
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket 反向代理
location /ws/ {
proxy_pass http://127.0.0.1:8080/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
# 静态资源缓存
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
WebSocket的 proxy_read_timeout 我设置了3600秒。如果不设置,Nginx默认60秒就会断开空闲的WebSocket连接,而我们的心跳是30秒一次,理论上不会被断开。但有些低版本的Nginx对WebSocket超时处理不够完善,显式加长读超时可以减少莫名其妙的断连。
try_files $uri $uri/ /index.html 是vue-router history模式的核心配置。如果不加,直接访问二级路由(比如 /replay)时Nginx找不到对应文件,返回404,只有从首页跳转才能正常访问。
5.3 环境变量与构建流程
环境变量这层,不同环境对应不同配置:
code复制# .env.development
VITE_API_BASE_URL=/api
VITE_WS_URL=ws://localhost:5173/ws
# .env.production
VITE_API_BASE_URL=/api
VITE_WS_URL=wss://your-domain.com/ws
前端代码统一用 import.meta.env.VITE_API_BASE_URL 读取环境变量,不要在代码里写死接口地址。生产环境用wss,这个点也很重要。因为生产环境如果用了HTTPS,浏览器会禁止不加密的WebSocket连接,必须升级到 wss:// 才能正常工作。
构建命令在一个部署脚本里统一封装:
bash复制npm run build
scp -r dist/* user@server:/var/www/frontend/
5.4 前端日志与错误监控
摔倒检测系统是7x24小时运行的,前端跑着跑着自己挂了,值班人员不一定能及时发现。所以第二版上线前,我们给前端加了一套日志与错误监控:
- 全局错误捕获。监听
window.onerror和unhandledrejection,将报错信息、组件栈、URL、用户操作路径组装成一条日志上报到后端。 - 关键操作埋点。告警确认、误报标记、摄像头切换、画面回放,这些操作全部打点,记录操作人、操作时间、操作对象。
- 前端日志查询。后端提供日志查询接口,管理人员可以在前端页面的“系统日志”里按时间、类型、级别筛选日志,不需要登服务器看Nginx日志。
这套日志系统在排查线上问题时帮了大忙。有一次用户反馈说“告警列表打开了就卡死”,查日志发现是某条告警数据里的 frame.boxes 字段是 null,前端在解析时直接 boxes.map is not a function 报错。如果没有日志,这种偶发问题排查起来要靠猜,有了日志,两分钟就定位了。
6. 踩坑实录:三个让人印象深刻的线上问题
6.1 内存泄漏:一条未清理的动画帧
上线一周后有用户反馈,打开实时画布页面,浏览器内存占用持续增长,半天之后页面吃掉2GB内存,操作变得非常迟钝。
排查过程是这样的:先在Chrome Performance面板录制一段实时画布运行时的内存变化,发现Canvas动画的 requestAnimationFrame 回调频率始终保持满帧运行,即使画面没有任何变化也没有停下来。
问题出在这段代码上:
typescript复制function renderLoop() {
drawDetectionOverlay();
requestAnimationFrame(renderLoop);
}
renderLoop 里没有条件判断,只要启动就永不停止。正确的写法是:当没有新的检测消息需要绘制时,取消下一帧调度。
typescript复制let rafId: number | null = null;
function onDetectionMessage() {
if (rafId != null) return; // 已有一个调度在排队
rafId = requestAnimationFrame(() => {
drawDetectionOverlay();
rafId = null;
});
}
这个改动看起来很小,但效果非常明显。修复后,实时画布页面在空闲状态下的内存占用基本稳定在200MB以内。
6.2 消息串台:moduleId字段缺失引发的“串台”
第二版刚联调时,出现过一个很诡异的现象:值班人员在查看3号楼走廊的摄像头时,画面上突然出现了另一栋楼的检测框和骨架。
排查了一圈,发现是后端推送消息时,部分老摄像头设备没配置 cameraId 字段,后端在组装消息时为了统一,给这些消息默认填了一个空字符串。前端收到消息后,activeCameraId 匹配时把这个空字符串当成普通值处理,结果每一路摄像头都会把这批消息画到自己的画布上。
修复措施很简单,在消息校验环节加一个过滤:
typescript复制if (!msg.cameraId || msg.cameraId !== activeCameraId) {
return;
}
这个坑给我最大的教训是:凡是依赖字段值做判断的地方,必须对空值和缺失值有防御。不管前后端约定多清晰,线上数据永远比你想象的更脏。
6.3 首屏优化:从首屏6秒到1.8秒
最后聊一个优化案例。WebSocket链路、画布渲染都稳定之后,我们开始接到用户反馈:页面打开很慢,尤其是维护人员用老旧办公电脑访问时,首屏白屏时间能到6秒。
用Lighthouse审计了一下,问题集中在以下三个方面:
- 首屏时把所有页面模块的JS全部加载了,包括统计报表的ECharts库、事件回放里的视频播放组件。
- 依赖包没有做分包处理,一个chunk.js体积接近2MB。
- 没有利用浏览器缓存机制,每次刷新文件都要重新下载。
解决方案是路由懒加载加手动分包:
typescript复制const routes = [
{
path: '/alerts',
component: () => import('@/views/AlertCenter.vue'),
},
{
path: '/canvas',
component: () => import('@/views/RealtimeCanvas.vue'),
},
{
path: '/stats',
component: () => import('@/views/StatsReport.vue'),
},
];
Vite构建时把第三方库拆成独立chunk,让浏览器长期缓存:
typescript复制build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
video: ['video.js', 'hls.js'],
},
},
},
},
经过这轮优化,首屏JS体积从2MB降到430KB,白屏时间从6秒降到1.8秒。老旧电脑也能比较流畅地打开系统了。
最后再分享一个小技巧。这类AI应用的前端,在开发前期尽量用Mock数据把整条链路跑通,再做真实环境联调。我在第二版重构时,提前把WebSocket消息模拟器写好了,前端开发完全不依赖后端接口。等到后端Ready之后,只需要把Mock地址切到真实地址,问题立刻暴露在联调阶段,而不是上线之后。前端在AI项目里的价值,其实不只是展示画面和表格,更是把算法能力变成用户可以顺畅操作、快速决策的闭环工具。把这条链路做扎实,比堆多少酷炫特效都有用。
