前后端分片上传:Node.js + React 的完美实践

217 阅读4分钟

前言

在现代的 Web 开发中,大文件上传是一个常见的需求。无论是上传高清图片、大型视频,还是其他大文件,传统的单文件上传方式往往会因为文件过大而导致上传失败、网络卡顿等问题。为了解决这些问题,分片上传技术应运而生。今天,我们就来详细探讨如何在前后端实现分片上传大文件,让文件上传变得轻松又高效。

一、为什么需要分片上传?

分片上传的核心思想是将一个大文件拆分成多个小块(分片),然后分别上传这些小块。在服务器端再将这些分片合并成完整的文件。这种方式有以下优点:

  1. 提高上传成功率:小分片更容易上传成功,避免了因文件过大导致的网络超时等问题。
  2. 支持断点续传:如果某个分片上传失败,可以重新上传该分片,而不需要重新上传整个文件。
  3. 优化用户体验:用户可以看到每个分片的上传进度,提升整体的使用体验。

接下来,我们就通过一个实际的案例,来看看如何在 Node.js 后端和 React 前端实现分片上传。

二、Node.js 后端实现

1. 设置文件存储路径

我们创建一个express项目,然后使用 multer 来处理文件上传。

首先,需要设置文件的存储路径。如果文件是分片上传的,我们会为每个文件创建一个临时文件夹,用于存储分片。

const multer = require('multer');
const path = require('path');
const fs = require('fs');

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    const uploadDir = path.join(__dirname, '../uploads');
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }

    const fileId = req.body.fileId;
    if (fileId) {
      const tempDir = path.join(uploadDir, fileId);
      if (!fs.existsSync(tempDir)) {
        fs.mkdirSync(tempDir, { recursive: true });
      }
      cb(null, tempDir);
    } else {
      cb(null, uploadDir);
    }
  },
  filename: function (req, file, cb) {
    const originalName = req.body.originalName || file.originalname;
    if (req.body.index !== undefined) {
      const chunkIndex = req.body.index;
      cb(null, `${originalName}-chunk-${chunkIndex}`);
    } else {
      cb(null, originalName);
    }
  }
});

const upload = multer({ storage }).fields([
  { name: 'file', maxCount: 1 },
  { name: 'chunk', maxCount: 1 }
]);

2. 上传接口

上传接口会根据是否有分片信息来决定是直接保存文件信息,还是将分片信息存入数据库。

const uploadFile = async (req, res, next) => {
  try {
    const { files } = req;
    let file = files['file'] ? files['file'][0] : null;
    let chunk = files['chunk'] ? files['chunk'][0] : null;

    const fileToProcess = chunk || file;
    if (!fileToProcess) {
      return next();
    }

    const { index, totalChunks, fileId, originalName, userId, folderId } = req.body;
    const chunkFile = fileToProcess.path;

    if (!totalChunks || totalChunks === '1') {
      const fileId = await saveFileInfo(userId, folderId, originalName, chunkFile, fileToProcess.size);
      res.json({ success: true, message: '文件上传成功', fileId });
    } else {
      const insertQuery = 'INSERT INTO file_chunks (fileId, chunkIndex, totalChunks, filePath, originalFilename) VALUES (?, ?, ?, ?, ?)';
      await db.query(insertQuery, [fileId, index, totalChunks, chunkFile, originalName]);
      res.json({ success: true, message: '分片上传成功' });
    }
  } catch (error) {
    next('文件上传失败');
  }
};

3. 合并分片

当所有分片都上传完成后,我们需要将分片合并成完整的文件,并删除临时文件夹。

const mergeFile = async (req, res, next) => {
  const { fileId, totalChunks, originalName, userId, folderId } = req.body;
  const targetDir = path.join(__dirname, '../uploads', fileId);
  const filePath = path.join(__dirname, '../uploads', originalName);

  const [rows] = await db.query('SELECT COUNT(*) AS count FROM file_chunks WHERE fileId = ?', [fileId]);
  if (rows[0].count == totalChunks) {
    fs.writeFileSync(filePath, '');
    for (let i = 0; i < totalChunks; i++) {
      const currentChunkPath = path.join(targetDir, `${originalName}-chunk-${i}`);
      fs.appendFileSync(filePath, fs.readFileSync(currentChunkPath));
      fs.unlinkSync(currentChunkPath);
    }
    fs.rmdirSync(targetDir);

    const fileSize = fs.statSync(filePath).size;
    const fileId = await saveFileInfo(userId, folderId, originalName, filePath, fileSize);
    res.json({ success: true, message: '文件合并成功', fileId });
  } else {
    res.status(400).json({ success: false, message: '所有的分片未全部上传' });
  }
};

三、React 前端实现

1. 分片上传逻辑

在前端,我们需要判断文件大小是否超过 5MB,这个文件大小可以根据自己的项目需求做一个限制。

如果超过,则进行分片上传;否则进行普通上传。

const handleUpload = async (file) => {
  // 5MB 文件大小
  const chunkSize = 5 * 1024 * 1024; 
  const fileSize = file.size;
  // 创建一个唯一ID
  const fileId = Date.now().toString(); 

  setProgress(0);
  setUploading(true);

  if (fileSize > chunkSize) {
    const totalChunks = Math.ceil(fileSize / chunkSize);
    for (let i = 0; i < totalChunks; i++) {
      const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
      const chunkInfo = {
        userId,
        folderId,
        file,
        chunk,
        index: i,
        totalChunks,
        fileId
      };
      await uploadsFile(chunkInfo);
      setProgress((prevProgress) => prevProgress + 100 / totalChunks);
    }

    try {
      await mergeChunks({
        fileId,
        totalChunks,
        userId,
        folderId,
        originalName: file.name
      });
      message.success('文件合并成功!');
    } catch (error) {
      message.error('文件合并失败!');
      console.error('Error merging file chunks:', error);
    }
  } else {
    const fileInfo = {
      userId,
      folderId,
      file
    };
    await uploadsFile(fileInfo);
    setProgress(100);
  }

  setUploading(false);
  message.success('文件上传成功!');
};

2. 上传接口调用

在上传接口调用时,我们需要根据是否有分片信息来构建不同的表单数据。

export const uploadsFile = (fileInfo) => {
  const formData = new FormData();
  formData.append('userId', fileInfo.userId.toString());
  formData.append('folderId', fileInfo.folderId.toString());
  formData.append('originalName', fileInfo.file.name);

  if (fileInfo.chunk && fileInfo.index !== undefined && fileInfo.totalChunks && fileInfo.fileId) {
    formData.append('index', fileInfo.index.toString());
    formData.append('totalChunks', fileInfo.totalChunks.toString());
    formData.append('fileId', fileInfo.fileId);
    formData.append('chunk', fileInfo.chunk);
  } else {
    formData.append('file', fileInfo.file);
  }

  return request.post(`/files/upload`, formData);
};

export const mergeChunks = (chunkInfo) => {
  return request.post(`/files/merge`, chunkInfo);
};

四、总结

通过上述的前后端实现,我们可以轻松地完成大文件的分片上传。这种方式不仅提高了上传的成功率,还优化了用户体验。 在实际开发中,你可以根据具体需求调整分片大小、并发上传的分片数等参数,以达到最佳的性能。

希望这篇文章能帮助你在项目中实现高效的大文件上传功能!如果你有任何问题或建议,欢迎在评论区留言交流。