├── .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 | 
41 |
42 | ### 上传中途失败,下次断点续传
43 |
44 |
45 | 
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 |
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 | }
367 | >
368 | 选择文件
369 |
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 |
--------------------------------------------------------------------------------