大文件上传:告别传统传输瓶颈,让数据流转更高效

流程图解析

大文件传输的'高速公路':不堵车、不限速、不抛锚

传统文件传输的四大困扰

  • 传输卡顿/中断
    网络波动、服务器超时导致反复重传,耗时耗力。
  • 格式与大小限制
    老旧系统仅支持小文件(如<100MB),无法满足高清视频、设计源文件等需求。
  • 安全性缺失
    明文传输易被截获,敏感数据泄露风险高。
  • 进度不可控
    无法实时查看进度,失败后需从头开始,用户体验差。

大文件上传解决方案

  • 智能分片上传
    将文件自动拆分为多块并行传输,网络波动自动续传,速度提升300%。
  • 格式/容量无感化
    支持100GB+超大文件、100+格式(视频/3D模型/RAW图等),无需压缩。
  • 企业级安全保障
    传输加密(AES-256+TLS)+ 权限管控 + 日志溯源,满足GDPR/等保合规。
  • 全流程可视化
    实时进度条、预估剩余时间、失败自动定位断点,用户掌控感更强

大文件上传核心功能与实现原理

  1. 分片上传

    • 原理:将大文件切割为多个小块(如每片5MB),并行上传至服务器。
    • 示例:上传10GB视频时,自动拆分为2000个5MB分片,分批上传。
  2. 合并切片

    • 原理:服务端按顺序将所有分片拼接成完整文件,并校验完整性(如MD5比对)。
    • 示例:上传完成后,服务端在1秒内合并2000个分片生成原始视频文件。
  3. 秒传(极速上传)

    • 原理:计算文件的唯一哈希值(如SHA-256),若服务器已存在相同文件,则直接复用。
    • 示例:用户重复上传1GB文件时,系统0秒完成"上传"(实际跳过传输)。
  4. 断点续传

    • 原理:记录已上传分片信息,网络中断后自动从断点继续上传。
    • 示例:上传8GB文件到50%时断网,恢复后仅需传剩余50%,而非重新开始。
  5. 上传进度实时反馈

    • 原理:前端动态计算已传分片占比,实时显示进度条、速度、剩余时间。
    • 示例:用户上传20GB文件时,界面显示"65%完成,预计剩余10分钟"。

典型应用场景

  1. 企业云盘

    • 场景:跨国团队传输4K视频工程文件(80GB),分片上传确保跨国网络波动下1小时完成,断点续传避免因VPN中断重传。
  2. 在线医疗影像系统

    • 场景:上传患者CT扫描DICOM文件(30GB/人),秒传技术避免重复存储相同病例,加密分片满足HIPAA合规要求。
  3. 云备份服务

    • 场景:企业每日备份500GB数据库,实时进度条让管理员预估完成时间,合并切片确保备份完整性。
  4. 视频平台创作工具

    • 场景:博主上传未压缩8K素材(200GB),分片并行跑满带宽,进度反馈辅助规划发布时间。

达成 让数据流动,像呼吸一样自然! 传大文件?我们连'卡顿'这个词都传丢了!

开始展示

分片上传

这个功能点分为前端的文件分片、计算hash值、上传分片文件和服务端的创建分片目录并存储分片

  1. html标签元素
复制代码
<button id="upload" onClick="handleUpload()">上传</button>
<p id="hash-progress"></p>
<p id="total-slice"></p>
  1. 获取上传的文件信息
ini 复制代码
let fileName = "";
 let fileSize = 0;
 let fileHash = ""; // file hash
 let fileChunkListData = [];
 const handleUpload = async () => {
        const file = document.getElementById("file").files[0];
        if (!file) return alert("请选择文件!");
        fileName = file.name; // 文件名
        fileSize = file.size; // 文件大小
        // 文件分片
        const fileChunkList = createFileChunk(file);
        // 计算文件hash
        fileHash = await calculateHash(fileChunkList);
};
  1. 对文件进行分片:使用slice() 方法对大文件进行分片 ,并把分片的内容、大小等信息都放入到分片列表中,最后在页面上显示一下分片数量
ini 复制代码
  // 文件切片
  const createFileChunk = (file) => {
    const chunkList = [];
    // 计算文件切片总数
    const sliceSize = 5 * 1024 * 1024; // 5MB
    const totalSlice = Math.ceil(file.size / sliceSize);
    for (let i = 1; i <= totalSlice; i++) {
      let chunk;
      if (i === totalSlice) {
        chunk = file.slice((i - 1) * sliceSize, fileSize);
      } else {
        chunk = file.slice((i - 1) * sliceSize, i * sliceSize);
      }
      chunkList.push({
        file: chunk,
        fileSize,
      });
    }
    const sliceText = `一共分片:${totalSlice}`;
    document.getElementById("total-slice").innerHTML = sliceText;
    return chunkList;
  };
  1. 计算文件hash: 使用spark-md5 分别计算每个分片的hash值,最后得到整个文件hash值。计算hash值需要比较长的时间,可以在页面上输出计算hash值的进度。可以在web-work中计算hash

spark-md5:它通过增量计算和利用Web Workers在后台处理,避免阻塞主线程。这对于大文件来说尤其重要,因为直接计算整个文件的MD5可能会非常慢,甚至导致浏览器无响应。

// 复制代码
const calculateHash = (fileChunkList) => {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer(); // 创建一个 SparkMD5 实例,用于计算 MD5 哈希值
    let count = 0; // 计数器,用于跟踪处理的块数
    const loadNext = (index) => {
      const render = new FileReader(); // 创建一个 FileReader 实例,用于读取文件内容
      render.readAsArrayBuffer(fileChunkList[index].file); // 读取当前块的内容为ArrayBuffer
      render.onload = (e) => { // 文件块读取完成时的回调函数
        count++; // 增加计数器
        spark.append(e.target.result); // 将读取到的块内容传递给 SparkMD5 实例
        if (count === fileChunkList.length) { // 如果所有块都处理完毕
          resolve(spark.end()); // 计算并返回最终的 MD5 哈希值
        } else { 
          const percentage = parseInt(((count + 1) / fileChunkList.length) * 100); 
          const progressText = `计算hash值:${percentage}%`; 
          document.getElementById("hash-progress").innerHTML = progressText; 
          loadNext(count); // 处理下一个块
        }
      };
    };
    loadNext(0); // 开始处理第一个块
  });
};
  1. 需要将分片数据全部上传到服务器,这里需要注意是的分片的hash值是 ${fileHash}-{index}, 服务端会根据这个hash值创建分片文件。
ini 复制代码
const handleUpload = async () => {
  const file = document.getElementById("file").files[0];
  if (!file) return alert("请选择文件!");
  fileName = file.name; // 文件名
  fileSize = file.size; // 文件大小
  const fileChunkList = createFileChunk(file);
  fileHash = await calculateHash(fileChunkList); // 文件hash
  fileChunkListData = fileChunkList.map(({ file, size }, index) => {
    const hash = `${fileHash}-${index}`;
    return {
      file,
      size,
      fileName,
      fileHash,
      hash,
    };
  });
  await uploadChunks();
};
javascript 复制代码
// 上传
const uploadChunks = async () => {
const requestList = fileChunkListData
  .map(({ file, fileHash, fileName, hash }, index) => {
    const formData = new FormData();
    formData.append("file", file);
    formData.append("fileHash", fileHash);
    formData.append("name", fileName);
    formData.append("hash", hash);
    return { formData };
  })
  .map(async ({ formData }) => {
    return requestApi({
      url: `${HOST}`,
      method: "POST",
      body: formData,
    });
  });
await Promise.all(requestList);
};
  1. 请求方法
javascript 复制代码
/**
 * @description: 封装fetch
 * @param {Object} FetchConfig fetch config
 * @return {Promise} fetch result
 */
const requestApi = ({
  url,
  method = "GET",
  ...fetchProps
}) => {
  return new Promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchProps,
    });
    resolve(res.json());
  });
};
服务端
javascript 复制代码
import * as http from "http"; //ES 6
import path from "path";

const server = http.createServer();

server.on("request", async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Headers", "*");
  if (req.method === "OPTIONS") {
    res.status = 200;
    res.end();
    return;
  }
});

server.listen(3000, () => console.log("正在监听 3000 端口"));
  1. 接下来,我们就可以在里面添加上传分片的接口。使用multiparty读取到客户端提交的表单数据后,判断切片目录是否存在,不存在就使用 fileHash 值创建一个临时的分片目录,并使用fs-extra 的move 方法存储文件分片到对应的分片目录下

multiparty 是 Node.js 中处理 multipart/form-data 的基础工具,适合需要精细控制文件上传流程的场景

javascript 复制代码
import * as http from "http"; //ES 6
import path from "path";
import { fileURLToPath } from "url";
import fse from "fs-extra";
import multiparty from "multiparty";

const extractExt = (filename) =>
  filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名

const server = http.createServer();
// 获取当前模块文件的路径
const __filename = fileURLToPath(import.meta.url);

// 获取当前模块文件所在目录的路径
const __dirname = path.dirname(__filename);

// 设置大文件存储目录
const UPLOAD_DIR = path.resolve(__dirname, "dist");

server.on("request", async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Headers", "*");
  if (req.method === "OPTIONS") {
    res.status = 200;
    res.end();
    return;
  }

  if (req.url === "/") {
    const multipart = new multiparty.Form();
    multipart.parse(req, async (err, fields, files) => {
      if (err) {
        console.error(err);
        res.status = 500;
        res.end("process file chunk failed");
        return;
      }
      const [chunk] = files.file;
      const [hash] = fields.hash;
      const [filename] = fields.name;
      const [fileHash] = fields.fileHash;
      const chunkDir = `${UPLOAD_DIR}/${fileHash}`; // dist/xxxx 
      console.log("chunkDir", chunkDir);
      // 最后的文件路径
      const filePath = path.resolve(
        UPLOAD_DIR,
        `${fileHash}${extractExt(filename)}`
      );
      console.log("filePath", filePath);
      // 文件存在直接返回
      if (fse.existsSync(filePath)) {
        res.end(
          JSON.stringify({
            messaage: "file exist",
          })
        );
        return;
      }
      // 切片目录不存在,创建切片目录
      if (!fse.existsSync(chunkDir)) {
        await fse.mkdirs(chunkDir);
      }
      // chunk.path 是服务器临时路径,
      await fse.move(chunk.path, `${chunkDir}/${hash}`);
      res.status = 200;
      res.end(
        JSON.stringify({
          messaage: "received file chunk",
        })
      );
    });
  }
});

server.listen(3000, () => console.log("正在监听 3000 端口"));

合并分片

  1. 在上传完文件分片之后,我们就可以对所有文件分片进行合并,这里需要请求一个合并分片的接口,需要传递文件的fileHash 和 filename
javascript 复制代码
//上传分片
const uploadChunks = async () => {
  //...
  await mergeRequest(fileName, fileHash);
};

// 合并分片
const mergeRequest = async (fileName, fileHash) => {
  await requestApi({
    url: `${HOST}/merge`,
    method: "POST",
    headers: {
      "Content-Type": "application/json;charset=utf-8",
    },
    body: JSON.stringify({
      filename: fileName,
      fileHash,
    }),
  });
};

服务端

  1. 实现一下合并分片的接口,首先需要读取请求中的数据,然后拼接出合并后的文件名称 <math xmlns="http://www.w3.org/1998/Math/MathML"> u p l o a d D i r / {uploadDir}/ </math>uploadDir/{fileHash}${ext},最后调用合并分片方法。
ini 复制代码
const extractExt = (filename) =>
  filename.slice(filename.lastIndexOf("."), filename.length); // 提取后缀名
  
const resolvePost = (req) =>
  new Promise((resolve) => {
    let chunk = "";
    req.on("data", (data) => {
      chunk += data;
    });
    req.on("end", () => {
      resolve(JSON.parse(chunk));
    });
  }); 
 
 if (req.url === "/merge") {
    const data = await resolvePost(req);
    const { filename, fileHash } = data;
    const ext = extractExt(filename);
    const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
    await mergeFileChunk(filePath, fileHash);
    res.status = 200;
    res.end(JSON.stringify("file merged success"));
  }
  1. 合并切片功能最核心的功能就是根据fileHash读取对应分片目录下的分片文件列表,并按照分片下标进行排序,避免后面合并时顺序错乱。然后,使用 writeFile 方法创建一个空文件,再使用appendFileSync 依次向文件中添加分片数据,最后删除临时的分片目录
javascript 复制代码
// 合并切片
const mergeFileChunk = async (filePath, fileHash) => {
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
  try {
    // 检查目录是否存在
    if (!fse.existsSync(chunkDir)) {
      throw new Error(`Chunk directory does not exist: ${chunkDir}`);
    }
    const chunkPaths = await fse.readdir(chunkDir);
    // 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱
    chunkPaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
    await fse.writeFile(filePath, "");
    chunkPaths.forEach((chunkPath) => {
      fse.appendFileSync(
        filePath,
        fse.readFileSync(`${chunkDir}/${chunkPath}`)
      );
      fse.unlinkSync(`${chunkDir}/${chunkPath}`);
    });
    fse.rmdirSync(chunkDir); // 合并后删除保存切片的目录
  } catch (err) {
    console.error(err);
  }
};

秒传

前端

实现秒传只需要在文件上传之前请求接口验证一下文件是否存在。

javascript 复制代码
const handleUpload = async () => {
  //...
  const { shouldUpload } = await verifyUpload(
    fileName,
    fileHash
  );
  if (!shouldUpload) {
    alert("秒传:上传成功");
    return;
  }
};

//文件秒传
const verifyUpload = async (filename, fileHash) => {
  const data = await requestApi({
    url: `${HOST}/verify`,
    method: "POST",
    headers: {
      "Content-Type": "application/json;charset=utf-8",
    },
    body: JSON.stringify({
      filename,
      fileHash,
    }),
  });
  return data;
};

服务端

如果文件存在shouldUpload 就返回 false,否则就返回 true 。

ini 复制代码
if (req.url === "/verify") {
    const data = await resolvePost(req);
    const { fileHash, filename } = data;
    const ext = extractExt(filename);
    const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
    if (fse.existsSync(filePath)) {
      res.end(
        JSON.stringify({
          shouldUpload: false,
        })
      );
    } else {
      res.end(
        JSON.stringify({
          shouldUpload: true,
        })
      );
    }
  } 

断点续传

  1. 新增两个按钮,来控制文件上传进度。
  2. 改造请求方法,添加 abortControllerList 用于存储需要被取消的请求,如果接口请求成功,则将fetch从 abortControllerList 中移除。
ini 复制代码
/* ... */
<button id="pause" onClick="handlePause()" style="display: none">
  暂停
</button>
<button id="resume" onClick="handleResume()" style="display: none">
  恢复
</button>
/* ... */


/**
 * @description: 封装fetch
 * @param {Object} FetchConfig fetch config
 * @return {Promise} fetch result
 */
let abortControllerList = [];

const requestApi = ({
  url,
  method = "GET",
  onProgress,
  ...fetchProps
}) => {
  const controller = new AbortController();
  abortControllerList.push(controller);
  return new Promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchProps,
      signal: controller.signal,
    });

    // 将请求成功的 fetch 从列表中删除
    const aCIndex = abortControllerList.findIndex(
      (c) => c.signal === controller.signal
    );
    abortControllerList.splice(aCIndex, 1);
    //...
  });
};
  1. 在分片上传也需要做一些改造,将接口中获取到的uploadedList ,从所有分片列表中过滤出去,当已上传的uploadedList 数量加 requestList 的数量等于分片列表fileChunkListData 的数量时才进行分片合并。
javascript 复制代码
let fileName = "",
  fileHash = "",
  fileSize = 0,
  fileChunkListData = [],
  abortControllerList = [];
const HOST = "http://localhost:3000";

//...

const handleUpload = async () => {
  //...
  const { shouldUpload, uploadedList } = await verifyUpload(
    fileName,
    fileHash
  );
  if (!shouldUpload) {
    alert("秒传:上传成功");
    return;
  }
  //...
  await uploadChunks(uploadedList);
};

//上传分片
const uploadChunks = async (uploadedList) => {
  const requestList = fileChunkListData
    .filter(({ hash }) => !uploadedList.includes(hash))
    .map(({ file, fileHash, fileName, hash }, index) =>     {
     //...
    })
    .map(async ({ formData, hash }) => {
  .   //...
    });
  //...
  // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
  //合并分片
  if (
    uploadedList.length + requestList.length ===
    fileChunkListData.length
  ) {
    await mergeRequest(fileName, fileHash);
  }
};

然后,实现一下暂停和恢复的事件处理,暂停是通过调用 AbortController 的 abort() 方法实现。恢复则是重新获取uploadedList 后再进行分片上传实现。

ini 复制代码
//暂停
const handlePause = () => {
  abortControllerList.forEach((controller) => controller?.abort());
  abortControllerList = [];
};
// 恢复
const handleResume = async () => {
  const { uploadedList } = await verifyUpload(fileName, fileHash);
  await uploadChunks(uploadedList);
};

服务端

断点续传是在秒传接口的基础上实现的,只是需要新增已上传分片列表uploadedList 。

javascript 复制代码
import * as http from "http"; //ES 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createServer();

//...

// 返回已经上传切片名列表
const createUploadedList = async (fileHash) =>
  fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
    ? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
    : [];

server.on("request", async (req, res) => { 
  //...
  
  if (req.url === "/verify") {
    const data = await resolvePost(req);
    const { fileHash, filename } = data;
    const ext = extractExt(filename);
    const filePath = `${UPLOAD_DIR}/${fileHash}${ext}`;
    if (fse.existsSync(filePath)) {
      res.end(
        JSON.stringify({
          shouldUpload: false,
        })
      );
    } else {
      res.end(
        JSON.stringify({
          shouldUpload: true,
          uploadedList: await createUploadedList(fileHash),
        })
      );
    }
  }
});

server.listen(3000, () => console.log("正在监听 3000 端口"));

上传进度

  1. 新增显示进度的标签。
  2. 对fetch请求再做一点改造,这里需要使用getReader() 手动读取数据流,获取到当前上传进度,并添加onProgress 回调。
ini 复制代码
<p id="progress"></p>

/**
 * @description: 封装fetch
 * @param {Object} FetchConfig fetch config
 * @return {Promise} fetch result
 */
const requestApi = ({
  url,
  method = "GET",
  onProgress,
  ...fetchProps
}) => {
  //...
  return new Promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchProps,
       signal: controller.signal,
    });
    const total = res.headers.get("content-length");
    const reader = res.body.getReader(); //创建可读流
    const decoder = new TextDecoder();
    let loaded = 0;
    let data = "";
    while (true) {
      const { done, value } = await reader.read();
      loaded += value?.length || 0;
      data += decoder.decode(value);
      onProgress && onProgress({ loaded, total });
      if (done) {
        break;
      }
    }
    //...
    resolve(JSON.parse(data));
  });
};

然后,在上传的时候将已上传进度设置成100,并添加onProgress回调处理,累计每个分片的进度,得到整体的上传进度。

ini 复制代码
let fileName = "",
fileHash = "",
fileSize = 0,
fileChunkListData = [],
abortControllerList = [];
const HOST = "http://localhost:3000";

//...

const handleUpload = async () => {
//...
fileChunkListData = fileChunkList.map(({ file, size }, index) => {
  //...
  return {
    percentage: uploadedList.includes(hash) ? 100 : 0,
  };
});
//...
};

//上传分片
const uploadChunks = async (uploadedList) => {
const requestList = fileChunkListData
  .filter(({ hash }) => !uploadedList.includes(hash))
  .map(({ file, fileHash, fileName, hash }, index) => {
  //...
  })
  .map(async ({ formData, hash }) => {
    return requestApi({
      url: `${HOST}`,
      method: "POST",
      body: formData,
      onProgress: ({ loaded, total }) => {
            const percentage = parseInt((loaded / total) * 100);
              const curIndex = fileChunkListData.findIndex(
                ({ hash: h }) => h === hash
              );
              fileChunkListData[curIndex].percentage = percentage;
              const totalLoaded = fileChunkListData
                .map((item) => item.size * (item.percentage / 100))
                .reduce((acc, cur) => acc + cur, 0);
              const totalPercentage = Math.min(
                parseInt((totalLoaded / fileSize) * 100),
                100
              );
              const progressText = `上传进度:${totalPercentage}%`;
              document.getElementById("progress").innerHTML = progressText;
      },
    });
  });
 //...
};

优化策略

并发控制优化

使用 Web Worker 执行计算任务

使用requestIdleCallback在浏览器的空闲时间去计算文件的hash

相关推荐
嘉琪coder12 分钟前
显示器报废,win笔记本远程连接mac mini4 3种方法实测
前端·windows·mac
hrrrrb39 分钟前
【CSS3】筑基篇
前端·css·css3
boy快快长大42 分钟前
【VUE】day01-vue基本使用、调试工具、指令与过滤器
前端·javascript·vue.js
三原1 小时前
五年使用vue2、vue3经验,我直接上手react
前端·javascript·react.js
嘉琪coder1 小时前
React的两种状态哲学:受控与非受控模式
前端·react.js
木胭脂沾染了灰1 小时前
策略设计模式-下单
java·前端·设计模式
Eric_见嘉1 小时前
当敦煌壁画遇上 VS Code:我用古风色系开发了编程主题
前端·产品·visual studio code
拉不动的猪1 小时前
刷刷题28(http)
前端·javascript·面试