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

流程图解析

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

传统文件传输的四大困扰

  • 传输卡顿/中断
    网络波动、服务器超时导致反复重传,耗时耗力。
  • 格式与大小限制
    老旧系统仅支持小文件(如<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

相关推荐
崔庆才丨静觅1 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了2 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅2 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment3 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅3 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊3 小时前
jwt介绍
前端
爱敲代码的小鱼3 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax