大文件上传 js + express实现

76 阅读5分钟

大文件上传

大文件前言

1.秒传

A 上传过这个文件了 MD5 带上去了

B 也会携带MD5服务器端判断一下,这个文件是否上传过,如果上传过,改个名字,直接返回,不用再上传了

其中 MD5 是散列函数的一种

2.大文件上传

(前端并发的数量最大为6个, IE是5个, socket最大链接好几百个)

10GB 的文件上传很慢,进行切片分片上传,1G 1G 的上传,上传到服务器,服务器保存,合并,完成上传

3.断点续传

10GB的文件,上传到7.8G的时候,网络中断了,重新上传,从7.8G开始上传,而不是从0开始上传

大文件上传

1. 简单搭建

服务端搭建:

安装 express

安装 nodemon

简单搭起

 const express = require("express");
 const app = express();
 app.listen(4444, () => console.log("Server started on port 4444"));

客户端:

 <!DOCTYPE html>
 <html lang="en">
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>Document</title>
   </head>
   <body>
     <div style="margin: 0 auto; width: 500px; text-align: center">
       <h3>大文件上传</h3>
       <input type="file" id="file" />
       <button id="upload">上传</button>
     </div>
     <script src="./index.js"></script>
   </body>
 </html>

index.js

 const uplooad = document.getElementById("upload");
 const file = document.getElementById("file");
 ​
 uplooad.addEventListener("click", () => {
   const filedata = file.files[0]; // 读取文件内容
   console.log(filedata);
 })

重点:此处file对象是继承于blob blob本身会提供一个方法,slice进行切分

file blob 都是二进制

2. 接着我们现在可以进行切片

  1. 设置固定分片大小 chunkSize

  2. 进行切片 file.files[0].slice 存储到 chucks

  3. 注意:此处需要计算使用MD5 计算hash来 实现秒传 webworker.js

  4. 引入 MD5 作为文件名

    获取地址:github.com/satazor/js-…

由于 js 是单线程,计算能力弱,因此我们需要单独开个服务,不去阻塞js运行,我们就可以使用webworker

 const uplooad = document.getElementById("upload");
 const file = document.getElementById("file");
 const worker = new Worker("./webworker.js");
 // 切片大小
 const chunkSize = 1024 * 1024 * 5; // 5M
 const chucks = []; // 存放切片
 ​
 uplooad.addEventListener("click", () => {
   const filedata = file.files[0]; // 读取文件内容
   const total = Math.ceil(filedata.size / chunkSize);  // 切多少份 也就是调多少次接口
   console.log(total)
   //1 0-5 
   //2 5-10 
   //3 10-15 
   //。。。
   chucks.push(
     ...Array.from({ length: total }, (_, i) =>
       // i 为索引从 0 开始
       filedata.slice(i * chunkSize, (i + 1) * chunkSize)
     )
   );
   // worker.js 接收这个消息,处理这个消息
   console.log(chucks);
   worker.postMessage({
     chucks,
     filename: filedata.name,
   });
 });
 ​
 worker.onmessage = async (e) => {
  const { hash, filename } = e.data;
  console.log(hash, filename); // e38b65dd8a72eceab884fab53f55ea63 6月17日.mp4
 }
 ​

webworker.js

  1. 拿到 chucks 切片数组
  2. new self.SparkMD5.ArrayBuffer(); 创建sparkMD5对象 计算 MD5
  3. 将blob转化成 ArrayBuffer
  4. 利用new FileReader() -> reader.readAsArrayBuffer(chucks[currentChuck]) -> reader.onload异步函数里面
  5. 递归完之后,发送给主线程,主线程就可以拿到hash,拿到hash之后,就可以实现秒传
 //web worker 浏览器多线程脚本
 //web worker 中的代码不能访问 DOM
 //web worker 中的代码不能访问全局变量 window
 //web worker 中的代码不能访问本地文件系统 document
 //web worker 是运行在后台的js 不会阻塞页面的渲染
 // self 代表当前 worker 全局作用域
 self.importScripts('./spark-md5.min.js')
 self.onmessage = function (e) {
     const { chucks, filename } = e.data;
     console.log(chucks);
     const spark = new self.SparkMD5.ArrayBuffer(); // 创建sparkMD5对象 计算 MD5
     let currentChuck = 0;
     // 需要把blob 转成 ArrayBuffer
     function calculateMD5() {
         const reader = new FileReader();// 各种格式转换 base64 blob arraybuffer file
         reader.onload = function (e) {
             // console.log('计算中', e.target.result);
             spark.append(e.target.result); // 添加数据
             currentChuck++;
             if (currentChuck < chucks.length) {
                 calculateMD5();
             }
             else {
                 // web worker 可以发送信息给主线程
                 self.postMessage({
                     hash: spark.end(),
                     filename,
                     total: chucks.length
                 });
             }
         }
         // 读取文件
         reader.readAsArrayBuffer(chucks[currentChuck]);
     }
     calculateMD5();
 }

3. 开始 node server搭建

使用 multer 中间件
  • 处理文件上传的中间件

问题1:如果上传file一点要写在最下面,如果读到 file 就停止了

server/index.js

  1. 使用multer进行创建文件夹,然后将片段依次保存
  2. 保存之后使用文件流进行片段合并,合并之后保存到{hash}里面
 const express = require("express");
 const cors = require("cors");
 const fs = require("fs");
 const multer = require("multer"); // 处理文件上传的中间件
 const app = express();
 const path = require("path");
 app.use(cors()); // 解决跨域问题
 ​
 // 处理文件上传存放的位置
 // 处理文件的名字是什么样子的
 const storage = multer.diskStorage({
   destination: function (req, file, cb) {
     console.log(req.body);
     // 创建文件夹 1. 文件夹的名字 2. 是否递归创建 如果文件存在就不创建 不存在则会创建
     fs.mkdirSync(`uploads/${req.body.hash}`, { recursive: true });
     cb(null, `uploads/${req.body.hash}`);
   },
   // 处理文件的名字是什么样子的
   filename: function (req, file, cb) {
     cb(null, `${req.body.filename}-${req.body.index}`);
   },
 });
 ​
 const upload = multer({ storage });
 //  upload.single("file") 前端的key值
 app.post("/upload", upload.single("file"), (req, res) => {
     console.log(req.file);
   //   res.send("File uploaded successfully");
   res.json({ message: "File uploaded successfully", success: true });
 });
 app.get("/home", (req, res) => {
   res.end("hello express server");
 });
 ​
 /**
  * 合并切片文件
  *  1. 通过文件流合并
  *  2. appendFile 合并
  * */ 
 app.get("/merge",async (req, res) => {
     const { hash, filename } = req.query;
     const files = fs.readdirSync(`uploads/${hash}`); // 获取切片所在的目录
     // console.log(files);
     // 对乱序的进行排序
     const filesArrsSort = files.sort((a, b) => {
       return a.split("-")[1] - b.split("-")[1];
     })
     // __dirname 服务所在目录
     const filePath = path.join(__dirname, `${hash}`); // 文件合并后的路径
     fs.mkdirSync(filePath, {recursive: true}); // 创建一个目录
     console.log(filesArrsSort);
     // 创建写入流
     const writerStream = fs.createWriteStream(path.join(filePath, filename));
     for (const file of filesArrsSort) {
         await new Promise((resolve, reject) => {
             // 创建可读流
             const readStream = fs.createReadStream(path.join(`uploads/${hash}`, file));
             readStream.pipe(writerStream, {end: false});
             // 因为写进去的写完了就会自动关闭 end:false 不让他自动关闭
             readStream.on("end", () => {
                 fs.unlinkSync(path.join(`uploads/${hash}`, file)); // 删除切片
                 resolve();
             })
             readStream.on("error", reject)
         })
     }
     writerStream.end();
     // const filePath = `uploads/${hash}/${filename}`;
     res.json({ message: "File merged successfully", success: true });
 ​
 });
 ​
 app.listen(4446, () => console.log("Server started on port 4446"));

接着web/index.js

 worker.onmessage = async (e) => {
  const { hash, filename } = e.data;
 //  哈夫曼树 这里是为了合并的时候通过索引可以正确查找文件位置通过哈夫曼合并
 const tasks = chucks.map((chunk, index) => ({ chunk, index }));
 //  for of 可以处理异步
  for (const {chunk, index} of tasks) {
     const formData = new FormData();
     formData.append("hash", hash);
     formData.append("filename", filename);
     formData.append("index", index);
     formData.append("file", chunk);
     await fetch("http://localhost:4446/upload", {
       method: "POST",
       body: formData,
     })
   }
   // 通知后端合并文件
   await fetch(`http://localhost:4446/merge?hash=${hash}&filename=${filename}`)
 }

最后,可以看到大文件已经上传到

server/{hash} 里面

image-20250623215552658

断点续传

server/index.js

加上续传接口

此时:判断后端是否存在文件,存在就返回存在的文件

 app.get("/verify", (req, res) => {
     const { hash } = req.query;
     const isExit = fs.existsSync(`upload/${hash}`);
     if (!isExit) {
         return res.json({
             // success: false,
             // message: "文件不存在",
             success: true,
             files: [],
         })
     }
     const files = fs.readdirSync(`uploads/${hash}`);
     res.json({
         success: true,
         files
     })
 })
 ​

web/index.js

请求续传接口

此时:这里根据返回的文件进行过滤,就不上传已经存在的文件了

 worker.onmessage = async (e) => {
  const { hash, filename } = e.data;
  console.log(hash, filename, chucks);
  const res = await fetch(`http://localhost:4446/verify?hash=${hash}`);
  const { files } = await res.json(); // 判断是否已经上传过
  console.log(files)
  const set = new Set(files);
 ​
 //  哈夫曼树 这里是为了合并的时候通过索引可以正确查找文件位置通过哈夫曼合并
 const tasks = chucks.map((chunk, index) => ({ chunk, index })).filter(({ index }) => !set.has(index));
 console.log(tasks);
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
  console.log(filename, hash); 
  console.log(tasks);
 //  for of 可以处理异步
  for (const {chunk, index} of tasks) {
     const formData = new FormData();
     formData.append("hash", hash);
     formData.append("filename", filename);
     formData.append("index", index);
     formData.append("file", chunk);
     await fetch("http://localhost:4446/upload", {
       method: "POST",
       body: formData,
     })
     // await sleep(2000)
   }
   // 通知后端合并文件
   await fetch(`http://localhost:4446/merge?hash=${hash}&filename=${filename}`)
 }
 ​

秒转

秒传实现思路:

  1. 用户选择文件。

  2. (客户端)将整个文件发送到 Web Worker,计算文件的完整 MD5 值。

  3. (主线程)从 Web Worker 接收到完整文件的 MD5 值。

  4. (主线程)立即向服务器发送一个“秒传验证”请求,携带完整文件 MD5 和文件名。

  5. (服务器端)接收到秒传验证请求,查找该 MD5 对应的文件。

    • 如果文件已存在: 服务器返回成功响应(例如 { uploaded: true, url: '...' })。
    • 如果文件不存在或不完整: 服务器返回文件不存在的响应,并且可选地告知已存在哪些分片(为续传做准备,例如 { uploaded: false, uploadedChunks: [0, 2, 5] })。
  6. (主线程)根据服务器的秒传验证响应进行判断:

    • 如果文件已存在: 客户端显示秒传成功,结束上传流程。
    • 如果文件不存在: 客户端才开始进行文件切片,并根据服务器返回的 uploadedChunks 信息,只上传缺失的分片。

因此需在webworker 里面修改

 //web worker 浏览器多线程脚本
 //web worker 中的代码不能访问 DOM
 //web worker 中的代码不能访问全局变量 window
 //web worker 中的代码不能访问本地文件系统 document
 //web worker 是运行在后台的js 不会阻塞页面的渲染
 // self 代表当前 worker 全局作用域
 self.importScripts("./spark-md5.min.js");
 self.onmessage = function (e) {
   const { type, payload } = e.data;
   if (type === "calculateFullHash") {
     // 计算整个文件的MD5
     const { filedata, filename } = payload;
     const spark = new self.SparkMD5.ArrayBuffer(); // 创建sparkMD5对象 计算 MD5
     const reader = new FileReader(); // 各种格式转换 base64 blob arraybuffer file
     reader.onload = function (e) {
       spark.append(e.target.result); // 添加整个文件数据
       // web worker 可以发送信息给主线程
       self.postMessage({
         hash: spark.end(),
         filename,
         type: "fullHashCalculated",
       });
     };
     reader.onerror = function (error) {
       console.log(error);
       self.postMessage({
         type: "error",
         message: "Failed to read file for full hash calculation.",
         error: error,
       });
     };
     reader.readAsArrayBuffer(filedata);
   } else if (type === "calculateChunkHashes") {
     const { chucks, filename } = payload;
 ​
     const spark = new self.SparkMD5.ArrayBuffer(); // 创建sparkMD5对象 计算 MD5
     let currentChuck = 0;
     // // 需要把blob 转成 ArrayBuffer
     function calculateMD5() {
       const reader = new FileReader(); // 各种格式转换 base64 blob arraybuffer file
       // 异步操作 - 回调函数
       reader.onload = function (e) {
         // console.log('计算中', e.target.result, currentChuck);
         spark.append(e.target.result); // 添加数据
         currentChuck++;
         if (currentChuck < chucks.length) {
           calculateMD5();
         } else {
           // web worker 可以发送信息给主线程,非秒传
           self.postMessage({
             hash: spark.end(),
             filename,
             total: chucks.length,
             type: "chunkHashesCalculated",
           });
         }
       };
       // 读取文件
       reader.readAsArrayBuffer(chucks[currentChuck]);
     }
     calculateMD5();
   } else {
     console.log(type);
     self.postMessage({
       type: "error",
       message: "Unknown message type received by worker.",
     });
   }
 };

web/index.js 重写

 const uplooad = document.getElementById("upload");
 const file = document.getElementById("file");
 const worker = new Worker("./webworker.js");
 // 切片大小
 const chunkSize = 1024 * 1024 * 5; // 5M
 const chucks = []; // 存放切片
 let filedata = null; // 存储当前选择的文件
 uplooad.addEventListener("click", () => {
   const filedata = file.files[0]; // 读取文件内容
   const total = Math.ceil(filedata.size / chunkSize); // 切多少份 也就是调多少次接口
   worker.postMessage({
     type: "calculateFullHash",
     payload: {
       filedata: filedata,
       filename: filedata.name,
     },
   });
 });
 ​
 worker.onmessage = async (e) => {
   const { hash, filename, type, message, error } = e.data;
   console.log(hash, filename, chucks);
   if (type === "fullHashCalculated") {
     // 2:接收到完整文件MD5, 进行秒传验证
     const verifyRes = await fetch(
       `http://localhost:4446/verify_full_file?hash=${hash}&filename=${filename}`
     );
     const verifyData = await verifyRes.json();
     console.log("🎉 秒传成功!文件已存在于服务器。", verifyData.url);
     alert("秒传成功!文件已存在。");
 ​
     return;
   } else if (type === "chunkHashesCalculated") {
     const res = await fetch(`http://localhost:4446/verify?hash=${hash}`);
     const { files } = await res.json(); // 判断是否已经上传过
     console.log(files);
     const set = new Set(files);
 ​
     //  哈夫曼树 这里是为了合并的时候通过索引可以正确查找文件位置通过哈夫曼合并
     const tasks = chucks
       .map((chunk, index) => ({ chunk, index }))
       .filter(({ index }) => !set.has(index));
     console.log(tasks);
     const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
     console.log(filename, hash);
     console.log(tasks);
     //  for of 可以处理异步
     for (const { chunk, index } of tasks) {
       const formData = new FormData();
       formData.append("hash", hash);
       formData.append("filename", filename);
       formData.append("index", index);
       formData.append("file", chunk);
       await fetch("http://localhost:4446/upload", {
         method: "POST",
         body: formData,
       });
       // await sleep(2000)
     }
     // 通知后端合并文件
     await fetch(
       `http://localhost:4446/merge?hash=${hash}&filename=${filename}`
     );
   } else if (type === "error") {
     console.error("Web Worker 发生错误:", message, error);
     alert(`文件处理失败: ${message}`);
   }
 };

server/index.js

增加文件完整性接口

 // 检验文件完整性接口,验证秒传
 app.get("/verify_full_file", (req, res) => {
   const { hash, filename} = req.query
   const finalFilePath = path.join(__dirname, hash, filename);  // 合并后文件的完整路径
 ​
   const fullFileExists = fs.existsSync(finalFilePath)
   if (fullFileExists) {
     // 如果完整文件已存在,则秒传成功
     return res.json({
       uploaded: true,
       url: `/files/${hash}/${filename}`, // 返回文件可访问的URL
       message: "文件已存在,秒传成功!",
     });
   } else {
     return res.json({
       uploaded: false,
       message: "文件不存在,请上传。",
     });
   }
 })

进度条思路:

设置宽度 / 百分比字眼

已上传分片数 / 总分片数

就可以完成了

最后:请多指教,谢谢

作业: 进度条