├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── robots.txt ├── spark-md5.min.js └── workers │ └── hash.js ├── server └── server.js ├── src ├── App.css ├── App.js ├── App.test.js ├── index.css ├── index.js ├── logo.svg ├── reportWebVitals.js ├── request.js └── setupTests.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | target 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 前端大文件上传 2 | 3 | - React + Ant Design UI 界面 4 | - 利用[`File`](https://developer.mozilla.org/zh-CN/docs/Web/API/File)从`Blob`继承的[`slice`](https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice)方法对文件切片 5 | - 通过`web worker`利用`FileReader`+[`spark-md5`](https://github.com/satazor/js-spark-md5)生成文件 `hash` 值 6 | - `xhr`通过`formData`上传文件 7 | - `nodejs` + `http` 模块 8 | - `fse` 处理文件 9 | - `multiparty` 处理`formData` 10 | 11 | 功能: 12 | 13 | - 大文件切片 14 | - 暂停/恢复上传 15 | - 断点续传,记忆已上传部分 16 | - 文件秒传 17 | 18 | ## 开始 19 | 20 | ```sh 21 | # npm 22 | npm install 23 | npm start 24 | 25 | # yarn 26 | yarn start 27 | 28 | ``` 29 | 30 | ```sh 31 | # 启动node server 32 | node server/server.js 33 | ``` 34 | 35 | ## 演示 36 | 37 | ### 暂停/恢复/重复上传 38 | 39 | 40 | ![upload_pause gif](https://user-images.githubusercontent.com/7972688/128820298-db9a37e3-9be5-41f6-b558-92d0dc115566.gif) 41 | 42 | ### 上传中途失败,下次断点续传 43 | 44 | 45 | ![upload_continue gif](https://user-images.githubusercontent.com/7972688/128820450-4dbea09b-65e2-44af-ae5c-816d394675f7.gif) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-upload", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": { 6 | "name": "suchenrain", 7 | "email": "suchenxiaoyu@qq.com" 8 | }, 9 | "keywords": [ 10 | "react", 11 | "前端大文件上传", 12 | "切片上传", 13 | "断点续传", 14 | "web worker", 15 | "spark-md5" 16 | ], 17 | "license": "MIT", 18 | "dependencies": { 19 | "@testing-library/jest-dom": "^5.11.4", 20 | "@testing-library/react": "^11.1.0", 21 | "@testing-library/user-event": "^12.1.10", 22 | "antd": "^4.16.10", 23 | "multiparty": "^4.2.2", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "react-hot-toast": "^2.1.0", 27 | "react-scripts": "4.0.3", 28 | "web-vitals": "^1.0.1" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suchenrain/react-upload/97873574ff1242dd2f9c532e97665e7b7a9ecac1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suchenrain/react-upload/97873574ff1242dd2f9c532e97665e7b7a9ecac1/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/suchenrain/react-upload/97873574ff1242dd2f9c532e97665e7b7a9ecac1/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/spark-md5.min.js: -------------------------------------------------------------------------------- 1 | (function(factory){if(typeof exports==="object"){module.exports=factory()}else if(typeof define==="function"&&define.amd){define(factory)}else{var glob;try{glob=window}catch(e){glob=self}glob.SparkMD5=factory()}})(function(undefined){"use strict";var add32=function(a,b){return a+b&4294967295},hex_chr=["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"];function cmn(q,a,b,x,s,t){a=add32(add32(a,q),add32(x,t));return add32(a<>>32-s,b)}function md5cycle(x,k){var a=x[0],b=x[1],c=x[2],d=x[3];a+=(b&c|~b&d)+k[0]-680876936|0;a=(a<<7|a>>>25)+b|0;d+=(a&b|~a&c)+k[1]-389564586|0;d=(d<<12|d>>>20)+a|0;c+=(d&a|~d&b)+k[2]+606105819|0;c=(c<<17|c>>>15)+d|0;b+=(c&d|~c&a)+k[3]-1044525330|0;b=(b<<22|b>>>10)+c|0;a+=(b&c|~b&d)+k[4]-176418897|0;a=(a<<7|a>>>25)+b|0;d+=(a&b|~a&c)+k[5]+1200080426|0;d=(d<<12|d>>>20)+a|0;c+=(d&a|~d&b)+k[6]-1473231341|0;c=(c<<17|c>>>15)+d|0;b+=(c&d|~c&a)+k[7]-45705983|0;b=(b<<22|b>>>10)+c|0;a+=(b&c|~b&d)+k[8]+1770035416|0;a=(a<<7|a>>>25)+b|0;d+=(a&b|~a&c)+k[9]-1958414417|0;d=(d<<12|d>>>20)+a|0;c+=(d&a|~d&b)+k[10]-42063|0;c=(c<<17|c>>>15)+d|0;b+=(c&d|~c&a)+k[11]-1990404162|0;b=(b<<22|b>>>10)+c|0;a+=(b&c|~b&d)+k[12]+1804603682|0;a=(a<<7|a>>>25)+b|0;d+=(a&b|~a&c)+k[13]-40341101|0;d=(d<<12|d>>>20)+a|0;c+=(d&a|~d&b)+k[14]-1502002290|0;c=(c<<17|c>>>15)+d|0;b+=(c&d|~c&a)+k[15]+1236535329|0;b=(b<<22|b>>>10)+c|0;a+=(b&d|c&~d)+k[1]-165796510|0;a=(a<<5|a>>>27)+b|0;d+=(a&c|b&~c)+k[6]-1069501632|0;d=(d<<9|d>>>23)+a|0;c+=(d&b|a&~b)+k[11]+643717713|0;c=(c<<14|c>>>18)+d|0;b+=(c&a|d&~a)+k[0]-373897302|0;b=(b<<20|b>>>12)+c|0;a+=(b&d|c&~d)+k[5]-701558691|0;a=(a<<5|a>>>27)+b|0;d+=(a&c|b&~c)+k[10]+38016083|0;d=(d<<9|d>>>23)+a|0;c+=(d&b|a&~b)+k[15]-660478335|0;c=(c<<14|c>>>18)+d|0;b+=(c&a|d&~a)+k[4]-405537848|0;b=(b<<20|b>>>12)+c|0;a+=(b&d|c&~d)+k[9]+568446438|0;a=(a<<5|a>>>27)+b|0;d+=(a&c|b&~c)+k[14]-1019803690|0;d=(d<<9|d>>>23)+a|0;c+=(d&b|a&~b)+k[3]-187363961|0;c=(c<<14|c>>>18)+d|0;b+=(c&a|d&~a)+k[8]+1163531501|0;b=(b<<20|b>>>12)+c|0;a+=(b&d|c&~d)+k[13]-1444681467|0;a=(a<<5|a>>>27)+b|0;d+=(a&c|b&~c)+k[2]-51403784|0;d=(d<<9|d>>>23)+a|0;c+=(d&b|a&~b)+k[7]+1735328473|0;c=(c<<14|c>>>18)+d|0;b+=(c&a|d&~a)+k[12]-1926607734|0;b=(b<<20|b>>>12)+c|0;a+=(b^c^d)+k[5]-378558|0;a=(a<<4|a>>>28)+b|0;d+=(a^b^c)+k[8]-2022574463|0;d=(d<<11|d>>>21)+a|0;c+=(d^a^b)+k[11]+1839030562|0;c=(c<<16|c>>>16)+d|0;b+=(c^d^a)+k[14]-35309556|0;b=(b<<23|b>>>9)+c|0;a+=(b^c^d)+k[1]-1530992060|0;a=(a<<4|a>>>28)+b|0;d+=(a^b^c)+k[4]+1272893353|0;d=(d<<11|d>>>21)+a|0;c+=(d^a^b)+k[7]-155497632|0;c=(c<<16|c>>>16)+d|0;b+=(c^d^a)+k[10]-1094730640|0;b=(b<<23|b>>>9)+c|0;a+=(b^c^d)+k[13]+681279174|0;a=(a<<4|a>>>28)+b|0;d+=(a^b^c)+k[0]-358537222|0;d=(d<<11|d>>>21)+a|0;c+=(d^a^b)+k[3]-722521979|0;c=(c<<16|c>>>16)+d|0;b+=(c^d^a)+k[6]+76029189|0;b=(b<<23|b>>>9)+c|0;a+=(b^c^d)+k[9]-640364487|0;a=(a<<4|a>>>28)+b|0;d+=(a^b^c)+k[12]-421815835|0;d=(d<<11|d>>>21)+a|0;c+=(d^a^b)+k[15]+530742520|0;c=(c<<16|c>>>16)+d|0;b+=(c^d^a)+k[2]-995338651|0;b=(b<<23|b>>>9)+c|0;a+=(c^(b|~d))+k[0]-198630844|0;a=(a<<6|a>>>26)+b|0;d+=(b^(a|~c))+k[7]+1126891415|0;d=(d<<10|d>>>22)+a|0;c+=(a^(d|~b))+k[14]-1416354905|0;c=(c<<15|c>>>17)+d|0;b+=(d^(c|~a))+k[5]-57434055|0;b=(b<<21|b>>>11)+c|0;a+=(c^(b|~d))+k[12]+1700485571|0;a=(a<<6|a>>>26)+b|0;d+=(b^(a|~c))+k[3]-1894986606|0;d=(d<<10|d>>>22)+a|0;c+=(a^(d|~b))+k[10]-1051523|0;c=(c<<15|c>>>17)+d|0;b+=(d^(c|~a))+k[1]-2054922799|0;b=(b<<21|b>>>11)+c|0;a+=(c^(b|~d))+k[8]+1873313359|0;a=(a<<6|a>>>26)+b|0;d+=(b^(a|~c))+k[15]-30611744|0;d=(d<<10|d>>>22)+a|0;c+=(a^(d|~b))+k[6]-1560198380|0;c=(c<<15|c>>>17)+d|0;b+=(d^(c|~a))+k[13]+1309151649|0;b=(b<<21|b>>>11)+c|0;a+=(c^(b|~d))+k[4]-145523070|0;a=(a<<6|a>>>26)+b|0;d+=(b^(a|~c))+k[11]-1120210379|0;d=(d<<10|d>>>22)+a|0;c+=(a^(d|~b))+k[2]+718787259|0;c=(c<<15|c>>>17)+d|0;b+=(d^(c|~a))+k[9]-343485551|0;b=(b<<21|b>>>11)+c|0;x[0]=a+x[0]|0;x[1]=b+x[1]|0;x[2]=c+x[2]|0;x[3]=d+x[3]|0}function md5blk(s){var md5blks=[],i;for(i=0;i<64;i+=4){md5blks[i>>2]=s.charCodeAt(i)+(s.charCodeAt(i+1)<<8)+(s.charCodeAt(i+2)<<16)+(s.charCodeAt(i+3)<<24)}return md5blks}function md5blk_array(a){var md5blks=[],i;for(i=0;i<64;i+=4){md5blks[i>>2]=a[i]+(a[i+1]<<8)+(a[i+2]<<16)+(a[i+3]<<24)}return md5blks}function md51(s){var n=s.length,state=[1732584193,-271733879,-1732584194,271733878],i,length,tail,tmp,lo,hi;for(i=64;i<=n;i+=64){md5cycle(state,md5blk(s.substring(i-64,i)))}s=s.substring(i-64);length=s.length;tail=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];for(i=0;i>2]|=s.charCodeAt(i)<<(i%4<<3)}tail[i>>2]|=128<<(i%4<<3);if(i>55){md5cycle(state,tail);for(i=0;i<16;i+=1){tail[i]=0}}tmp=n*8;tmp=tmp.toString(16).match(/(.*?)(.{0,8})$/);lo=parseInt(tmp[2],16);hi=parseInt(tmp[1],16)||0;tail[14]=lo;tail[15]=hi;md5cycle(state,tail);return state}function md51_array(a){var n=a.length,state=[1732584193,-271733879,-1732584194,271733878],i,length,tail,tmp,lo,hi;for(i=64;i<=n;i+=64){md5cycle(state,md5blk_array(a.subarray(i-64,i)))}a=i-64>2]|=a[i]<<(i%4<<3)}tail[i>>2]|=128<<(i%4<<3);if(i>55){md5cycle(state,tail);for(i=0;i<16;i+=1){tail[i]=0}}tmp=n*8;tmp=tmp.toString(16).match(/(.*?)(.{0,8})$/);lo=parseInt(tmp[2],16);hi=parseInt(tmp[1],16)||0;tail[14]=lo;tail[15]=hi;md5cycle(state,tail);return state}function rhex(n){var s="",j;for(j=0;j<4;j+=1){s+=hex_chr[n>>j*8+4&15]+hex_chr[n>>j*8&15]}return s}function hex(x){var i;for(i=0;i>16)+(y>>16)+(lsw>>16);return msw<<16|lsw&65535}}if(typeof ArrayBuffer!=="undefined"&&!ArrayBuffer.prototype.slice){(function(){function clamp(val,length){val=val|0||0;if(val<0){return Math.max(val+length,0)}return Math.min(val,length)}ArrayBuffer.prototype.slice=function(from,to){var length=this.byteLength,begin=clamp(from,length),end=length,num,target,targetArray,sourceArray;if(to!==undefined){end=clamp(to,length)}if(begin>end){return new ArrayBuffer(0)}num=end-begin;target=new ArrayBuffer(num);targetArray=new Uint8Array(target);sourceArray=new Uint8Array(this,begin,num);targetArray.set(sourceArray);return target}})()}function toUtf8(str){if(/[\u0080-\uFFFF]/.test(str)){str=unescape(encodeURIComponent(str))}return str}function utf8Str2ArrayBuffer(str,returnUInt8Array){var length=str.length,buff=new ArrayBuffer(length),arr=new Uint8Array(buff),i;for(i=0;i>2]|=buff.charCodeAt(i)<<(i%4<<3)}this._finish(tail,length);ret=hex(this._hash);if(raw){ret=hexToBinaryString(ret)}this.reset();return ret};SparkMD5.prototype.reset=function(){this._buff="";this._length=0;this._hash=[1732584193,-271733879,-1732584194,271733878];return this};SparkMD5.prototype.getState=function(){return{buff:this._buff,length:this._length,hash:this._hash.slice()}};SparkMD5.prototype.setState=function(state){this._buff=state.buff;this._length=state.length;this._hash=state.hash;return this};SparkMD5.prototype.destroy=function(){delete this._hash;delete this._buff;delete this._length};SparkMD5.prototype._finish=function(tail,length){var i=length,tmp,lo,hi;tail[i>>2]|=128<<(i%4<<3);if(i>55){md5cycle(this._hash,tail);for(i=0;i<16;i+=1){tail[i]=0}}tmp=this._length*8;tmp=tmp.toString(16).match(/(.*?)(.{0,8})$/);lo=parseInt(tmp[2],16);hi=parseInt(tmp[1],16)||0;tail[14]=lo;tail[15]=hi;md5cycle(this._hash,tail)};SparkMD5.hash=function(str,raw){return SparkMD5.hashBinary(toUtf8(str),raw)};SparkMD5.hashBinary=function(content,raw){var hash=md51(content),ret=hex(hash);return raw?hexToBinaryString(ret):ret};SparkMD5.ArrayBuffer=function(){this.reset()};SparkMD5.ArrayBuffer.prototype.append=function(arr){var buff=concatenateArrayBuffers(this._buff.buffer,arr,true),length=buff.length,i;this._length+=arr.byteLength;for(i=64;i<=length;i+=64){md5cycle(this._hash,md5blk_array(buff.subarray(i-64,i)))}this._buff=i-64>2]|=buff[i]<<(i%4<<3)}this._finish(tail,length);ret=hex(this._hash);if(raw){ret=hexToBinaryString(ret)}this.reset();return ret};SparkMD5.ArrayBuffer.prototype.reset=function(){this._buff=new Uint8Array(0);this._length=0;this._hash=[1732584193,-271733879,-1732584194,271733878];return this};SparkMD5.ArrayBuffer.prototype.getState=function(){var state=SparkMD5.prototype.getState.call(this);state.buff=arrayBuffer2Utf8Str(state.buff);return state};SparkMD5.ArrayBuffer.prototype.setState=function(state){state.buff=utf8Str2ArrayBuffer(state.buff,true);return SparkMD5.prototype.setState.call(this,state)};SparkMD5.ArrayBuffer.prototype.destroy=SparkMD5.prototype.destroy;SparkMD5.ArrayBuffer.prototype._finish=SparkMD5.prototype._finish;SparkMD5.ArrayBuffer.hash=function(arr,raw){var hash=md51_array(new Uint8Array(arr)),ret=hex(hash);return raw?hexToBinaryString(ret):ret};return SparkMD5}); 2 | -------------------------------------------------------------------------------- /public/workers/hash.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | self.importScripts('/spark-md5.min.js'); 3 | 4 | self.onmessage = (e) => { 5 | const { fileChunks } = e.data; 6 | const spark = new self.SparkMD5.ArrayBuffer(); 7 | 8 | let percentage = 0, 9 | count = 0; 10 | 11 | const loadNext = (index) => { 12 | const reader = new FileReader(); 13 | reader.readAsArrayBuffer(fileChunks[index].fileChunk); 14 | reader.onload = (e) => { 15 | count++; 16 | spark.append(e.target.result); 17 | if (count === fileChunks.length) { 18 | self.postMessage({ 19 | percentage: 100, 20 | hash: spark.end(), 21 | }); 22 | self.close(); 23 | } else { 24 | percentage += 100 / fileChunks.length; 25 | self.postMessage({ 26 | percentage, 27 | }); 28 | 29 | loadNext(count); 30 | } 31 | }; 32 | }; 33 | 34 | loadNext(0); 35 | }; 36 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const path = require('path'); 3 | const fse = require('fs-extra'); 4 | const multiparty = require('multiparty'); 5 | 6 | const server = http.createServer(); 7 | const UPLOAD_DIR = path.resolve(__dirname, '..', 'target'); 8 | 9 | const resolvePost = (req) => { 10 | return new Promise((resolve) => { 11 | let chunk = ''; 12 | req.on('data', (data) => { 13 | chunk += data; 14 | }); 15 | req.on('end', () => { 16 | resolve(JSON.parse(chunk)); 17 | }); 18 | }); 19 | }; 20 | 21 | const extractExt = (filename) => 22 | filename.slice(filename.lastIndexOf('.'), filename.length); 23 | 24 | const createUploadedList = async (fileHash) => { 25 | const fileDir = path.resolve(UPLOAD_DIR, fileHash); 26 | return fse.existsSync(fileDir) ? await fse.readdir(fileDir) : []; 27 | }; 28 | 29 | const pipeStream = (chunkPath, writeStream) => { 30 | return new Promise((resolve) => { 31 | const chunkReadStream = fse.createReadStream(chunkPath); 32 | chunkReadStream.on('end', () => { 33 | fse.unlinkSync(chunkPath); 34 | resolve(); 35 | }); 36 | chunkReadStream.pipe(writeStream); 37 | }); 38 | }; 39 | 40 | const mergeFileChunks = async (targetFilePath, fileHash, chunkSize) => { 41 | const chunkDir = path.resolve(UPLOAD_DIR, fileHash); 42 | const chunkNames = await fse.readdir(chunkDir); 43 | //根据分片下表排序 44 | chunkNames.sort((a, b) => a.split('_')[1] - b.split('_')[1]); 45 | 46 | await fse.writeFileSync(targetFilePath, ''); 47 | 48 | await Promise.all( 49 | chunkNames.map((chunkName, index) => { 50 | const chunkPath = path.resolve(chunkDir, chunkName); 51 | return pipeStream( 52 | chunkPath, 53 | fse.createWriteStream(targetFilePath, { 54 | start: index * chunkSize, 55 | }) 56 | ); 57 | }) 58 | ); 59 | 60 | await fse.rmdir(chunkDir); 61 | }; 62 | 63 | server.on('request', async (req, res) => { 64 | res.setHeader('Access-Control-Allow-Origin', '*'); 65 | res.setHeader('Access-Control-Allow-Headers', '*'); 66 | if (req.method === 'OPTIONS') { 67 | res.end(); 68 | return; 69 | } 70 | 71 | if (req.url === '/verify') { 72 | const data = await resolvePost(req); 73 | const { fileHash, fileName } = data; 74 | 75 | const ext = extractExt(fileName); 76 | const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`); 77 | 78 | if (fse.existsSync(filePath)) { 79 | res.end( 80 | JSON.stringify({ 81 | shouldUploadFile: false, 82 | }) 83 | ); 84 | } else { 85 | res.end( 86 | JSON.stringify({ 87 | shouldUploadFile: true, 88 | uploadedChunks: await createUploadedList(fileHash), 89 | }) 90 | ); 91 | } 92 | return; 93 | } 94 | 95 | if (req.url === '/merge') { 96 | const data = await resolvePost(req); 97 | const { fileHash, fileName, chunkSize } = data; 98 | const ext = extractExt(fileName); 99 | const targetFilePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`); 100 | await mergeFileChunks(targetFilePath, fileHash, chunkSize); 101 | 102 | res.end( 103 | JSON.stringify({ 104 | code: 0, 105 | msg: `file ${fileName} merged.`, 106 | }) 107 | ); 108 | return; 109 | } 110 | 111 | const multipart = new multiparty.Form(); 112 | 113 | multipart.parse(req, async (err, fields, files) => { 114 | if (err) { 115 | return; 116 | } 117 | const [chunk] = files.chunk; 118 | const [hash] = fields.hash; 119 | const [fileHash] = fields.fileHash; 120 | const chunkDir = path.resolve(UPLOAD_DIR, fileHash); 121 | 122 | if (!fse.existsSync(chunkDir)) { 123 | await fse.mkdirs(chunkDir); 124 | } 125 | 126 | await fse.move(chunk.path, `${chunkDir}/${hash}`, { overwrite: true }); 127 | res.status = 200; 128 | res.end('received file chunk'); 129 | }); 130 | }); 131 | 132 | server.listen(8080, () => console.log('listening 8080...')); 133 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.css'; 2 | 3 | .container { 4 | width: 650px; 5 | margin: 0 auto; 6 | margin-top: 20px; 7 | } 8 | 9 | .container div:not(:last-child) { 10 | margin-bottom: 5px; 11 | } 12 | 13 | .center { 14 | text-align: center; 15 | } 16 | 17 | .file-selected { 18 | padding-left: 10px; 19 | font-style: italic; 20 | max-width: 300px; 21 | text-overflow: ellipsis; 22 | overflow: hidden; 23 | display: inline-block; 24 | white-space: nowrap; 25 | vertical-align: text-top; 26 | } 27 | .file-selected-size { 28 | padding-left: 10px; 29 | font-weight: 600; 30 | font-style: italic; 31 | } 32 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import { Upload, Button, Row, Col, Progress, Divider } from 'antd'; 3 | import { 4 | UploadOutlined, 5 | CheckCircleFilled, 6 | CloseCircleFilled, 7 | } from '@ant-design/icons'; 8 | import { useMemo, useRef, useState } from 'react'; 9 | import { request } from './request'; 10 | import toast, { Toaster } from 'react-hot-toast'; 11 | 12 | const UPLOAD_STATES = { 13 | INITIAL: 0, 14 | HASHING: 1, 15 | UPLOADING: 2, 16 | PAUSED: 3, 17 | SUCCESS: 4, 18 | FAILED: 5, 19 | }; 20 | 21 | function App() { 22 | const [file, setFile] = useState(null); 23 | const [hashPercent, setHashPercent] = useState(0); 24 | const [chunks, setChunks] = useState([]); 25 | const [uploadState, setUploadState] = useState(UPLOAD_STATES.INITIAL); 26 | const fileHashRef = useRef(null); 27 | const pendingRequest = useRef([]); 28 | const toastId = useRef(null); 29 | const DEFAULT_CHUNK_SIZE = 100 * 1024; 30 | const MAX_CHUNK_COUNT = 15; 31 | 32 | const totalPercent = useMemo(() => { 33 | if (!file || chunks.length < 1) return 0; 34 | const loaded = chunks 35 | .map((item) => item.chunk.size * item.percent) 36 | .reduce((acc, cur) => acc + cur); 37 | 38 | return (loaded / file.size).toFixed(2); 39 | // eslint-disable-next-line react-hooks/exhaustive-deps 40 | }, [chunks]); 41 | 42 | const fileChunkSize = useMemo(() => { 43 | if (!file) return; 44 | const chunkCount = Math.ceil(file.size / DEFAULT_CHUNK_SIZE); 45 | if (chunkCount > MAX_CHUNK_COUNT) { 46 | return Math.ceil(file.size / MAX_CHUNK_COUNT); 47 | } else { 48 | return DEFAULT_CHUNK_SIZE; 49 | } 50 | // eslint-disable-next-line react-hooks/exhaustive-deps 51 | }, [file]); 52 | 53 | const beforeUpload = (file) => { 54 | // clear 55 | reset(); 56 | 57 | setFile(file); 58 | return false; 59 | }; 60 | 61 | const reset = () => { 62 | setUploadState(UPLOAD_STATES.INITIAL); 63 | setHashPercent(0); 64 | setChunks([]); 65 | fileHashRef.current = null; 66 | }; 67 | 68 | const shouldUpload = async (fileHash, fileName) => { 69 | const { data } = await request({ 70 | url: 'http://localhost:8080/verify', 71 | headers: { 72 | 'content-type': 'application/json', 73 | }, 74 | data: JSON.stringify({ 75 | fileHash, 76 | fileName, 77 | }), 78 | }); 79 | return JSON.parse(data); 80 | }; 81 | 82 | const upload = async () => { 83 | try { 84 | if (!file) return; 85 | 86 | if (uploadState === UPLOAD_STATES.INITIAL) { 87 | toastId.current = toast.loading('分片...'); 88 | const fileChunkList = createChunks(file, fileChunkSize); 89 | 90 | setUploadState(UPLOAD_STATES.HASHING); 91 | toast.loading('计算文件hash...', { id: toastId.current }); 92 | fileHashRef.current = await computeHash(fileChunkList); 93 | 94 | const primaryFileChunks = fileChunkList.map( 95 | ({ fileChunk }, index) => ({ 96 | fileHash: fileHashRef.current, 97 | chunk: fileChunk, 98 | hash: `${fileHashRef.current}_${index}`, 99 | percent: 0, 100 | }) 101 | ); 102 | setChunks(primaryFileChunks); 103 | } 104 | 105 | setUploadState(UPLOAD_STATES.UPLOADING); 106 | 107 | toast.loading('分片上传中...', { id: toastId.current }); 108 | 109 | const { shouldUploadFile, uploadedChunks } = await shouldUpload( 110 | fileHashRef.current, 111 | file.name 112 | ); 113 | if (!shouldUploadFile) { 114 | setUploadState(UPLOAD_STATES.SUCCESS); 115 | toast.success('文件秒传成功!', { id: toastId.current }); 116 | setChunks((preChunks) => { 117 | return preChunks.map((item) => ({ 118 | ...item, 119 | percent: 100, 120 | })); 121 | }); 122 | return; 123 | } 124 | let chunkArr = []; 125 | //render chunks 126 | setChunks((preChunks) => { 127 | chunkArr = preChunks.map( 128 | ({ fileHash, chunk, hash, percent }) => ({ 129 | fileHash, 130 | chunk, 131 | hash, 132 | percent: uploadedChunks.includes(hash) ? 100 : percent, 133 | }) 134 | ); 135 | return chunkArr; 136 | }); 137 | await uploadChunks(chunkArr, uploadedChunks); 138 | } catch (err) { 139 | toast.error(`${err}`, { id: toastId.current }); 140 | setUploadState(UPLOAD_STATES.FAILED); 141 | } 142 | }; 143 | 144 | const uploadChunks = async (chunks, uploadedChunks = []) => { 145 | if (chunks.length < 1) return; 146 | 147 | let reqList = chunks 148 | .filter(({ hash }) => !uploadedChunks.includes(hash)) 149 | .map(({ chunk, hash, fileHash }) => { 150 | let formData = new FormData(); 151 | formData.append('chunk', chunk); 152 | formData.append('hash', hash); 153 | formData.append('fileHash', fileHash); 154 | formData.append('filename', file.name); 155 | return { formData, hash }; 156 | }) 157 | .map(({ formData, hash }) => { 158 | return request({ 159 | url: 'http://localhost:8080', 160 | data: formData, 161 | onProgress: createProgressHandler(hash), 162 | requestList: pendingRequest.current, 163 | }); 164 | }); 165 | 166 | // 发送切片 167 | await Promise.all(reqList); 168 | if (reqList.length + uploadedChunks.length === chunks.length) { 169 | // 发送合并请求 170 | toast.loading('合并文件分片...', { id: toastId.current }); 171 | await mergeRequest(); 172 | setUploadState(UPLOAD_STATES.SUCCESS); 173 | toast.success('文件已上传', { id: toastId.current }); 174 | } else { 175 | toast.error('上传失败', { id: toastId.current }); 176 | setUploadState(UPLOAD_STATES.FAILED); 177 | } 178 | }; 179 | const createProgressHandler = (hash) => { 180 | // get initial percent 181 | const chunk = chunks.find((item) => item.hash === hash); 182 | const initialPercent = chunk?.percent || 0; 183 | 184 | return (e) => { 185 | setChunks((preChunks) => { 186 | let preChunk = preChunks.find((item) => item.hash === hash); 187 | preChunk.percent = 188 | initialPercent + 189 | (e.loaded / e.total) * (100 - initialPercent); 190 | return [...preChunks]; 191 | }); 192 | }; 193 | }; 194 | 195 | const mergeRequest = async () => { 196 | await request({ 197 | url: 'http://localhost:8080/merge', 198 | headers: { 199 | 'content-type': 'application/json', 200 | }, 201 | data: JSON.stringify({ 202 | fileHash: fileHashRef.current, 203 | fileName: file.name, 204 | chunkSize: fileChunkSize, 205 | }), 206 | }); 207 | }; 208 | 209 | const createChunks = (file, chunkSize = DEFAULT_CHUNK_SIZE) => { 210 | const fileChunkList = []; 211 | let cur = 0; 212 | while (cur < file.size) { 213 | fileChunkList.push({ fileChunk: file.slice(cur, cur + chunkSize) }); 214 | cur += chunkSize; 215 | } 216 | return fileChunkList; 217 | }; 218 | 219 | const handlePauseUpload = () => { 220 | setUploadState(UPLOAD_STATES.PAUSED); 221 | toast('暂停上传', { id: toastId.current }); 222 | pendingRequest.current.forEach((xhr) => xhr?.abort()); 223 | pendingRequest.current = []; 224 | }; 225 | const handleResumeUpload = async () => { 226 | try { 227 | setUploadState(UPLOAD_STATES.UPLOADING); 228 | toast.loading('分片上传中...', { id: toastId.current }); 229 | const { uploadedChunks } = await shouldUpload( 230 | fileHashRef.current, 231 | file.name 232 | ); 233 | uploadChunks(chunks, uploadedChunks); 234 | } catch (err) { 235 | toast.error(`${err}`, { id: toastId.current }); 236 | setUploadState(UPLOAD_STATES.FAILED); 237 | } 238 | }; 239 | 240 | const clearFile = () => { 241 | setFile(null); 242 | reset(); 243 | }; 244 | 245 | const computeHash = (fileChunks) => { 246 | return new Promise((resolve, reject) => { 247 | const hashWorker = new Worker('/workers/hash.js'); 248 | hashWorker.postMessage({ fileChunks }); 249 | hashWorker.onmessage = (e) => { 250 | const { percentage, hash } = e.data; 251 | setHashPercent(percentage.toFixed(2)); 252 | if (hash) { 253 | resolve(hash); 254 | } 255 | }; 256 | }); 257 | }; 258 | const formatBytes = (bytes, decimals = 2) => { 259 | if (bytes === 0) return '0 Bytes'; 260 | 261 | const k = 1024; 262 | const dm = decimals < 0 ? 0 : decimals; 263 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 264 | 265 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 266 | 267 | return (bytes / Math.pow(k, i)).toFixed(dm) + ' ' + sizes[i]; 268 | }; 269 | 270 | const showStatus = (percent) => { 271 | if (percent === 0) { 272 | if (uploadState === UPLOAD_STATES.FAILED) 273 | return ( 274 | <> 275 | 276 | {` 连接异常`} 277 | 278 | ); 279 | else return `等待上传...`; 280 | } 281 | if (percent === 100) { 282 | return ; 283 | } 284 | switch (uploadState) { 285 | case UPLOAD_STATES.PAUSED: 286 | return `已暂停 [${percent.toFixed(2)}%]`; 287 | case UPLOAD_STATES.UPLOADING: 288 | return `上传中 [${percent.toFixed(2)}%]`; 289 | case UPLOAD_STATES.FAILED: 290 | return ( 291 | <> 292 | 293 | {` 上传失败`} 294 | 295 | ); 296 | 297 | default: 298 | return; 299 | } 300 | }; 301 | 302 | const renderChunks = () => { 303 | return chunks.map((chunk) => ( 304 | 305 | 306 | {chunk.hash} 307 | 308 | 309 | {formatBytes(chunk.chunk.size)} 310 | 311 | 312 | 0 325 | ? '' 326 | : { 327 | '0%': '#ffc107', 328 | '100%': '#87d068', 329 | } 330 | } 331 | style={{ width: '75%' }} 332 | /> 333 | 334 | 335 | )); 336 | }; 337 | 338 | const disableSelectFile = 339 | uploadState === UPLOAD_STATES.HASHING || 340 | uploadState === UPLOAD_STATES.PAUSED || 341 | uploadState === UPLOAD_STATES.UPLOADING; 342 | 343 | return ( 344 | <> 345 | 355 |
356 | 357 | 358 | 364 | 370 | 371 | {file && ( 372 | <> 373 | {`${file.name}`} 374 | {`[${formatBytes( 375 | file.size 376 | )}]`} 377 | 378 | )} 379 | 380 | 381 | 393 | {(uploadState === UPLOAD_STATES.SUCCESS || 394 | uploadState === UPLOAD_STATES.FAILED) && ( 395 | 398 | )} 399 | {uploadState === UPLOAD_STATES.UPLOADING && ( 400 | 403 | )} 404 | {uploadState === UPLOAD_STATES.PAUSED && ( 405 | 408 | )} 409 | 410 | 411 | 412 | {!!file && ( 413 | <> 414 | 415 | 计算文件hash进度: 416 | 417 | 418 | 419 | 420 | 421 | 422 | )} 423 | 424 | {chunks.length > 0 && ( 425 | <> 426 | 427 | 上传总进度: 428 | 429 | 434 | 435 | 436 | 437 | 438 | 439 | 切片Hash 440 | 441 | 442 | 大小 443 | 444 | 445 | 上传进度 446 | 447 | 448 | 449 | )} 450 | 451 | {renderChunks()} 452 |
453 | 454 | ); 455 | } 456 | 457 | export default App; 458 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | // 9 | , 10 | // , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | export const request = ({ 2 | url, 3 | method = 'post', 4 | data, 5 | headers = {}, 6 | onProgress, 7 | requestList, 8 | }) => { 9 | return new Promise((resolve, reject) => { 10 | const xhr = new XMLHttpRequest(); 11 | xhr.open(method, url); 12 | //set headers 13 | Object.keys(headers).forEach((key) => 14 | xhr.setRequestHeader(key, headers[key]) 15 | ); 16 | xhr.upload.onprogress = onProgress; 17 | xhr.send(data); 18 | xhr.onload = (e) => { 19 | if (e.currentTarget.status === 200) { 20 | if (requestList && requestList.length > 0) { 21 | let itemIndex = requestList.findIndex( 22 | (item) => item === xhr 23 | ); 24 | requestList.splice(itemIndex, 1); 25 | } 26 | resolve({ 27 | data: e.target.response, 28 | }); 29 | } else { 30 | reject(new Error('上传失败')); 31 | } 32 | }; 33 | xhr.onerror = () => { 34 | reject(new Error('网络好像出问题啦~')); 35 | }; 36 | 37 | requestList?.push(xhr); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | --------------------------------------------------------------------------------