WebSocket实时推送与Worker分片上传:中后台项目实战记录
发布时间:2026/9/28 12:36:43来源:尧图网络
这是“前端个人项目开发记录”系列的第六篇。我手上这个项目是一个B端数据管理平台前端用 Vue3 Vite TypeScript后端配合 Django从第一期搭建脚手架到现在已经跑了小半年。前五期分别做了工程化基础、组件库封装、权限控制、可视化大屏和 Nginx 部署这几周集中碰到的三个硬骨头是如何让后端数据一变前端就实时刷新、怎么把几十GB的大文件稳定传上去、以及到底要不要拆微前端。这篇文章就是这期的完整记录涉及 WebSocket 接入、Worker 分片上传和 qiankun 迁移的决策过程比较适合正在做中后台或数据看板类前端项目的朋友参考。1. 本期要处理的三件事不是拍脑袋是项目逼到这一步1.1 项目现状三块业务的真实诉求这个平台从第一期开始定位就很明确管理后台 数据看板 报表模块。管理后台负责配置和审核数据看板给管理层实时展示采集到的设备运行数据报表模块则面向运营人员做日常导出和分析。实际用下来三个问题越来越明显。第一看板的数据变化非常频繁后端每天要接收数十万条设备状态记录目前前端是每5秒轮询一次全部接口页面同时发十几个请求数据一多整个列表就开始延迟而且后端数据变化后前端总是要等下一次轮询才能看到快则几秒慢则十几秒管理层开会的时候根本等不起。第二平台有一个“离线包上传”功能运营人员会上传几十GB的日志压缩包用于后续分析原来的单接口上传经常传一半失败请求断了只能重新来浏览器内存占用还高得吓人。第三除了主平台我还同时维护着另外两个小系统一个报表工具、一个运营门户它们大量复用主平台的组件和请求逻辑每次改一个公共组件都要三处同步版本升级时稍不注意就出现不一致。1.2 目标的定义这一期我到底要交付什么为了让这一期不变成无底洞我把目标拆成了三条每条都带着验收标准。第一看板实时性后端产生新数据后前端要在1秒内展示到页面上同时要支持前端反向发送指令比如某个权限用户临时关闭某个区域的推送。第二大文件上传在打开页面的同时上传40GB左右的文件页面不能出现长时间卡顿断网或浏览器刷新后可以从已传完的分片继续传。第三微前端拆分把报表工具和运营门户从“手动同步代码”改为独立子应用子应用可以独立开发、独立部署、互不阻塞。拆完之后我才意识到这三件事其实不是零散的它们都在解决同一个核心矛盾这个项目正在从“一个人能控制的单体前端”变成“需要多人并行、多端实时协作的前端”。第六期写这些内容正好是这个项目成长到一定阶段的真实记录。1.3 改造前的基础约束我给自己加了两条约束。第一已经上线的页面能不改就不改实在要动也必须保证旧功能不回归。第二后端接口保持兼容前端先做适配后端按节奏补接口不能让一个优化直接把全平台拖停。实际执行时发现这两条约束比想象中值钱。比如 WebSocket 接入时我没有立刻删掉轮询代码而是先让看板同时走两条通道灰度验证 WebSocket 数据稳定后再关闭轮询大文件上传也保留了原单接口作为小文件兜底只在文件超过某个阈值时才走 Worker 分片流程。这样即使新链路出问题业务也不会彻底中断。2. 后端一有数据就往页面推WebSocket 全链路落地笔记2.1 为什么放弃轮询以及 SSE 为什么不够最开始的轮询方案其实不难理解每个看板组件各自设一个定时器5秒拉一次后端接口。问题在于组件多了以后请求在小时间窗口内集中爆发后端压测时看到接口平均响应时间被拉高了接近一倍另外不同组件的轮询周期不同数据天然存在几秒的时差同一个指标在两个卡片上显示出不同的数值运营人员直接截图来问是不是数据错了。选择 WebSocket 之前也考虑过 SSE。SSE 实现简单、自动重连、基于 HTTP很多场景比 WebSocket 更省心。但我有一个硬需求是前端要向后端发指令比如有权限的人要临时修改某个区域的推送阈值或者要强制重新拉取某段历史数据。SSE 是单向的这个场景做起来很绕。再加上项目本身已经用了 Django Channels 做后台任务直接复用同一套 channel layer 就能把消息推到前端WebSocket 就成了更顺的选择。2.2 Django Channels 的关键配置与后端发送后端这边的核心是给每个登录用户建一个独立的 group所有需要推送给该用户的消息都发到这个 group 里而不是直接发给某个 connection。这样用户在多个标签页打开看板或者从电脑切到手机消息都能同时到达。# consumers.py from channels.generic.websocket import AsyncWebsocketConsumer from channels.db import database_sync_to_async class DataPushConsumer(AsyncWebsocketConsumer): async def connect(self): user self.scope.get(user) if not user or not user.is_authenticated: await self.close(code4001) return self.group_name fuser_{user.id} await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() async def disconnect(self, close_code): await self.channel_layer.group_discard(self.group_name, self.channel_name) async def data_message(self, event): # 注意这里的 data_message 对应 group_send 里的 type 字段 await self.send(text_dataevent[text])消息发送端在业务代码里拿到 channel_layer 后调用 group_send 把 JSON 字符串送到对应 groupfrom channels.layers import get_channel_layer import json channel_layer get_channel_layer() await channel_layer.group_send( fuser_{user_id}, { type: data.message, text: json.dumps({event: device_status, payload: {...}}), } )这里有一个容易被忽略的细节group_send 里的type字段会自动把点号转成下划线并作为 consumer 里的方法名。我一开始写的是type: data_message结果前端一直收不到消息排查了半天才发现是方法名和 type 没对齐。这类问题光看官方文档很难一眼看出来必须自己跑一遍才能记住。2.3 前端封装把 WebSocket 做成可复用的 hook前端这边我最终选择自己封装一个轻量的useWebSockethook没有直接上 socket.io 或者第三方库。原因很简单项目的推送场景比较固定不需要自动降级、多路复用这些重能力自己封装反而更容易控制连接生命周期。// useWebSocket.ts export interface WsHandlers { onMessage: (data: any) void; onOpen?: () void; onClose?: () void; } export function useWebSocket(url: string, handlers: WsHandlers) { let ws: WebSocket | null null; let heartbeatTimer: number | undefined; let retryCount 0; let closedByUser false; function startHeartbeat() { stopHeartbeat(); // 每 30 秒发一次业务层 ping比 websocket 协议层的 ping frame 更好排查问题 heartbeatTimer window.setInterval(() { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); } function connect() { closedByUser false; ws new WebSocket(url); ws.onopen () { retryCount 0; startHeartbeat(); handlers.onOpen?.(); }; ws.onmessage (event) { try { const data JSON.parse(event.data); if (data.type pong) return; // 心跳响应直接忽略 handlers.onMessage(data); } catch (e) { console.warn(无法解析的 WebSocket 消息, e); } }; ws.onclose () { stopHeartbeat(); if (closedByUser) return; // 指数退避重连1s、2s、4s、8s... 最多 30s const delay Math.min(1000 * Math.pow(2, retryCount), 30000); retryCount; setTimeout(connect, delay); }; ws.onerror () { // 让 onclose 触发重连这里只做日志 console.error(WebSocket 连接发生错误); }; } function close() { closedByUser true; stopHeartbeat(); ws?.close(); } return { connect, close }; }用的时候在每个需要实时数据的页面里执行const { connect, close } useWebSocket(...)组件挂载时 connect卸载时 close。看起来很简单但实际踩坑点恰恰就藏在“挂载、卸载”这四个字里。2.4 实战踩坑路由切换后 WebSocket 没关消息重复消费这个坑是在一个 keep-alive 缓存页面里踩到的。看板列表和详情页都加了 keep-alive避免用户来回切换时重复加载数据。问题随之而来页面缓存后组件不会卸载只会走deactivated生命周期而我的 WebSocket 连接是放在onMounted里创建的onUnmounted里关闭。结果就是用户从看板 A 切到看板 B再切回看板 AWebSocket 连接被创建了两次两个连接同时收消息页面上出现大量重复渲染。排查过程也算典型。先是在浏览器 Network 面板看到同一个 WebSocket 地址有好几条连接记录然后我打印 consumer 的 connect 日志发现同一个用户反复 connect。更奇怪的是从 A 切到 B 再切回来旧连接并没有关闭——这证实了问题出在生命周期而不是后端。正确做法是区分缓存场景onActivated(() { connect(); }); onDeactivated(() { close(); }); onUnmounted(() { close(); });这里还有个额外收获close()里必须把closedByUser置为 true否则用户在 B 页面时旧连接会按指数退避无限重连后端瞬间接收到一堆无效连接日志量直接爆炸。另外 group_name 一定要按用户维度隔离否则消息会广播给所有在线用户我在测试环境就曾看到用户A的上传进度推到了用户B的页面上。3. 上传几十GB不卡页面Worker 分片上传的完整思路3.1 盲目的第一版做了分片但 UI 还是卡大文件上传的第一版我其实做得很快就是把文件用File.slice()切成 8MB 一块然后逐个 POST 到后端。上线测试后立刻发现一个新问题页面滚动变得非常卡上传按钮点击后的反馈要等好久才出现。当时我用 Performance 面板看了一下发现大部分时间消耗在前端的哈希计算上。我用了 SparkMD5 去算整个文件的大哈希40GB 的文件在主线程上跑 MD5CPU 直接被打满整个页面连点击事件都卡住了。分片确实减轻了网络压力但计算压力全堆在了主线程这个设计是失败的。3.2 把哈希和分片信息计算挪进 Worker这次我决定让 Worker 负责所有重计算包括文件切片、每片哈希、整体文件哈希主线程只负责接收 Worker 的消息和发起网络请求。// uploadWorker.js self.onmessage async (event) { const { file, chunkSize } event.data; const totalChunks Math.ceil(file.size / chunkSize); const chunks []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); const blob file.slice(start, end); const hash await calculateHash(blob); chunks.push({ index: i, hash }); // 每算完 5% 就回报一次进度避免看起来像“卡死” if (i % Math.ceil(totalChunks / 20) 0) { self.postMessage({ type: HASH_PROGRESS, done: i 1, total: totalChunks, }); } } self.postMessage({ type: READY, chunks, totalChunks }); }; function calculateHash(blob) { return new Promise((resolve) { const reader new FileReader(); reader.onload () { // 这里用 spark-md5 的增量接口算每片哈希 const spark new SparkMD5.ArrayBuffer(); spark.append(reader.result); resolve(spark.end()); }; reader.readAsArrayBuffer(blob); }); }Worker 里计算完所有分片哈希后把结果发给主线程。主线程拿到结果后再做两件事先询问后端“这个文件之前传过没有”得到答案后把未上传的分片排队发送。顺带说一句我在 Worker 里用的是每个分片单独算哈希而不是把整个文件一次性读进内存算总哈希。好处是内存占用基本稳定在分片大小这个量级40GB 文件也不会把浏览器内存撑爆。缺点是最后没法得到一个全文件统一哈希但没关系我在后端用“分片哈希列表 文件大小”拼接后作为文件唯一标识效果一样。3.3 后端接口设计与上传流程整个上传流程我设计成四个接口POST /api/upload/init前端把文件总大小、分片大小、分片哈希列表发送给后端后端返回一个 uploadId同时返回哪些分片已经存在用于秒传和续传。POST /api/upload/part上传单个分片参数带上 uploadId 和分片序号。POST /api/upload/merge所有分片都传完后后端根据 uploadId 合并文件。GET /api/upload/progress/{uploadId}查询实时进度供刷新页面后恢复进度使用。前端在 Worker 给到READY消息后主线程执行如下逻辑const uploadedChunks await checkExistingChunks(fileHashList); const pendingChunks allChunks.filter((chunk) !uploadedChunks.includes(chunk.index)); await uploadWithConcurrency(pendingChunks, 4); await mergeFile(uploadId);uploadWithConcurrency我实现成一个简单的并发池控制同时只有 4 个分片在传。这个并发数是试出来的并发太高后端磁盘写入压力大且容易触发限流并发太低带宽跑不满。4 到 6 之间比较合理我最后固定为 4。单分片失败就重试最多三次三次都失败则自动跳过并继续其他分片全部完成后统一重试失败分片。这样一个分片网络抖动不会拖垮整个任务。3.4 实测效果与浏览器差异改完之后我用一个 40GB 的压缩包做了测试。整个文件被切成 5120 个分片Worker 计算哈希期间页面的滚动、切换标签、甚至打开新页面都不受影响上传过程中 CPU 占用比之前低很多主要瓶颈只出现在网络请求阶段。浏览器兼容性方面Safari 的File.slice()在老旧版本上有参数类型问题需要在代码里强转start end为数字Chrome 和 Edge 基本没问题。另外如果上传过程中用户切换了网络比如从 Wi-Fi 切到热点已完成的 TCP 请求不会恢复需要等分片重试机制兜底。还有一个小细节单个分片的大小我建议根据用户网络质量动态判断。内网环境可以调到 16MB外网环境 8MB 比较稳。分片太小会让 HTTP 请求数量爆炸光请求排队时间就拖慢整体速度这也是很多朋友参数调了之后反而更慢的原因。4. 微前端从“要不要拆”到“拆了之后怎么办”4.1 决策过程三个问题帮我下了决心微前端这个概念在圈子里争议很大我也犹豫了很久。最后让我下决心的是三个非常简单的问题这三个系统是不是需要独立发布答案是肯定的报表工具一个月要发好几次版本主平台却要稳定运行两者发布节奏完全错开。公共组件库能不能通过 npm 包共享可以但每次改公共组件都要发一个新版本再跑到三个系统里升级依赖。如果只是自己改还好一旦多人参与这个流程就变得很重。直接用 iframe 行不行行但 iframe 在浏览器前进后退、全局弹窗定位、跨应用状态同步这些场景下都很难受。综合下来我选择了 qiankun。它是目前生态里最成熟、遇到问题最好搜到答案的方案。官方文档和社区案例都很多这对个人项目来说很重要毕竟遇到问题没有太多人可问只能靠搜索引擎。4.2 主应用与子应用的改造关键主应用改造其实不算复杂核心是注册子应用并启动。// main.ts import { registerMicroApps, start } from qiankun; registerMicroApps([ { name: report, // 子应用唯一名称 entry: import.meta.env.VITE_REPORT_ENTRY, container: #subapp-container, activeRule: /report, }, { name: portal, entry: import.meta.env.VITE_PORTAL_ENTRY, container: #subapp-container, activeRule: /portal, }, ]); start({ sandbox: { strictStyleIsolation: false, }, });子应用这边需要导出生命周期钩子// vite-env.d.ts declare global { interface Window { __POWERED_BY_QIANKUN__?: boolean; } } // main.ts import { createApp, type App } from vue; import { createRouter, createWebHistory } from vue-router; import App from ./App.vue; let app: App | null null; let router: Router | null null; export async function bootstrap() { // 可选初始化工作 } export async function mount(props: any) { const base props?.basePath || /; router createRouter({ history: createWebHistory(base), routes, }); app createApp(App); app.use(router); app.mount(props.container); } export async function unmount() { app?.unmount(); app null; router null; } // 非微前端环境独立运行 if (!window.__POWERED_BY_QIANKUN__) { mount({ container: #app }); }Vite 子应用有一个特别容易踩的坑base路径必须动态设置。我一开始没有处理打包上线后子应用的 JS/CSS 全部请求了根路径Nginx 直接返回 404。需要在vite.config.ts里配置export default defineConfig({ base: process.env.NODE_ENV development ? / : /report/, // 与主应用 activeRule 对应 });4.3 样式隔离的两场遭遇战第一场主应用的全局 reset 样式把子应用的表单布局弄乱了。我在主应用里写了一行button { padding: 0; }结果子应用的所有按钮都挤成一团。一开始想开strictStyleIsolation: true但这样会用 Shadow DOM 隔离很多第三方弹窗组件的挂载行为会出问题。最终我选择了中间路线关闭严格样式隔离子应用自己用 CSS Modules并给组件的根节点加一个类似report-app-root的特定类名把最关键的全局冲突兜住。第二场子应用里的表格组件弹窗挂到了 body 上被主应用更高的 z-index 元素盖住。这个问题的根因是弹窗的appendToBody配置导致弹窗脱离了子应用容器样式和层级都受主应用影响。解决办法是把弹窗挂载到子应用容器内部或者统一调整 z-index 的层级规范。我最后选择了前者因为改动最小。4.4 迁移后的真实评价如果现在有人问我该不该上微前端我不会直接给否定的答案但会建议先算一笔账。独立发布和模块隔离是这个方案最大的收益实测下来确实解决了三个系统同步发布的痛点。但成本也不小改造期间需要处理路由、样式隔离、公共依赖、开发环境代理等一系列问题保守估计我一个人多做了一周的工作量。如果只是一个小团队内部用我更推荐用模块联邦也就是 Vite 的module federation插件或者直接 monorepo 单仓多包。但如果你需要独立部署、独立发布、甚至不同团队用不同框架qiankun 依然是目前最省心的选择。迁移后遇到问题别慌我的经验是第一时间看子应用是否处在__POWERED_BY_QIANKUN__环境下再确认base路径大部分诡异的 404 和资源加载失败都出在这两个地方。5. 顺手处理掉的几个前端隐患5.1 token 存内存还是 localStorage我最后的选择这个项目以前为了省事直接把 token 放在 localStorage 里每次请求时取出来塞进 header。直到有一次我审查代码发现一个用户昵称的渲染逻辑可能会把用户输入的 HTML 原样插入虽然项目不是高攻击价值系统但把 token 放在任何能通过脚本访问的地方始终是一件让人睡不着的事。现在的做法是token 从 localStorage 迁到 Pinia内存中保存页面刷新后用隐藏在 HttpOnly Cookie 里的 refresh token 换取新的 access token。这个方案牺牲了一点“本地长期保存”的便利性但换来了更低的泄漏风险。刷新页面时的白屏时间增加了几百毫秒但相比安全性这个代价完全值得。5.2 登录态与页面缓存的联动这个坑是切换账号时发现的。用户A退出登录用户B在同一个浏览器登录打开数据看板时居然看到了用户A上一次打开时的残留设备数据。排查后确认是 WebSocket 连接没有随登录态切换关闭旧的 channel 仍然在接收消息并更新看板状态。解决方案是做一个全局的“登录态切换清理事件”在登出和登录流程里手动触发closeAllWebSocketConnections()并且让看板组件监听这个事件。这个经验看起来很小但如果在实时推送场景下不复用同一套清理逻辑早晚会踩到像这样的数据串号。5.3 Nginx 与微前端静态资源路径问题改造前的 Nginx 配置很简单所有请求指到 dist 目录。改造后子应用上线第一个问题是静态资源 404刷新/report路径时页面直接白屏。这里的坑在于 qiankun 的主应用是 history 路由刷新时 Nginx 需要 fallback 到首页同时还要把子应用的路由交给子应用容器处理。我的最终配置大致是这样的location / { try_files $uri $uri/ /index.html; } location /report/ { proxy_pass http://localhost:8081/; }注意proxy_pass这行末尾的斜杠很关键带斜杠会把/report/前缀丢掉转发到子应用服务。如果不带斜杠请求会原样带过去子应用内部路由很容易错乱。为了这个问题我反复试了好几版配置才搞明白。5.4 大屏页面的两个布局细节这个平台里有一个面向管理层的数据大屏设计稿是 1920x1080。之前我一直用 rem 方案结果放大缩小时字体和组件的比例偶尔会对不上。这次我换了一种思路外层容器固定 1920x1080再用 CSStransform: scale()按浏览器视口尺寸做整体缩放。这样大屏内部所有元素的相对位置完全按照设计稿来不会出现某个图表被拉伸变形的问题。如果有数字孪生或者地图相关的元素我会额外建议把 canvas 和 DOM 元素放在同一层坐标系里避免用绝对定位硬凑。坐标换算看起来是小问题但一有缩放就全乱了大屏上数据对不齐很影响观感。5.5 AI 辅助工具在项目中怎么用不添乱这一段算是不务正业但真实有用的部分。我这个项目里用 AI 辅助编码工具生成了不少样板代码包括 WebSocket hook 的初版、分片上传的并发池雏形、还有一堆校验逻辑。说实话效率非常高原本要写半天的胶水代码十几分钟就出来了。但它也不是万能救世主。AI 给我的 WebSocket 重连逻辑就有个严重问题setTimeout没有在组件卸载时清理切页面后定时器一直跑导致连接静默重建。这类问题如果不手动审查根本发现不了。所以我的态度很明确AI 可以帮你写模板、帮你搭框架但涉及连接生命周期、权限校验、资金数据和用户隐私的代码必须自己一行行过脑子。工具是放大生产力的不是替你做架构决定的。6. 写在最后这一期给我最大的几点体会其实回头看这期做的三件事没有一件是特别“炫技”的WebSocket、Worker 分片、微前端都是前端社区聊烂了的话题。但真正把它们从一个概念变成项目里稳定运行的能力中间藏着大量上文里描述过的细节而这些细节几乎全部要靠实际踩坑才能沉淀下来。如果非要分享一点个人经验那就是开发记录这种系列文章最重要的不是记“做了什么”而是记“为什么这么做、当时排除了哪些选项”。我在第四期的时候随手写过一句话“轮询改为 WebSocket 不是因为 WebSocket 更高级而是因为业务对实时性的要求变了”。现在回看这句话比任何代码都更能帮我回忆当时的判断依据。最后分享一个小技巧遇到复杂问题时把排查过程按时间顺序写进项目的笔记文件里不用写得多规整流水账就行。过几个月再打开你会发现自己当时的解决路径比任何教程都有参考价值。下一期我打算聊聊性能监控和前端错误上报如果这篇文章能帮到你我也很高兴听到你的实战反馈。
网站建设高端定制企业官网