首页/文章/javascript

大文件传输优化: 分片上传、断点续传和秒传的前后端实现

2023-10-17
277410 分钟
...

大文件上传:一次性上传会存在的问题:比较慢,中途退出或者网络延迟,容易出现超时,上传失败等问题。所以一般会选择 分片上传、断点续传来实现。线上体验地址及演示在下面,源码在末尾。需要的小伙伴自取。

断点续传 断点续传是一种允许在上传中断后继续上传,而无需从头开始。

分片上传 分片上传是将大文件分割成多个小块(分片),然后逐个上传这些小块。小块可以并行上传,从而提高上传速度。一旦所有分片上传完成,服务器可以将这些分片合并成完整的文件。

秒传 秒传实际上就是不传,允许用户在上传文件时,如果服务器已经存在完全相同的文件,就直接跳过上传过程,实现瞬间完成的效果。

下面是效果

体验地址 xiaoyi1255
家人们,下手轻点,,服务器一共就40GB, 可以把项目荡下来耍

已上传的文件访问:ip:3000+返回的文件路径

整体实现思路

前端部分:Vue3 + antdv + web Worker + spark-md5

  1. 用户在前端选择文件,使用 web Worker 进行文件分片并计算文件 hash 值。
  2. 将分片上传至后端,上传前校验文件是否已存在,如果存在则上传缺失的分片。
  3. 完成分片上传后,向服务器发出合并请求,等待合并结果。
  4. 收到合并完成的消息和文件访问路径,显示给用户。

服务端部分: express + busboy + fs

  1. 实现三个接口:接收文件分片、合并分片、校验文件状态。
  2. 接收文件分片接口:将上传的文件分片按文件名创建文件夹,每个分片作为文件保存。
  3. 合并分片接口:接收文件名,查找对应文件夹下的分片,排序后读取文件流,生成完整文件。
  4. 将合并后的文件进行静态文件托管,并返回文件访问路径。
  5. 校验接口:检查是否存在文件,是否存在已上传的部分分片,若存在则返回已上传的分片信息。

项目框架结构

前端部分

校验文件是否已上传

  • 文件已上传就会直接返回文件的访问url 也就是所谓的秒传
  • 文件没有上传:就需要上传所有分片
  • 上传了部分分片,返回已经上传的文件分片名 => 再把未上传的分片进行上传 所谓的断点续传(只是这里没有加个暂停按钮)
/**  
* 校验文件是否已上传 
 * @param md5  
  * @param chunks  
   */ 
   const verifyFile = (md5: string, chunks: Blob[], file: File) => { 
     let chunsNames = [] as string[]  
      chunks.forEach((item, index) => chunsNames.push(md5 + separator + index))  
       return $fetch(`${config?.baseUrl}/upload/verifyFile`,    
        {      
         method: 'POST',  
              query: {     
                  chunksObj: { 
                  name: md5, chunsNames 
                  },   
                        extName: file.name.split(".").slice(-1)[0],  
                               fileName: md5 + '.' + file.name.split(".").slice(-1)[0]   
                                   }   
                                     }
                                     )
                                      }

文件分片

  • 分片策略: 根据文件大小拆分成几等份,或者每片固定分片大小去切。这里我使用的后者
  • File 对象: File 对象表示用户选择的文件,它包含文件的元数据(例如文件名、大小、类型、日期等)。通过读取文件的二进制内容,可以生成 Blob 对象,进而对文件进行分片。
  • Blob 对象: Blob(Binary Large Object)是表示二进制数据的对象。它可以包含文件的一部分或全部内容。通过切割 Blob 对象,可以得到文件的分片
typescript

复制代码

`/**  * 文件分片  * @param file 文件对象  * @param chunksize 分片大小  */ const createChunks = (file: File, chunksize: number) => {   const chunks = [];   for (let i = 0; i < file.size; i += chunksize) {     chunks.push(file.slice(i, i + chunksize));   }   return chunks; };`

创建MD5 加密串

  • 根据分片数组对象 使用spark-md5生成文件加密串
  • 好处就是文件唯一标识,除非更改文件内容,否则不会改变
  • 用作储存的标识
  • 后面会介绍使用 web Worker来进行加密
  • 因为文件如果几十个GB的话,程序不一定会崩溃,但是用户肯定会奔溃,因为耗时呀,js是单线程
typescript

复制代码

`/**  * 创建MD5 加密串  * @param chunks   */ import SparkMD5 from "spark-md5";  const createMd5 = (chunks: Blob[]) => {   const spark = new SparkMD5();   return new Promise((reslove) => {     function _read(i: number) {       if (i >= chunks.length) {         const md5 = spark.end();         reslove(md5);         return;       }       const blob = chunks[i];       const reader = new FileReader();        reader.onload = (e) => {         const bytes = e?.target?.result;         spark.append(bytes);         _read(i + 1);       };       reader.readAsArrayBuffer(blob);     }     _read(0);   }); };`

上传分片

  • 分片上传的好处就是:快、失败了某一个分片,不需要重新上传整个文件,只需上传未上传的分片
typescript

复制代码

``/**  * 上传chunk  * @param item chunks  * @param md5 加密串  * @param fileName 文件名  * @param index 下标:失败辅助标识  */ const uploadLargeFile = (item, md5 = '', fileName = '', index = -1) => {   const formData = new FormData();   formData.append("file", item);   return useFetch(`${config?.baseUrl}/upload/largeFile`, {     method: "POST",     headers: {       authorization: "authorization-text",     },     body: formData,     query: {       filename: md5 + separator + index,       name: md5,       fileName,       index,     },   }); }  /**  * 循环上传chunks  * @param chunks   * @param md5 加密串  * @param fileName 文件名  */ const uploadChunks = (chunks = [], md5 = '', fileName = '') => {   const allRequest = chunks.map((item, index) => {     return uploadLargeFile(item, md5, fileName, index)   });   return allRequest  }``

分片上传完成调合并接口

  • 当前端所有分片上传完成,就告诉服务端,把各分片进行整合并返回文件的访问url
typescript

复制代码

``/**  * 合并chunks  * @param md5   * @param file   */ const mergeFile = async (md5 = '', file: File) => {   const {     url = "",     fileType = "",     fileName: _fileName,   } = await $fetch(`${config?.baseUrl}/upload/mergeFile`, {     method: "POST",     query: {       fileName: md5,       filename: file.name,       extName: file.name.split(".").slice(-1)[0],     },   }); }``

使用web Worker进行MD5加密

  • 需要引入park-md5.js库 (注:我这里老报错,暂未找到解决方案,就暴力引入了)
  • 主要流程:
    • 创建worker.js 文件
    • 引入并使用 new Worker('worker.js')
    • 接收消息:通过监听message事件
    • 发送消息:通过发送postMessage
  • 注意事项:Worker是独立于主线程的子线程,不能访问dom
typescript

复制代码

`// md5Worker.js self.importScripts('park-md5.js');  self.addEventListener('message', async (event) => {   const chunks = event.data;   const md5 = await createMd5(chunks);   self.postMessage( md5); })  const createMd5 = (chunks) => {   const spark = new self.SparkMD5();    return new Promise((resolve) => {     function _read(i) {       if (i >= chunks.length) {         const md5 = spark.end();         resolve(md5);         return;       }        const blob = chunks[i];       const reader = new FileReader();        reader.onload = (e) => {         const bytes = e?.target?.result;         spark.append(bytes);         _read(i + 1);       };       reader.readAsArrayBuffer(blob);     }     _read(0);   }); };`
  • 主程序中使用
  • 不要问我为什么使用第一种动态引入,问就是遇到坑啦~
typescript

复制代码

`// 在主线程中创建 Web Worker import("./md5Worker?worker").then((worker) => {     const md5Worker = new worker.default();     // 发送消息     md5Worker.postMessage('发送的消息')     // 报错监听     md5Worker.onerror = err => {         }     // 接收消息     md5Worker.onmessage = function (e) {}     // 关闭联系     md5Worker.terminate() })  // ----------------------或者----------------------- const worker = new Worker('worker-script.js'); worker.postMessage('Hello from main thread'); worker.onmessage = function(event) {   console.log('Main thread received message from Worker:', event.data); };`

后端部分

创建server.js

javascript

复制代码

``const express = require('express'); const app = express(); const cors = require('cors'); // 导入 cors 中间件 const uploadRoutes = require('./routes/upload.js');  app.use(express.json()); // 托管静态文件 app.use('/static',express.static(path.join(__dirname,'./public'), { 	maxAge: 1000 * 60 * 60 *24 * 7 })) // 跨域 app.use(cors()) // 上传路由 app.use('/upload', uploadRoutes)  // 启动服务器 const PORT = process.env.PORT || 3000; server.listen(PORT, () => { 	console.log(`服务器正在运行,端口:${PORT}`); });``

文件校验接口

javascript

复制代码

``const express = require('express'); const path = require('path'); const fs = require('fs') const router = express.Router(); /**  * 校验文件是否已上传  * 1. 静态服务上是否存在该文件 存在=》返回url  * 2. 不存在改文件  *    1)是否存在已上传的部分chunks 存在,返回还未上传的chunks 名列表  */ router.post('/verifyFile', async (req, res) => {   const {     fileName,     extName,     chunksObj=''   } = req.query   console.log(JSON.parse(chunksObj))   const { name = '', chunsNames= [] } = JSON.parse(chunksObj || '{}') || {}   let notUploadedChunks = [] // 未上传的chunks名列表   let chunksFiles = []   // 校验文件是否已存在   const isSave = checkFileExistsInFolder(fileName)   // 文件不存在 接着检查是否存在已上传的chunks   if (!isSave && name) {     chunksFiles = getFilesInFolder(`../public/file/thunk/${name}`) || []     if (chunksFiles?.length && chunsNames?.length) {       notUploadedChunks = chunsNames.filter(item => !chunksFiles.includes(item))     }   }   const url = isSave ?  '/static/file/' + fileName : ''   res.status(200).send({     code: 0,     fileType,     fileName,     notUploadedChunks,     uploadedChunks: chunksFiles,     url   }) })  /**  * 查看是否已包含某个文件  * @param {*} targetFileName 查找的目标文件名  * @param {*} folderPath 文件夹路径 默认 /public/file/  * @returns   */ function checkFileExistsInFolder(targetFileName, folderPath='../public/file/') {     folderPath = path.join(__dirname, folderPath)     const filesInFolder = fs.readdirSync(folderPath);     const isUpoaded = filesInFolder.includes(targetFileName)     console.log('文件是否已存在', isUpoaded)     return isUpoaded; }  /**  * 检查某个文件夹是否存在  * @param {*} folderPath 文件夹路径  * @returns 文件夹内的所有文件  */ function getFilesInFolder(folderPath) {     folderPath = path.join(__dirname, folderPath)     if (!fs.existsSync(folderPath)) {       console.log(`Folder '${folderPath}' does not exist.`);       return [];     }           const filesInFolder = fs.readdirSync(folderPath) || [];     return filesInFolder; }``

分片上传接口

javascript

复制代码

``const express = require('express'); const Busboy = require('busboy') const path = require('path'); const fs = require('fs') const router = express.Router(); /**  * 大文件上传: 分片  */ router.post('/largeFile', (req, res) => {   const busboy = Busboy({ headers: req.headers });   const { filename, name, index } = req.query   busboy.on('file', (req, (err, file, filds, encoding, mimetype) => {     try {       const dir = `../public/file/thunk/${name}`       mkdirFolder(dir)       const saveTo = path.join(__dirname, dir, filename);       file.pipe(fs.createWriteStream(saveTo));     } catch (error) {       console.log(error, 'err*---------')       const resObj = {         msg: '分片上传失败',         code: -1,         err: error,         index // 返回报错的是那个chunks       }       res.send(resObj);     }   }));   busboy.on('finish', function (  ) {     const resObj = {       msg: '分片上传成功',       code: 0,       index,     }     res.send(resObj);   });   return req.pipe(busboy); })``

合并分片接口

typescript

复制代码

`const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); /**  * 合并分片  */ router.post('/mergeFile', async (req, res) => {   const { fileName, extName, filename } = req.query   thunkStreamMerge(     '../public/file/thunk/' + fileName,     '../public/file/' + fileName + '.' + extName   );   let fileType = extName   if (imageFormats.includes(extName)) {     fileType = 'img'   } else if (videoFormats.includes(extName)) {     fileType = 'video'   }    res.json({     code: 1,     url: '/static/file/' + fileName,     fileType,     fileName   }); })  /**  * 文件合并  * @param {string} sourceFiles 源文件目录  * @param {string} targetFile 目标文件路径  */ function thunkStreamMerge(sourceFiles, targetFile) {   const sourceFilesDir = path.join(__dirname, sourceFiles);   targetFile = path.join(__dirname, targetFile);    const fileList = fs     .readdirSync(sourceFilesDir)     .filter((file) => fs.lstatSync(path.join(sourceFilesDir, file)).isFile())     .sort((a, b) => parseInt(a.split('@')[1]) - parseInt(b.split('@')[1]))     .map((name) => ({       name,       filePath: path.join(sourceFilesDir, name),     }));    const fileWriteStream = fs.createWriteStream(targetFile);    thunkStreamMergeProgress(fileList, fileWriteStream, sourceFilesDir); }  /**  * 合并每一个切片  * @param {Array} fileList 文件数据列表  * @param {WritableStream} fileWriteStream 最终的写入结果流  * @param {string} sourceFilesDir 源文件目录  */ function thunkStreamMergeProgress(fileList, fileWriteStream, sourceFilesDir) {   if (!fileList.length) {     fileWriteStream.end('完成了');     // 删除临时目录     fs.rmdirSync(sourceFilesDir, { recursive: true, force: true });     return;   }    const { filePath: chunkFilePath } = fileList.shift();   const currentReadStream = fs.createReadStream(chunkFilePath);    // 把结果往最终的生成文件上进行拼接   currentReadStream.pipe(fileWriteStream, { end: false });    currentReadStream.on('end', () => {     // 拼接完之后进入下一次循环     thunkStreamMergeProgress(fileList, fileWriteStream, sourceFilesDir);   }); }`

踩坑实录

  1. Worker的使用 new Worker('worker.js') 路径问题
typescript

复制代码

`import md5Worker from "./md5Worker"; const worker = new Worker('./md5Worker.js') worker.onerror= (err) => {   console.log(err) }`

解决

typescript

复制代码

`import("./md5Worker?worker").then((worker) => { const md5Worker = new worker.default(); md5Worker.postMessage(chunks) md5Worker.onerror = err => {     console.log(err) } md5Worker.onmessage = async function (e) {} })`
  1. 分片上传
  • 所有分片上传成功 => 删除某一个分片(9)
  • 然后判断请求成功数,取错了 const isAllSuccess = successArr.length === chunks.length
  • 应该取的是总发送的分片数(因为部分分片已上传的情况是不满上面的条件的) 啪 就是一巴掌
  • const isAllSuccess = successArr.length === allRequest.length 才对
typescript

复制代码

`const allRequest = uploadChunks(chunks, md5, fileName, notUploadedChunks, uploadedChunks) console.log(allRequest, 'allRequest') const successArr: any[] = [] // 纪录成功上传的chunks Promise.allSettled(allRequest).then(res => {     res?.forEach(item => {     if (item.status == 'fulfilled' && item.value?.data?.value?.code == 0) {         const failIndex = item.value.data.value?.index         successArr.push(failIndex)     }     }) }).finally(async () => {     // const isAllSuccess = successArr.length === chunks.length // 你小子让我徘徊半小时是吧,看完不揍死你     const isAllSuccess = successArr.length === allRequest.length     if (!isAllSuccess) {         const tryAllRequest = chunks.map((item, index) => {             if (!successArr.includes('' + index)) {                 return uploadLargeFile(item, md5, fileName, index)             }         })         // 失败重试一次         await Promise.all(tryAllRequest)     }     mergeFile(md5, file)     loading.value = false;     showUploadList.value = false })`
  1. 上传结果的校验
  • 刚开始,我是通过上次分片结果返回的index进行记录的,结果 node 已报错,就整个都没有了
  • 后面才使用一个单独的接口 实时查询 文件状态
  1. 文件合并: 合并的时候没有对分片进行排序,,导致文件不对

源码

xiaoyi1255

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录