前端技术实现文件上传的断点续传

    科技2025-09-17  29

    本文要实现断点续传,点续传,续传,传。。。。。 断点续传是啥!!!戳这里—>百科断点续传 大白话:就是将一个大文件分成好几个小文件,再通过http请求或者webSocket等方式上传到服务器或者下载到本地。 本文主要介绍上传的续传,egg做完服务端,react做完前端

    效果图

    服务端代码解析

    后端代码是在使用egg生成器生成的基础上,进行编写的:

    路由

    /app/routes.ts

    import { Application } from 'egg'; export default (app: Application) => { const { controller, router } = app; router.post('/', controller.httpFile.index); router.post('/chunks_upload', controller.httpFile.chunksUpload); router.post('/chunks_merge', controller.httpFile.chunksMerge); router.post('/hash_check', controller.httpFile.hashCheck); };
    控制层

    /app/controller/http-file.ts 主要实现上方法

    上传前检测 存在已上传,上传没有完成,没有上传三种情况保存每次传来的切片合并切片 import { Controller } from 'egg'; import { mkdirsSync, del } from '../public/common'; import { streamMerge } from 'split-chunk-merge'; import path = require('path'); import fs = require('fs'); const uploadPath = path.join(__dirname, '../../uploads'); export default class HomeController extends Controller { public async index() { const { ctx } = this; ctx.body = await ctx.service.test.sayHi('egg'); } // 上传前检测 public async hashCheck() { const { ctx } = this; const { total, chunkSize, hash, name } = ctx.request.body; // 上传的文件哈希文件夹加名 const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/'); const filePath = path.join(uploadPath, name); if (fs.existsSync(filePath)) { // 文件已存在 ctx.status = 200; ctx.body = { success: true, msg: '检查成功,文件在服务器上已存在,不需要重复上传', data: { type: 0, // type=0 为文件已上传过 }, }; } else { if (fs.existsSync(chunksPath)) { // 存在文件切片文件夹,上传没有上传完 // 上次没有上传完成,找到以及上传的切片 const index: any = []; const chunks = fs.readdirSync(chunksPath); if (chunks.length === Number(total)) { // 切片上传完了,没有合并 ctx.status = 200; ctx.body = { success: true, msg: '切片上传完毕,没有合并', data: { type: 1, // type=1 切片上传完毕,没有合并 }, }; } else { // 切片没有上传完 chunks.forEach(item => { const chunksNameArr = item.split('-'); index.push(chunksNameArr[chunksNameArr.length - 1]); }); ctx.status = 200; ctx.body = { success: true, msg: '检查成功,需要断点续传', data: { type: 2, // type= 2 需要断点续传 index, }, }; } } else { // 没有这个文件的切片和文件 ctx.status = 200; ctx.body = { success: true, msg: '检查成功,为从未上传', data: { type: 3, // type=3 为从未上传 }, }; } } } // 保存切片 public async chunksUpload() { const { ctx } = this; const { /* name, total, */ index, /* size, */ chunkSize, hash } = ctx.request.body; const file = ctx.request.files[0]; const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/'); if (!fs.existsSync(chunksPath)) mkdirsSync(chunksPath); // 创建读入流 const readStream = fs.createReadStream(file.filepath); // 创建写入流 const writeStream = fs.createWriteStream(chunksPath + hash + '-' + index); // 管道输送 readStream.pipe(writeStream); readStream.on('end', () => { // 删除临时文件 fs.unlinkSync(file.filepath); }); ctx.status = 200; ctx.body = { success: true, msg: '上传成功', data: 200, }; } // 合并切片 public async chunksMerge() { const { ctx } = this; const { chunkSize, name, total, hash } = ctx.request.body; // 根据hash值,获取分片文件。 const chunksPath = path.join(uploadPath, hash + '-' + chunkSize, '/'); const filePath = path.join(uploadPath, name); // 读取所有的chunks 文件名存放在数组中, 并进行排序 const chunks = fs.readdirSync(chunksPath).sort((a: any, b: any) => ( a.split('-')[1] - b.split('-')[1] )); const chunksPathList: any = []; if (chunks.length !== total || chunks.length === 0) { ctx.status = 200; ctx.body = { success: false, msg: '切片文件数量与请求不符合,无法合并', data: '', }; } chunks.forEach((item: string) => { chunksPathList.push(path.join(chunksPath, item)); }); try { await streamMerge(chunksPathList, filePath, chunkSize); // 递归删除文件 del(chunksPath); ctx.status = 200; ctx.body = { success: true, msg: '合并成功', data: '', }; } catch { ctx.status = 200; ctx.body = { success: false, msg: '合并失败,请重试', data: '', }; } } }

    客户端端代码解析

    create-react-app创建的ts项目,引入antd

    逻辑思路:
    选择好文件,拿到文件的信息,点击上传后先对文件进行hash编码,保证上传的文件的名称唯一性设置好传输切片的大小,通过FIle的slice方法将文件分割成若个片,上传切片前先调用检测接口,根据返回回来的值进行上传 上传过的不要上传上传没有完成的,根据服务端返回最后一个切片的序列来接着之后的切片续传没有上传过的就可以直接上传 每次上传完最后一个切片后,使用切片合并接口,将服务端中上传的文件的切片文件夹里的切片按照序列进行合并。

    主要代码

    import React, { useState } from "react"; import { Upload, Button, Progress } from 'antd'; import { UploadOutlined } from '@ant-design/icons'; import SparkMD5 from 'spark-md5'; import axios from 'axios'; import {getState, mutate, useStore} from 'stook'; import "./index.css"; interface file{ file: File, // 上传的文件 chunkSize: number; // 文件的切片大小 } interface chunks_upload{ blockCount: number, // 文件的切片数量 chunkSize: number, // 文件的切片大小 hash: string, // 文件的哈希值 file: File, // 上传的文件 num: number, // 上传的第几个切片 } interface chunks_merge{ blockCount: number, // 文件的切片数量 chunkSize: number, // 文件的切片大小 hash: string, // 文件的哈希值 name: string, // 文件名字 } const win: any = window; // 文件数据的分割方法 const blobSlice = win.File.prototype.slice || win.File.prototype.mozSlice || win.File.prototype.webkitSlice; let totalNum:number=0; // 停止操作 let stop: boolean = false; // 获取文件哈希值 function hashFile({file, chunkSize}: file){ return new Promise((resolve, reject) => { let currentChunk = 0; const chunks = Math.ceil(file.size / chunkSize); const spark = new SparkMD5.ArrayBuffer(); const fileReader = new FileReader(); function loadNext() { const start = currentChunk * chunkSize; const end = start + chunkSize >= file.size ? file.size : start + chunkSize; fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); } fileReader.onload = (e: any) => { spark.append(e.target.result); // Append array buffer currentChunk += 1; if (currentChunk < chunks) { loadNext(); } else { console.log('finished loading'); const result = spark.end(); // 如果单纯的使用result 作为hash值的时候, 如果文件内容相同,而名称不同的时候 // 想保留两个文件无法保留。所以把文件名称加上。 const sparkMd5 = new SparkMD5(); sparkMd5.append(result); sparkMd5.append(file.name); const hexHash: string = sparkMd5.end(); resolve(hexHash); } }; fileReader.onerror = () => { console.warn('文件读取失败!'); }; loadNext(); }).catch(err => { console.log(err); }); } // 文件切片请求 async function chunks_upload({blockCount, chunkSize, hash, file, num}: chunks_upload){ let bool:boolean=true; for (let i = num; i < blockCount; i++) { if(stop){ return; } const start = i * chunkSize; const end = Math.min(file.size, start + chunkSize); // 构建表单 const form = new FormData(); form.append('file', blobSlice.call(file, start, end)); form.append('name', file.name); form.append('total', blockCount.toString()); form.append('index', i.toString()); form.append('chunkSize', file.size.toString()); form.append('hash', hash); // ajax提交 分片,此时 content-type 为 multipart/form-data const axiosOptions = { // 文件上传成功的处理 onUploadProgress: (e: any) => { // 处理上传的进度 console.log(blockCount, i); mutate('num', getState('num') + 1 ); }, }; let res = await axios.post('http://192.168.15.210:7002/chunks_upload', form, axiosOptions); if(res.data.data !== 200){ bool = false; } } // 请求切片合并数据 const data = { chunkSize: file.size, name: file.name, blockCount, hash }; if(bool){ chunks_merge(data); } } // 切片合并 function chunks_merge({ chunkSize, name, blockCount, hash }: chunks_merge){ axios.post('http://192.168.15.210:7002/chunks_merge', { chunkSize, name, total: blockCount, hash }).then(res => { console.log('上传成功'); }).catch(err => { console.log(err); }); } function FileUpdate(){ const [fileList, setFileList]: any = useState([]); const [num, setNum] = useStore('num', 0); // 切片大小 const [chunkSize, ]= useState(2 * 1024 * 1024); const props = { onRemove: (file: any) => { const index = fileList.indexOf(file); const newFileList = fileList.slice(); newFileList.splice(index, 1); setFileList(newFileList); }, beforeUpload: (file: any) => { setFileList([...fileList, file]); return false; }, fileList, }; async function handleUpload(){ setNum(0); const file: File = fileList[0]; stop = false if (!file) { alert('没有获取文件'); return; } // 文件切片数量 const blockCount = Math.ceil(file.size / chunkSize); totalNum = blockCount; // 文件哈希值 const hash: any = await hashFile({file, chunkSize}); // 先检查是否上传过 const check_form = new FormData(); check_form.append('total', blockCount.toString()); check_form.append('hash', hash); check_form.append('chunkSize', file.size.toString()); check_form.append('name', file.name); const res = await axios.post('http://192.168.15.210:7002/hash_check', check_form); const type = res.data.data.type; if(type === 0){ // 存在了 console.log("存在了"); mutate('num', blockCount ); return; } else if(type === 1) { // 切片上传完毕,没有合并 const data = { chunkSize: file.size, name: file.name, blockCount: blockCount, hash }; chunks_merge(data); mutate('num', blockCount ); console.log("切片上传完毕,没有合并"); return; } else if (type === 2){ // 检查成功,需要断点续传 const sum: number = res.data.data.index.length; console.log("sum", sum); mutate('num', sum ); console.log("上次上传没有完成"); chunks_upload({blockCount, chunkSize, file, hash, num: sum}); } else if (type === 3){ console.log("没有上传过"); chunks_upload({blockCount, chunkSize, file, hash, num: 0}); } } return <div className="file"> <Upload {...props}> <Button icon={<UploadOutlined />}>Select File</Button> </Upload> <Progress percent={parseInt(`${(num / totalNum) * 100}`)} /> <Button type="primary" onClick={handleUpload} disabled={fileList.length === 0} style={{ marginTop: 16 }} > Start Upload </Button> <Button type="primary" onClick={()=>{stop=true;}} disabled={fileList.length === 0} style={{ marginTop: 16 }} > stop </Button> </div> } export default FileUpdate;

    再附上效果图

    没有上传的效果 续传效果 已经上传过文件

    学习捷径

    两端的代码已在码云上,想继续研发的同学可以戳这里:file-slice文件断点续传demo

    Processed: 0.021, SQL: 9