如何将Node.js视频上传写入流进度推送给前端用户展示
实时上传进度实现方案
核心问题:普通HTTP接口遵循「完整接收请求→返回响应」的交互逻辑,没法在文件上传过程中向前端推送实时数据,而且原代码监听drain事件计算进度的逻辑不准(该事件仅在写入缓冲区排空时触发,会导致进度跳变),同时文件路径没拼接文件名会直接写入目录报错。
下面给两种可直接落地的实现方式:
方案1:SSE服务端推送(推荐,轻量无额外依赖)
适合只需要服务端单向推送进度的场景,不需要引入WebSocket这类重依赖,基于HTTP原生能力实现。
后端改造代码
首先在路由外层定义进度存储结构,用来暂存每个上传任务的进度状态:
const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); // 生成唯一任务ID,不想引依赖可自行实现随机串生成 // 存储上传进度,key为任务ID,value为进度详情 const uploadProgressMap = new Map();
新增3个配套接口:
- 获取上传任务ID接口
// 前端上传前先调用拿唯一任务ID router.get('/getUploadTaskId', (req, res) => { const taskId = uuidv4(); uploadProgressMap.set(taskId, { progress: 0, status: 'pending' }); res.json({ taskId }); });
- SSE进度推送接口
// SSE长连接,实时向前端推对应任务的进度 router.get('/uploadProgress/:taskId', (req, res) => { const { taskId } = req.params; if (!uploadProgressMap.has(taskId)) { return res.status(400).end('无效任务ID'); } // 设置SSE专属响应头 res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' }); // 推送初始状态 res.write(`data: ${JSON.stringify(uploadProgressMap.get(taskId))}\n\n`); // 定时轮询进度推送,100ms一次兼顾实时性和性能 const pushTimer = setInterval(() => { const taskInfo = uploadProgressMap.get(taskId); res.write(`data: ${JSON.stringify(taskInfo)}\n\n`); // 上传完成/报错就关闭连接清理资源 if (taskInfo.status === 'done' || taskInfo.status === 'error') { clearInterval(pushTimer); res.end(); uploadProgressMap.delete(taskId); } }, 100); // 前端断开连接时清理资源 req.on('close', () => { clearInterval(pushTimer); uploadProgressMap.delete(taskId); }); });
- 改造原有的上传接口
router.post('/videoUpload', (req, res) => { const taskId = req.query.taskId; if (!taskId || !uploadProgressMap.has(taskId)) { return res.status(400).json({ msg: '无效上传任务' }); } // 修正文件路径,实际生产环境必须做文件名校验,防止路径穿越攻击 const fileName = req.headers['x-file-name'] || `${Date.now()}.mp4`; const filePath = path.join(__dirname, 'uploads', fileName); const writeStream = fs.createWriteStream(filePath); const totalSize = parseInt(req.headers['content-length']); let receivedSize = 0; writeStream.on('open', () => { uploadProgressMap.set(taskId, { progress: 0, status: 'uploading' }); req.pipe(writeStream); }); // 监听请求流的数据接收,计算真实进度,比drain事件准确 req.on('data', (chunk) => { receivedSize += chunk.length; const percent = ((receivedSize / totalSize) * 100).toFixed(2); console.log(`上传进度:${percent}%`); uploadProgressMap.set(taskId, { progress: percent, status: 'uploading' }); }); // 写入完成事件(原代码用close不对,finish才是写入完成的触发时机) writeStream.on('finish', () => { uploadProgressMap.set(taskId, { progress: 100, status: 'done', fileName }); res.json({ msg: '上传成功', fileName }); }); // 错误处理 writeStream.on('error', (err) => { uploadProgressMap.set(taskId, { progress: 0, status: 'error', msg: err.message }); res.status(500).json({ msg: '上传失败', err: err.message }); }); req.on('error', (err) => { uploadProgressMap.set(taskId, { progress: 0, status: 'error', msg: err.message }); res.status(500).json({ msg: '上传失败', err: err.message }); }); });
前端对接代码
async function uploadVideo(file) { // 1. 获取上传任务ID const { taskId } = await fetch('/getUploadTaskId').then(res => res.json()); // 2. 建立SSE连接监听进度 const progressSource = new EventSource(`/uploadProgress/${taskId}`); const progressBar = document.querySelector('#progress-bar'); progressSource.onmessage = (event) => { const taskInfo = JSON.parse(event.data); // 更新页面进度条 progressBar.style.width = `${taskInfo.progress}%`; progressBar.textContent = `${taskInfo.progress}%`; if (taskInfo.status === 'done') { progressSource.close(); alert('上传完成'); } if (taskInfo.status === 'error') { progressSource.close(); alert(`上传失败:${taskInfo.msg}`); } }; // 3. 发起上传请求 await fetch(`/videoUpload?taskId=${taskId}`, { method: 'POST', headers: { 'X-File-Name': encodeURIComponent(file.name) }, body: file }); }
方案2:前端原生进度监听(代码量最小)
如果不需要获取文件实际落盘的精确进度,只需要展示浏览器发送文件的进度,不需要后端做任何改造,直接用XHR原生的进度事件即可:
function uploadVideo(file) { const xhr = new XMLHttpRequest(); const progressBar = document.querySelector('#progress-bar'); // 监听上传进度 xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = ((e.loaded / e.total) * 100).toFixed(2); progressBar.style.width = `${percent}%`; progressBar.textContent = `${percent}%`; } }; xhr.onload = () => { if (xhr.status === 200) alert('上传完成'); else alert('上传失败'); }; xhr.open('POST', '/videoUpload'); xhr.setRequestHeader('X-File-Name', encodeURIComponent(file.name)); xhr.send(file); }
生产环境注意事项
- 上述进度存在服务端内存中,仅适合单实例部署,多实例部署需要将进度存储替换为Redis等共享存储
- 必须对前端传入的文件名做合法性校验,过滤
../这类特殊字符,防止路径穿越攻击 - 进度推送间隔可根据业务调整,建议不小于100ms,避免频繁推送增加服务端压力
- 如果需要支持暂停上传、断点续传这类双向交互能力,可以将SSE替换为WebSocket,核心进度存储和计算逻辑不需要改动
内容的提问来源于stack exchange,提问作者eldernyPro
相关产品推荐
相关产品推荐

