You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何将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个配套接口:

  1. 获取上传任务ID接口
// 前端上传前先调用拿唯一任务ID
router.get('/getUploadTaskId', (req, res) => {
  const taskId = uuidv4();
  uploadProgressMap.set(taskId, {
    progress: 0,
    status: 'pending'
  });
  res.json({ taskId });
});
  1. 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);
  });
});
  1. 改造原有的上传接口
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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 00:16:03