一文读懂前后端如何实现大文件上传

865 阅读20分钟

1.大文件上传的业务背景和挑战

大文件上传是许多在线应用和服务中的一个常见需求,尤其是在那些需要处理视频、音频、大型文档集或高分辨率图片的场景中。这项功能的业务背景和挑战可以从多个角度来看:

1.1 业务背景

  • 媒体处理:视频编辑平台、音频处理软件、图像库等需要上传大量媒体文件。

  • 数据备份与迁移:企业需要备份或迁移大量数据,包括数据库文件、系统镜像等。

  • 内容分发网络:在CDN中上传大文件以便更快地在全球范围内分发。

  • 科学与研究:上传大型数据集,例如基因组序列、气象模型数据等。

  • 教育和在线学习:上传高质量的教学视频和教材。

  • 法律和财务:共享大量的法律文档或财务报表。

1.2 挑战

  • 性能问题:大文件上传可能导致客户端(浏览器)性能下降,特别是在资源有限的设备上。

  • 网络不稳定:大文件更有可能在上传过程中遇到网络问题,如断线、超时等。

  • 服务器负载:大文件上传会给服务器带来更大的负载,特别是在处理大量此类请求时。

  • 用户体验:长时间的上传过程可能导致用户感到不耐烦,影响用户体验。

  • 文件完整性和安全性:确保文件在传输过程中不被破坏或篡改,同时保证数据的隐私和安全。

  • 断点续传:支持在网络中断后能够继续上传,而不是重新开始。

  • 数据处理:大文件需要更复杂的处理流程,例如切片、压缩和解压缩。

  • 兼容性和标准化:确保各种浏览器和设备都能顺利完成上传过程。

2.大文件上传原理与实现

本文中前端使用的是React,后端用的是nodeExpress框架

2.1 创建项目

npx create-react-app uploadfile-client
npm install @ant-design/icons antd axios
npm start

2.2 基本页面

在src目录下index.js中:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import FileUploader from './FileUploader';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <FileUploader />
);

创建文件FileUploader.jsx

import { useRef } from "react";
import { InboxOutlined } from "@ant-design/icons";
import "./FileUploader.css";

function FileUploader() {
  return (
      <div className="upload-container">
        <InboxOutlined />
      </div>
  );
}

export default FileUploader;

创建文件FileUploader.css

.upload-container {
  width: 100%;
  height: 150px;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid #d9d9d9;
  background-color: #fafafa;
}

.upload-container:hover {
  border-color: #40a9ff;
}

.upload-container span {
  font-size: 60px;
}

2.3 拖入文件

FileUploader.jsx

import { useRef } from "react";
import { InboxOutlined } from "@ant-design/icons";
import "./FileUploader.css";
import useDrag from "./useDrag";

function FileUploader() {
  const uploadContainerRef = useRef(null);
  useDrag(uploadContainerRef);
  return (
      <div className="upload-container" ref={uploadContainerRef}>
        <InboxOutlined />
      </div>
  );
}

export default FileUploader;

自定义Hooks函数useDrag,创建文件useDrag.jsx

import { useState, useEffect, useCallback } from "react";
import { message } from "antd";

function useDrag(uploadContainerRef) {
  // 定义一个状态用来保存用户选中的文件
  const [selectedFile, setSelectedFile] = useState(null);

  const handleDrag = (event) => {
    event.preventDefault(); // 阻止默认行为
    event.stopPropagation(); // 阻止事件传播
  };

  const handleDrop = (event) => {
    event.preventDefault(); // 阻止默认行为
    event.stopPropagation(); // 阻止事件传播
    const { files } = event.dataTransfer;
    console.log("files--", files);
  };

  useEffect(() => {
    const uploadContainer = uploadContainerRef.current;
    uploadContainer.addEventListener("dragenter", handleDrag);
    uploadContainer.addEventListener("dragover", handleDrag);
    uploadContainer.addEventListener("drop", handleDrop);
    uploadContainer.addEventListener("dragleave", handleDrag);
    return () => {
      uploadContainer.removeEventListener("dragenter", handleDrag);
      uploadContainer.removeEventListener("dragover", handleDrag);
      uploadContainer.removeEventListener("drop", handleDrop);
      uploadContainer.removeEventListener("dragleave", handleDrag);
    };
  }, []);
  
  return { selectedFile };
}

export default useDrag;

关于拖拽的事件

  • dragenter:当拖动的元素或选中的文本进入有效拖放目标时触发。在这里,它用于初始化拖拽进入目标区域的行为。

  • dragover:当元素或选中的文本在有效拖放目标上方移动时触发。通常用于阻止默认的处理方式,从而允许放置。

  • drop:当拖动的元素或选中的文本在有效拖放目标上被放置时触发。这是处理文件放置逻辑的关键点。

  • dragleave:当拖动的元素或选中的文本离开有效拖放目标时触发。可以用于处理元素拖离目标区域的行为。

相关API

  • event.preventDefault():阻止事件的默认行为。在拖放事件中,这通常用于阻止浏览器默认的文件打开行为。

  • event.stopPropagation():阻止事件冒泡到父元素。这可以防止嵌套元素的拖放事件影响到外层元素。

  • event.dataTransfer:一个包含拖放操作数据的对象。在 drop 事件中,它可以用来获取被拖放的文件。

  • files: event.dataTransfer.files 是一个包含了所有拖放的文件的 FileList 对象。可以通过这个对象来访问和处理拖放的文件。

拖拽一个视频到页面区域内,控制台查看打印结果:

image.png

2.4 校验并预览文件

新建文件 constant.js,规定文件大小最大为2G

export const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 2;

在useDrag.jsx中校验文件,并将预览信息返回出去

import { useState, useEffect, useCallback } from "react";
import { message } from "antd";
import { MAX_FILE_SIZE } from "./constant";

function useDrag(uploadContainerRef) {
  // 定义一个状态用来保存用户选中的文件
  const [selectedFile, setSelectedFile] = useState(null);
  // 存放文件的预览信息  url:预览地址  type:文件类型
  const [filePreview, setFilePreview] = useState({ url: null, type: null });

  // 为了提高性能,用useCallback缓存函数
  const handleDrag = useCallback((event) => {
    event.preventDefault(); // 阻止默认行为
    event.stopPropagation(); // 阻止事件传播
  }, []); // 若不给第二个参数,则没有缓存的效果

  const checkFile = (files) => {
    const file = files[0]; // 当前只考虑拖拽一个文件
    if (!file) {
      message.error("没有选择任何文件");
      return;
    }
    // 判断文件大小不能超过2G
    if (file.size > MAX_FILE_SIZE) {
      message.error("文件大小不能超过2G");
      return;
    }
    // 再判断类型,限定只能上传图片或视频
    if (!(file.type.startsWith("image/") || file.type.startsWith("video/"))) {
      message.error("文件类型必须是图片或视频");
      return;
    }
    setSelectedFile(file);
  };

  const handleDrop = useCallback((event) => {
    event.preventDefault(); // 阻止默认行为
    event.stopPropagation(); // 阻止事件传播
    checkFile(event.dataTransfer.files);
  }, []);

  useEffect(() => {
    if (!selectedFile) return;
    const url = URL.createObjectURL(selectedFile);
    setFilePreview({ url, type: selectedFile.type });
    // useEffect会返回一个销毁函数
    return () => {
      URL.revokeObjectURL(url);
    };
  }, [selectedFile]);

  useEffect(() => {
    const uploadContainer = uploadContainerRef.current;
    uploadContainer.addEventListener("dragenter", handleDrag);
    uploadContainer.addEventListener("dragover", handleDrag);
    uploadContainer.addEventListener("drop", handleDrop);
    uploadContainer.addEventListener("dragleave", handleDrag);
    return () => {
      uploadContainer.removeEventListener("dragenter", handleDrag);
      uploadContainer.removeEventListener("dragover", handleDrag);
      uploadContainer.removeEventListener("drop", handleDrop);
      uploadContainer.removeEventListener("dragleave", handleDrag);
    };
  }, []);
  
  return { selectedFile, filePreview }; // 返回选中的文件和文件预览信息
}

export default useDrag;

在FileUploader.jsx中显示文件的预览信息

import { useRef } from "react";
import { InboxOutlined } from "@ant-design/icons";
import "./FileUploader.css";
import useDrag from "./useDrag";

function FileUploader() {
  const uploadContainerRef = useRef(null);
  const { selectedFile, filePreview } = useDrag(uploadContainerRef);
  return (
    <>
      <div className="upload-container" ref={uploadContainerRef}>
        {renderFilePreview(filePreview)}
      </div>
    </>
  );
}

// 显示文件的预览信息
function renderFilePreview(filePreview) {
  const { url, type } = filePreview;
  if (url) {
    if (type.startsWith("video/")) {
      return <video src={url} alt="preview" controls />;
    } else if (type.startsWith("image/")) {
      return <img src={url} alt="preview" />;
    } else {
      return url;
    }
  } else {
    return <InboxOutlined />;
  }
}

export default FileUploader;

在FileUploader.css中对图片和视频的高度做处理

.upload-container video,img {
  height: 100%;
}

关于URL.createObjectURL

URL.createObjectURL 是一个非常实用的 Web API,它允许你创建一个指向特定文件对象或 Blob(Binary Large Object)的 URL。这个 URL 可以用于访问存储在用户本地的文件数据,而无需实际上传文件到服务器。

基本用法
const objectURL = URL.createObjectURL(blob);
  • blob:这是一个 Blob 对象,它可以是任何类型的二进制数据,包括文件数据。在浏览器中,File 对象是 Blob 的一个特殊类型,因此你也可以传递一个 File 对象。

  • objectURL:这是一个字符串,代表了创建的 URL。它指向传入的 Blob 或 File 对象,并且只能 在创建它的文档中使用。

应用场景
  • 预览文件:在用户上传文件之前,你可以使用 URL.createObjectURL 来创建一个指向该文件的 URL,并用它来预览文件。例如,如果用户选择了一张图片,你可以立即在网页上显示这张图片, 而不需要等待图片上传到服务器。

  • 优化性能:对于大型二进制对象,使用这种方法可以避免将数据存储在 JavaScript 中,这样可以节省内存并提升性能。

  • 处理数据流:它也可以用于处理实时的数据流,例如从摄像头捕获的视频流。

示例

假设你有一个文件输入元素和一个图片元素,你希望在用户选择图片后立即显示这张图片:

document.getElementById('fileInput').addEventListener('change', (event) => {
    const file = event.target.files[0];
    const url = URL.createObjectURL(file);
    document.getElementById('previewImage').src = url;
});
注意事项
  • 内存管理:由 URL.createObjectURL 创建的 URL 会占用浏览器的内存,因为它指向的文件数据 会被保留在内存中。因此,当不再需要这个 URL 时,你应该使用 URL.revokeObjectURL 来释放这部分内存。

  • 安全性:虽然这些 URL 只在创建它们的文档中有效,但仍应注意确保 Blob 数据的安全性,尤其是 在处理用户提供的内容时。

总的来说, URL.createObjectURL 是处理本地文件和二进制数据的一个强大工具,特别是在需要实时处理或预览这些数据时。

2.5 分片上传

为了提升性能,在上传大文件的时候,可以把一个大文件切成多个小文件,然后并行上传。另外为了后面实现类似秒传的功能,所以需要对文件进行唯一的标识。因此我们需要根据文件的内容生成一个hash值来唯一的标识这个文件,要保证文件内容如果一样,产生的文件名是一样的

拿到文件的hash需要用到浏览器端的一个加密工具 ——— crypto.subtle.digest

crypto.subtle.digest('SHA-256', arrayBuffer) 是 Web Cryptography API 的一部分,它用于计算传入数据的 SHA-256 哈希。这个方法是浏览器提供的一种原生方式来执行密码学操作,其中包括创建数据的散列(哈希)。

其中,'SHA-256'是指定的哈希算法。SHA-256 是 SHA-2 算法家族中的一员,产生一个 256 位(32字节)的哈希值。它是目前广泛使用的安全哈希算法。

arrayBuffer是要进行哈希处理的数据,以 ArrayBuffer 的形式提供。ArrayBuffer 是一种表示固定长度原始二进制数据缓冲区的通用容器。

...
import { Button, message } from "antd";

function FileUploader() {
  const uploadContainerRef = useRef(null);
  const { selectedFile, filePreview } = useDrag(uploadContainerRef);
  
  const handleUpload = async () => {
    if (!selectedFile) {
      message.error("你尚未选中任何文件!");
      return;
    }
    const fileName = await getFileName(selectedFile);
    console.log('fileName--', fileName);
  };
  
  const renderButton = () => {
    return <Button onClick={handleUpload}>上传</Button>;
  };
  
  return (
    <>
      <div className="upload-container" ref={uploadContainerRef}>
        {renderFilePreview(filePreview)}
      </div>
      {renderButton()}
    </>
  );
}

// 根据文件对象获取由文件内容得到的hash文件名
async function getFileNmae(file) {
  const fileHash = await calculateFileHash(file);
  // 获取文件扩展名
  const fileExtension = file.name.split(".").pop();
  return `${fileHash}.${fileExtension}`;
}

// 计算文件的hash字符串
async function calculateFileHash(file) {
  const arrayBuffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
  return bufferToHex(hashBuffer);
}

// 将一个ArrayBuffer转换成16进制的字符串
function bufferToHex(buffer) {
  return Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}

...

当我拖入了一个365M的视频后,点击上传发现打印出来的fileName显示的比较慢。因为要读取300多M内容,然后还要计算hash值就很慢。后面会讲到这就是为什么用webWoker来优化的原因,这也是性能优化的一个重点。

image.png

获取到哈希文件名之后,接下来要切片上传了,写一个上传方法 uploadFile。和后端约定好每个切片的大小,此处以100M为例。实际上切片的大小是要根据带宽、网络波动、文件大小等情况进行动态调整,而不是写死的。

constant.js

export const CHUNK_SIZE = 100 * 1024 *1024;  // 每个切片大小100M

分片名称以文件的哈希文件名+索引来命名

fileUploader.jsx

...
import { CHUNK_SIZE } from "./constant";

function FileUploader() {
    ...
    const handleUpload = async () => {
    ...
    const fileName = await getFileNmae(selectedFile);
    await uploadFile(selectedFile, fileName);
  };
}

// 实现切片上传大文件
async function uploadFile(file, fileName) {
  // 将大文件进行切片
  const chunks = createFileChunks(file, fileName);
  console.log("chunks--", chunks);
}

function createFileChunks(file, fileName) {
  // 最后切成的分片的数组
  let chunks = [];
  // 计算一共要切成多少片
  let count = Math.ceil(file.size / CHUNK_SIZE);
  for (let i = 0; i < count; i++) {
    let chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
    chunks.push({
      chunk,
      chunkFileName: `${fileName}-${i}`,
    });
  }
  return chunks;
}
...

上传一个298.4M的视频,查看打印的chunks:

image.png

切片完成后,就要考虑并行上传每个分片了,需要向后端发请求。这时候封装一下axios,此处省略。分片全部上传完后,需要发送一个合并文件的请求

...

// 实现切片上传大文件
async function uploadFile(file, fileName) {
  // 将大文件进行切片
  const chunks = createFileChunks(file, fileName);
  // 实现并行上传
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    return createRequest(fileName, chunk, chunkFileName);
  });
  try {
    // 并行上传每个分片
    await Promise.all(requests);
    // 等全部的分片上传完了,会向服务器发送一个合并文件的请求
    await axiosInstance.get(`/merge/${fileName}`);
    message.success("文件上传完成");
  } catch (error) {
    console.log("上传出错", error);
    message.error("上传出错");
  }
}

function createRequest(fileName, chunk, chunkFileName) {
  return axiosInstance.post(`/upload/${fileName}`, chunk, {
    headers: {
      "Content-Type": "application/octet-stream", // 二进制流格式
    },
    params: {
      chunkFileName,
    },
  });
}

...

【扩展点】并发量队列控制,在同一时间内最多并行上传比如说5个分片

前端部分目前我们处理完了,开始写后端

创建项目:

npm init -y
npm install express morgan http-status-codes http-errors cors fs-extra

新建文件index.js

// 引入 Express 模块
const express = require("express");
// 引入 Morgan 日志记录模块
const logger = require("morgan");
// 引入 HTTP 状态码
const { StatusCodes } = require("http-status-codes");
// 引入 CORS 跨域资源共享模块
const cors = require("cors");
// 引入 path 模块处理文件路径
const path = require("path");
// 引入 fs-extra 模块处理文件系统
const fs = require("fs-extra");
// 引入创建 HTTP 错误的模块
const createError = require('http-errors');

// 定义每个文件块的大小 
const CHUNK_SIZE = 100 * 1024 * 1024; 
// 定义公共文件夹的路径 
const PUBLIC_DIR = path.resolve(__dirname, "public"); 
// 定义临时文件夹的路径 
const TEMP_DIR = path.resolve(__dirname, "temp"); 
// 确保公共文件夹存在 
fs.ensureDirSync(PUBLIC_DIR); 
// 确保临时文件夹存在 
fs.ensureDirSync(TEMP_DIR);

// 创建 Express 应用
const app = express();
// 使用 Morgan 中间件进行日志记录
app.use(logger("dev"));
// 解析 JSON 格式的请求体
app.use(express.json());
// 解析 URL 编码的请求体
app.use(express.urlencoded({ extended: true }));
// 使用 CORS 中间件允许跨域请求
app.use(cors());
// 设置静态文件目录
app.use(express.static(path.resolve(__dirname, "public")));

// 启动服务器监听 8080 端口
app.listen(8080, () => {
  console.log("Server started on port 8080");
});

引入我们所需要的模块,定义公共文件夹(用来存放上传并合并好的文件)和临时文件夹(用来存放分片的文件)。

开始写上传分片的接口。通过路径参数和查询参数分别获取到文件名和分片名,然后创建保存文件和分片的路径。为了将文件写入硬盘,创建一个可写流。因为后面会实现暂停操作,如果客户端点击了暂停按钮,会取消上传的操作,取消之后会在服务端触发请求对象的aborted事件,关闭可写流。使用管道的方式将请求中的请求体流数据写入到文件中。

// 处理上传文件的请求
app.post("/upload/:fileName", async (req, res, next) => {
  try {
    // 通过路径参数获取文件名
    const { fileName } = req.params;
    // 通过查询参数获取分片名
    const { chunkFileName } = req.query;
    // 创建用户保存此文件的分片目录
    const chunkDir = path.resolve(TEMP_DIR, fileName);
    // 分片的文件路径
    const chunkFilePath = path.resolve(chunkDir, chunkFileName);
    // 先确定分片目录存在
    await fs.ensureDir(chunkDir);
    // 创建此文件的可写流
    const ws = fs.createWriteStream(chunkFilePath, {});
    // 如果请求被中断,关闭可写流
    req.on("aborted", () => {
      ws.close();
    });
    // 管道流写入可写流
    await pipeStream(req, ws);
    // 返回成功响应
    res.json({ success: true });
  } catch (error) {
    next(error);
  }
});

// 定义管道流函数
function pipeStream(rs, ws) {
  return new Promise((resolve, reject) => {
    // 把可读流中的数据写入可写流中
    rs.pipe(ws).on("finish", resolve).on("error", reject);
  });
}

接下来开始写合并文件的接口。要想合并,我们需要拿到所有分片,找到分片所在目录,读取里面的文件,顺序可能是乱的,要对其进行排序。为了提升性能,需要进行并行写入,之后删除临时文件夹。

// 处理合并文件的请求
app.get("/merge/:fileName", async (req, res, next) => {
  // 通过路径参数获取文件名
  const { fileName } = req.params;
  try {
    // 合并文件块
    await mergeChunks(fileName);
    // 返回成功响应
    res.json({ success: true });
  } catch (error) {
    next(error);
  }
});

// 定义合并块文件的函数
async function mergeChunks(fileName) {
  // 定义一个合并后的文件路径
  const mergedFilePath = path.resolve(PUBLIC_DIR, fileName);
  const chunkDir = path.resolve(TEMP_DIR, fileName);
  // 读取文件,返回一个分片文件对应的数组
  const chunkFiles = await fs.readdir(chunkDir);
  // 对分片按索引进行排序
  chunkFiles.sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
  // 合并所有分片文件
  // chunkFile 分片文件路径;index 分片索引
  const pipes = chunkFiles.map((chunkFile, index) => {
    // 第一个参数,可读流;第二个参数,可写流
    return pipeStream(
      // autoClose: true  写入之后自动关闭该可读流
      fs.createReadStream(path.resolve(chunkDir, chunkFile), {
        autoClose: true,
      }),
      fs.createWriteStream(mergedFilePath, { start: index * CHUNK_SIZE })
    );
  });
  // 并发将每个分片的数据写入到目标文件中去
  await Promise.all(pipes);
  // 删除分片的文件和文件夹
  // node v14.14.0 及以后版本,推荐使用 fs.rm()
  await fs.rmdir(chunkDir, { recursive: true }); // recursive: true:递归删除文件夹及其中的所有文件和子文件夹
}

注:CHUNK_SIZE的值要和前端保持一致,当然分片的大小也可以从前端传过来。

2.6 上传进度

上传的过程缺少反馈,比如说上传报错了没有、上传到哪一步了,用户对此一无所知。因此我们需要一个进度条来体现上传进度。

定义一个进度状态,在使用axios发请求的时候,可以使用一个参数 onUploadProgress,它可以用来把控上传进度。当上传进度100%后,我们要重置进度条 ,且要将选中的文件、预览信息都要清空。

在useDrag钩子函数中返回一个新的函数,用来清除上传的文件

function useDrag(uploadContainerRef) {
    ...
    
  const resetFileStatus = () => {
      setSelectedFile(null);
      setFilePreview({ url: null, type: null });
  };
  
  return { selectedFile, filePreview, resetFileStatus };
}
...

FileUploader.jsx

import { useRef, useState } from "react";
import { Button, message, Progress } from "antd";
...

function FileUploader() {
   ...
  const { selectedFile, filePreview, resetFileStatus } = useDrag(uploadContainerRef);
  let [uploadProgress, setUploadProgress] = useState({});
  
  const resetAllStatus = () => {
    resetFileStatus();
    setUploadProgress({});
  }
  
  const handleUpload = async () => {
    ...
    await uploadFile(selectedFile, fileName, setUploadProgress, resetAllStatus);
  };
   
   const renderProgress = () => {
    return Object.keys(uploadProgress).map((chunkName, index) => (
      <div key={chunkName}>
        <span>切片{index}:</span>
        <Progress percent={uploadProgress[chunkName]} />
      </div>
    ));
  };
  
  return (
    <>
      ...
      {renderProgress()}
    </>
  );
}

// 实现切片上传大文件
async function uploadFile(file, fileName, setUploadProgress, resetAllStatus) {
  ...
  // 实现并行上传
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    return createRequest(fileName, chunk, chunkFileName, setUploadProgress);
  });
  try {
    // 并行上传每个分片
    await Promise.all(requests);
    // 等全部的分片上传完了,会向服务器发送一个合并文件的请求
    await axiosInstance.get(`/merge/${fileName}`);
    message.success("文件上传完成");
    resetAllStatus();
  } catch (error) {
    ...
  }
}

function createRequest(fileName, chunk, chunkFileName, setUploadProgress) {
  return axiosInstance.post(`/upload/${fileName}`, chunk, {
    headers: {
      "Content-Type": "application/octet-stream", // 二进制流格式
    },
    params: {
      chunkFileName,
    },
    // 上传进度发生变化的事件回调函数
    onUploadProgress: (progressEvent) => {
      // 用已经上传的字节数除以总字节数字得到完成的百分比
      const percentCompleted = Math.round(
        (progressEvent.loaded * 100) / progressEvent.total
      );
      setUploadProgress((preProgress) => ({
        ...preProgress,
        // key 分片名称  value 上传进度百分比
        [chunkFileName]: percentCompleted,
      }));
    },
  });
}

...

效果如图所示:

WechatIMG1639.jpeg

也可以写一个方法用来看到总进度完成情况,计算平均值即可

function FileUploader() {
  ...
    
  const renderTotalProgress = () => {
    const percents = Object.values(uploadProgress);
    const totalPercent = Math.round(percents.reduce((pre, cur) => pre + cur, 0) / percents.length);
    return (
      <div>
        <span>总进度:</span>
        <Progress percent={totalPercent} />
      </div>
    );
  };

  return (
    <>
      ...
      {renderTotalProgress()}
    </>
  );
}

2.7 秒传

如果上传的文件在服务器中已经存在了,则不需要重复上传。要想实现秒传的功能,需要服务端提供一个接口,返回已经上传的分片和大小。

来写下这个接口:

// 处理文件验证请求
app.get("/verify/:fileName", async (req, res, next) => {
  // 从请求参数中获取文件名
  const { fileName } = req.params;
  // 检查文件是否已在公共目录中存在
  const filePath = path.resolve(PUBLIC_DIR, fileName);
  const existFile = await fs.pathExists(filePath);
  // 如果文件已存在,不需要上传
  if (existFile) {
    return res.json({ success: true, needUpload: false });
  }
  
  return res.json({ success: true, needUpload: true });
});

前端在上传文件切片前就调用这个接口来判断服务器是否存在该文件

// 实现切片上传大文件
async function uploadFile(file, fileName, setUploadProgress, resetAllStatus) {
  // 判断上传的文件是否存在于服务器
  const {needUpload} = await axiosInstance.get(`/verify/${fileName}`);
  if (!needUpload) { 
    message.success("文件已存在,秒传成功"); 
    return resetAllStatus(); 
  }
  ...
}

2.8 暂停上传

有暂停就有恢复,所以需要一个状态来控制。另外取消上传请求要用到axios.CancelToken,这是 axios 库提供的一个功能,允许你在发起请求后,如果需要,取消这个请求。这对于处理那些不再需要的请求(例如用户导航离开当前页面,或者应用程序决定不再需要结果)是非常有用的。

...
import axios from "axios";
...

// 定义上传的状态
const UploadStatus = {
  NOT_STARTED: "NOT_STARTED", // 初始状态,尚未开始上传
  UPLOADING: "UPLOADING", // 上传中(包括暂停后恢复上传)
  PAUSED: "PAUSED", // 已暂停上传
};

function FileUploader() {
  ...
  // 控制上传的状态
  const [uploadStatus, setUploadStatus] = useState(UploadStatus.NOT_STARTED);
  // 存放所有上传请求的取消token
  const [cancelTokens, setCancelTokens] = useState([]);
  
  const resetAllStatus = () => {
    resetFileStatus();
    setUploadProgress({});
    // 重置状态方法中添加修改上传状态为初始状态
    setUploadStatus(UploadStatus.NOT_STARTED);
  };
  
  
  const handleUpload = async () => {
    if (!selectedFile) {
      message.error("你尚未选中任何文件!");
      return;
    }
    // 上传状态改为上传中
    setUploadStatus(UploadStatus.UPLOADING);
    // 传参加上setCancelTokens方法
    const fileName = await getFileNmae(selectedFile);
    await uploadFile(
      selectedFile,
      fileName,
      setUploadProgress,
      resetAllStatus,
      setCancelTokens
    );
  };
  
  // 暂停上传
  const pauseUpload = async () => {
    setUploadStatus(UploadStatus.PAUSED);
    cancelTokens.forEach(cancelToken => cancelToken.cancel('用户暂停了上传'))
  }
  
  // 根据上传状态的不同,页面也就显示不同的按钮
  const renderButton = () => {
    switch (uploadStatus) {
      case UploadStatus.NOT_STARTED:
        return <Button onClick={handleUpload}>上传</Button>;
      case UploadStatus.UPLOADING:
        return <Button onClick={pauseUpload}>暂停</Button>;
      case UploadStatus.PAUSED:
        return <Button onClick={handleUpload}>恢复上传</Button>;
    }
  };
  
  const renderProgress = () => {
    // 上传状态为非初始状态页面才显示切片进度和总进度
    if (uploadStatus !== UploadStatus.NOT_STARTED) {
      let totalProgress = renderTotalProgress();
      let chunkProgress = Object.keys(uploadProgress).map(
        (chunkName, index) => {
          return (
            <div key={chunkName}>
              <span>切片{index}:</span>
              <Progress percent={uploadProgress[chunkName]} />
            </div>
          );
        }
      );
      return (
        <>
          {totalProgress}
          {chunkProgress}
        </>
      );
    }
  };

  const renderTotalProgress = () => {
    const percents = Object.values(uploadProgress);
    const totalPercent = Math.round(
      percents.reduce((pre, cur) => pre + cur, 0) / percents.length
    );
    return (
      <div>
        <span>总进度:</span>
        <Progress percent={totalPercent} />
      </div>
    );
  };
  
  return (
    <>
      <div className="upload-container" ref={uploadContainerRef}>
        {renderFilePreview(filePreview)}
      </div>
      {renderButton()}
      {/* 将切片进度和总进度合在一起 */}
      {renderProgress()}
    </>
  );
}

async function uploadFile(
  file,
  fileName,
  setUploadProgress,
  resetAllStatus,
  setCancelTokens
) {
  ...
  // 创建新的存储了每个上传任务的 CancelToken 数组
  const newCancelTokens = [];
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    // 每个chunk对应创建一个新的 CancelToken 源
    const cancelToken = axios.CancelToken.source();
    newCancelTokens.push(cancelToken);
    // 传参加上cancelToken
    return createRequest(fileName, chunk, chunkFileName, setUploadProgress, cancelToken);
  });
  try {
    // 保存新的取消令牌数组
    setCancelTokens(newCancelTokens);
    await Promise.all(requests);
    await axiosInstance.get(`/merge/${fileName}`);
    message.success("文件上传完成");
    resetAllStatus();
  } catch (error) {
    // 检查错误是否由请求取消触发
    if (axios.isCancel(error)) {
      console.log("上传暂停");  
      message.warning("上传暂停");
    } else {
      console.log("上传出错", error);
      message.error("上传出错");
    }
  }
}

function createRequest(fileName, chunk, chunkFileName, setUploadProgress, cancelToken) {
  return axiosInstance.post(`/upload/${fileName}`, chunk, {
    ...,
    // cancelToken: axios 内置的取消令牌
    // 通过 cancelToken.token 将取消令牌传递给请求,使得能够在需要时取消上传
    cancelToken: cancelToken.token
  });
} 

2.9 断点续传

在点击上传按钮之后,再点击暂停按钮,这时候去查看服务端文件夹temp,里面有了一些切片文件。若此时点击恢复上传按钮,即重新上传,那么怎么做到呢?

我们需要知道已经上传了哪些分片到服务器端,每个分片上传了多少字节。

改造后端verify接口:

app.get("/verify/:fileName", async (req, res, next) => {
  const { fileName } = req.params;
  const filePath = path.resolve(PUBLIC_DIR, fileName);
  const existFile = await fs.pathExists(filePath);
  if (existFile) {
    return res.json({ success: true, needUpload: false });
  }
  // 检查临时目录是否存在
  const tempDir = path.resolve(TEMP_DIR, fileName);
  const existDir = await fs.pathExists(tempDir);
  // 存放已经上传的分片的对象数组
  let uploadedChunkList = [];
  if (existDir) {
    // 获取临时目录中的所有分片对应的文件
    const chunkFiles = await fs.readdir(tempDir);
    // 读取每个分片的文件信息,主要是其文件大小,即已经上传的文件大小
    uploadedChunkList = await Promise.all(
      chunkFiles.map(async (file) => {
        const stat = await fs.stat(path.resolve(tempDir, file));
        return { chunkFileName: file, size: stat.size };
      })
    );
  }
  // 若没有该文件,则服务器还需要客户端上传此文件
  return res.json({ success: true, needUpload: true, uploadedChunkList });
});

该接口还要返回uploadList,即已经上传的分片和分片大小,那么客户端只需上传分片剩下的数据即可。

这也是为什么实现秒传功能时要客户端来校验的原因,而不是通过服务端来处理。若客户端不去校验,就不知道该上传哪个分片,只能全给服务端了,服务端再去做判断,这样会浪费性能。

前端代码处理:在并行上传分片的时候,要考虑到现在发送给服务器的可能不是完整的chunk,要进行截取。

async function uploadFile(
  file,
  fileName,
  setUploadProgress,
  resetAllStatus,
  setCancelTokens
) {
  // 添加并解构服务端返回的uploadedChunkList
  const { needUpload, uploadedChunkList } = await axiosInstance.get(
    `/verify/${fileName}`
  );
  if (!needUpload) {
    message.success("文件已存在,秒传成功");
    return resetAllStatus();
  }
  const chunks = createFileChunks(file, fileName);
  const newCancelTokens = [];
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    const cancelToken = axios.CancelToken.source();
    newCancelTokens.push(cancelToken);
    // 以后向服务器发送的数据可能就不再是完整的分片数据
    // 判断当前的分片是否已经上传过服务器
    const existingChunk = uploadedChunkList.find((uploadChunk) => {
      return uploadChunk.chunkFileName === chunkFileName;
    });
    // 若存在existingChunk,说明分片已经上传过一部分了,或者完全上传完成
    if (existingChunk) {
      // 获取已经上传的分片的大小
      const uploadSize = existingChunk.size;
      // 从chunk中进行截取,过滤掉已经上传过的大小,得到剩下需要继续上传的内容
      const remainingChunk = chunk.slice(uploadSize);
      // 若剩下的数据为0,即表示上传完毕
      if (remainingChunk.size === 0) {
        return Promise.resolve();
      }
      // 若剩下的数据还有,则需要继续上传剩余的部分
      return createRequest(
        fileName,
        remainingChunk,
        chunkFileName,
        setUploadProgress,
        cancelToken,
        uploadSize // 添加传参——文件要上传的起始位置
      );
    } else {
      return createRequest(
        fileName,
        chunk,
        chunkFileName,
        setUploadProgress,
        cancelToken,
        0 // 添加传参——没有上传过的文件传0
      );
    }
  });
  ...
}

function createRequest(
  fileName,
  chunk,
  chunkFileName,
  setUploadProgress,
  cancelToken,
  start // 添加接受的参数start
) {
  return axiosInstance.post(`/upload/${fileName}`, chunk, {
    ...,
    params: {
      chunkFileName,
      start // 把写入文件的起始位置也作为查询参数发送给服务器
    },
    ...
  });
}

改造后端upload接口

app.post("/upload/:fileName", async (req, res, next) => {
  try {
    const { fileName } = req.params;
    const { chunkFileName } = req.query;
    // 写入文件的起始位置
    // 从查询参数中获取起始位置,如果不是数字则默认为 0
    const start = isNaN(req.query.start) ? 0 : parseInt(req.query.start, 10);
    const chunkDir = path.resolve(TEMP_DIR, fileName);
    const chunkFilePath = path.resolve(chunkDir, chunkFileName);
    await fs.ensureDir(chunkDir);
    // 创建文件可写流的时候指定start,start即表示写入的起始位置 (*)
    const ws = fs.createWriteStream(chunkFilePath, { start, flags: "a" }); 
    req.on("aborted", () => {
      ws.close();
    });
    await pipeStream(req, ws);
    res.json({ success: true });
  } catch (error) {
    next(error);
  }
});

ps:代码中 * 处flags: "a"fs.createWriteStream 的一个选项,用于指定文件打开模式,其中 "a" 代表 追加模式

  • 如果文件已存在,则在文件末尾追加内容,而不会覆盖现有内容。

  • 如果文件不存在,则会创建一个新文件。

其他常见文件打开模式:

Flag描述
"r"只读模式打开文件。如果文件不存在会报错。
"r+"读写模式打开文件。如果文件不存在会报错。
"w"只写模式打开文件。如果文件存在,则会清空文件内容。
"w+"读写模式打开文件。如果文件存在,则会清空文件内容。
"a"只写模式打开文件。如果文件存在,则追加内容。
"a+"读写模式打开文件。如果文件存在,则追加内容。

问题1: 当我们开始断点续传的时候,发现进度条会回弹一下,如下图gif所示:

upload.gif

问题2:当我们在上传文件后,点击暂停按钮,此时去刷新页面,然后再重新上传该视频,会发现进度条不是从原来的地方延展,而是从0开始,这是为何?如下图所示:

未命名.gif

我们在上传的时候计算进度百分比是利用了已经上传的字节数除以总字节数,但是每次都是从0开始的,所以totalSize不是总进度,而是这一次上传的进度。

比如说文件有100个字节,你原来传了50个字节了,还剩50个字节,那么进度条指的是剩下的这50个字节的百分比,所以每次都是从0开始的。我们只要让进度条显示的是总的进度那么上述问题2就迎刃而解了。

async function uploadFile(
  file,
  fileName,
  setUploadProgress,
  resetAllStatus,
  setCancelTokens
) {
  ...
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    ...
    if (existingChunk) {
      ...
      return createRequest(
        fileName,
        remainingChunk,
        chunkFileName,
        setUploadProgress,
        cancelToken,
        uploadSize,
        chunk.size // 文件的总大小
      );
    } else {
      return createRequest(
        fileName,
        chunk,
        chunkFileName,
        setUploadProgress,
        cancelToken,
        0,
        chunk.size // 文件的总大小
      );
    }
  });
  ...
}

function createRequest(
  fileName,
  chunk,
  chunkFileName,
  setUploadProgress,
  cancelToken,
  start,
  totaSize // 文件的总大小
) {
  return axiosInstance.post(`/upload/${fileName}`, chunk, {
    ...,
    onUploadProgress: (progressEvent) => {
      // (progressEvent.loaded 本次上传成功的字节 + start 上次上传成功的字节)/ 总字节数
      // 之前的progressEvent.total是当前总字节数不是总的字节数
      const percentCompleted = Math.round(
        ((progressEvent.loaded + start) * 100) / totaSize
      );
      setUploadProgress((preProgress) => ({
        ...preProgress,
        [chunkFileName]: percentCompleted,
      }));
    },
    ...
  });
}

针对问题1,我们在上传的时候对于进度条要给个默认进度值,即计算下之前的值

async function uploadFile(
  file,
  fileName,
  setUploadProgress,
  resetAllStatus,
  setCancelTokens
) {
  ...
  const requests = chunks.map(({ chunk, chunkFileName }) => {
    ...
    if (existingChunk) {
      const uploadSize = existingChunk.size;
      const remainingChunk = chunk.slice(uploadSize);
      if (remainingChunk.size === 0) {
        // 全部上传完毕,修改进度条百分比为100%
        setUploadProgress((preProgress) => ({
          ...preProgress,
          [chunkFileName]: 100,
        }));
        return Promise.resolve();
      }
      // 未完全上传,给个默认进度值,即计算下之前的值
      setUploadProgress((preProgress) => ({
        ...preProgress,
        [chunkFileName]: (uploadSize * 100) / chunk.size,
      }));
      ...
    } else {
      ...
    }
  });
  ...
}

3. 拓展

3.1 Web Workers

要优化大文件上传并利用Web Workers的优势,你可以将耗时操作的逻辑移到Web Worker中。这样可以防止耗时的文件操作阻塞UI线程,从而提升用户界面的响应性。

基本概念

  • 多线程执行:Web Workers 允许你在浏览器中创建一个独立的线程来执行 JavaScript 代码,这有 助于处理高计算量或耗时的任务,而不会影响页面的性能和响应性。

  • 与主线程隔离:Worker 线程与主线程是完全隔离的,它们有自己的全局上下文,不能直接访问DOM、window 对象等。所有的数据交换都通过消息传递进行。

应用场景

Web Workers 非常适合用于那些需要大量计算且可能会阻塞 UI 的任务,如图像处理、大数据计算、复杂的排序或搜索操作等。通过将这些操作移到后台线程,可以保持前端界面的流畅和响应。

注意事项

  • 数据传输:与 Worker 之间的数据传递是通过结构化克隆算法实现的,这意味着传递的对象会被复制而非共享。

  • 限制:Workers 不能访问主线程的全局变量或函数。同样,它们不能直接操作 DOM。

  • 资源和性能:虽然 Workers 可以提升性能,但它们也是资源密集型的。创建过多的 Workers 可能会消耗大量内存和处理能力。

在大文件上传中,计算文件hash名的过程比较缓慢,也没有什么反馈,我们可以将这个过程放到 Web Worker 中,不要阻塞主线程。

创建 Worker 文件:需要放到静态文件根目录下。在src平级下新建文件夹public,在public下新建文件filenameWorker.js

// selef 即当前的window
self.addEventListener('message', async (event) => {
  // 获取主进程发过来的文件
  const file = event.data;
  // 单独开一个进程来进行hash计算并得到新的文件名
  const fileName = await getFileNmae(file);
  // 将文件名再发回主线程
  self.postMessage(fileName);
})

// 将获取hash文件名的代码从文件FileUploader.jsx处cv过来
async function getFileNmae(file) {
  const fileHash = await calculateFileHash(file);
  const fileExtension = file.name.split(".").pop();
  return `${fileHash}.${fileExtension}`;
}

async function calculateFileHash(file) {
  const arrayBuffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
  return bufferToHex(hashBuffer);
}

function bufferToHex(buffer) {
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

然后在主线程中,创建 Worker 的实例,并与之通信。

FileUploader.jsx:

import { ..., useEffect } from "react";
import { ..., Spin } from "antd";

...

function FileUploader() {
  ...
  // 定义 Worker
  const [filenameWorker, setFilenameWorker] = useState(null);
  // 定义一个是否正在计算文件hash名的状态
  const [isCalculatingFileName, setIsCalculatingFileName] = useState(false);

  useEffect(() => {
    // 创建 Worker 实例
    const filenameWorker = new Worker('/filenameWorker.js');
    setFilenameWorker(filenameWorker);
  }, [])

  ...

  const handleUpload = async () => {
    if (!selectedFile) {
      message.error("你尚未选中任何文件!");
      return;
    }
    setUploadStatus(UploadStatus.UPLOADING);
    // 向 Worker 发送消息,让其帮助计算文件名
    filenameWorker.postMessage(selectedFile);
    setIsCalculatingFileName(true);
    // 监听 Worker 发送回来的消息,接收计算好的文件名
    filenameWorker.onmessage = async (event) => {
      setIsCalculatingFileName(false);
      // 上传文件
      await uploadFile(
        selectedFile,
        event.data, // 事件的数据即文件名
        setUploadProgress,
        resetAllStatus,
        setCancelTokens
      );
    }
  };
  
  ...
  
  return (
    <>
      <div className="upload-container" ref={uploadContainerRef}>
        {renderFilePreview(filePreview)}
      </div>
      {renderButton()}
      {/* 计算文件名时给个加载状态的样式 */}
      {isCalculatingFileName && (
        <Spin>
          <span>正在计算文件名...</span>
        </Spin>
      )}
      {renderProgress()}
    </>
  );
}

3.2 重试机制

如果大文件上传失败了我们需要可以自动重试。在文件 constant.js 中定义一个常量:

...
export const MAX_RETRIES = 3; // 若上传失败最多可以重试三次

在方法uploadFile中多加一个传参,表示第几次重试,默认值为0。上传失败了,只要重试次数小于MAX_RETRIES,则再次调用uploadFile方法。

FileUploader.jsx:

...
import { ..., MAX_RETRIES } from "./constant";

...
async function uploadFile(
  file,
  fileName,
  setUploadProgress,
  resetAllStatus,
  setCancelTokens,
  retryCount = 0 // 重试次数
) {
  ...
  try {
    ...
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log("上传暂停");
      message.warning("上传暂停");
    } else {
      if (retryCount < MAX_RETRIES) {
        console.log("上传出错了,重试中...");
        uploadFile(file, fileName, setUploadProgress, resetAllStatus, setCancelTokens, retryCount + 1);
      } else {
        console.log("上传出错", error);
        message.error("上传出错");
      }
    }
  }
}