├── .gitignore ├── README.md ├── nodeServer ├── tmp │ └── 下载 (4).jpeg └── uploads │ └── 下载 (4).jpeg ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── server.js ├── src ├── Upload.js ├── index.js └── style.css └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .history -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## fileUpload 2 | 3 | React + Node 实现大文件分片上传、断点续传、秒传 4 | 5 | 6 | 7 | ## 启动 8 | 9 | 1、安装依赖 10 | 11 | ``` 12 | npm install 13 | ``` 14 | 15 | 16 | 17 | 2、启动React 18 | 19 | ``` 20 | npm run start 21 | ``` 22 | 23 | 24 | 25 | 3、启动Node 26 | 27 | ``` 28 | npm run server 29 | ``` 30 | 31 | -------------------------------------------------------------------------------- /nodeServer/tmp/下载 (4).jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linhexs/file-upload/345d09d5cb69c4067987cfecf88c5ee32cf81cb6/nodeServer/tmp/下载 (4).jpeg -------------------------------------------------------------------------------- /nodeServer/uploads/下载 (4).jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linhexs/file-upload/345d09d5cb69c4067987cfecf88c5ee32cf81cb6/nodeServer/uploads/下载 (4).jpeg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-upload", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "antd": "^4.16.13", 10 | "axios": "^0.24.0", 11 | "express": "^4.17.1", 12 | "react": "^17.0.2", 13 | "react-dom": "^17.0.2", 14 | "react-scripts": "4.0.3", 15 | "react-slidedown": "^2.4.6", 16 | "spark-md5": "^3.0.2", 17 | "web-vitals": "^1.0.1" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "server": "node server.js" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "concat-files": "^0.1.1", 46 | "formidable": "^1.2.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linhexs/file-upload/345d09d5cb69c4067987cfecf88c5ee32cf81cb6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Upload 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linhexs/file-upload/345d09d5cb69c4067987cfecf88c5ee32cf81cb6/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/linhexs/file-upload/345d09d5cb69c4067987cfecf88c5ee32cf81cb6/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 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | let express = require('express') 2 | let app = express() 3 | let formidable = require('formidable') 4 | let path = require('path') 5 | let uploadDir = 'nodeServer/uploads' 6 | let fs = require('fs-extra') 7 | let concat = require('concat-files') 8 | 9 | // 处理跨域 10 | app.all('*', (req, res, next) => { 11 | res.header('Access-Control-Allow-Origin', '*') 12 | res.header( 13 | 'Access-Control-Allow-Headers', 14 | 'Content-Type,Content-Length, Authorization, Accept,X-Requested-With' 15 | ) 16 | res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS') 17 | res.header('X-Powered-By', ' 3.2.1') 18 | if (req.method === 'OPTIONS') res.send(200) /*让options请求快速返回*/ 19 | else next() 20 | }) 21 | 22 | // 列出文件夹下所有文件 23 | function listDir(path) { 24 | return new Promise((resolve, reject) => { 25 | fs.readdir(path, (err, data) => { 26 | if (err) { 27 | reject(err) 28 | return 29 | } 30 | // 把mac系统下的临时文件去掉 31 | if (data && data.length > 0 && data[0] === '.DS_Store') { 32 | data.splice(0, 1) 33 | } 34 | resolve(data) 35 | }) 36 | }) 37 | } 38 | 39 | // 文件或文件夹是否存在 40 | function isExist(filePath) { 41 | return new Promise((resolve, reject) => { 42 | fs.stat(filePath, (err, stats) => { 43 | // 文件不存在 44 | if (err && err.code === 'ENOENT') { 45 | resolve(false) 46 | } else { 47 | resolve(true) 48 | } 49 | }) 50 | }) 51 | } 52 | 53 | // 获取文件Chunk列表 54 | async function getChunkList(filePath, folderPath, callback) { 55 | let isFileExit = await isExist(filePath) 56 | let result = {} 57 | // 如果文件已在存在, 不用再继续上传, 真接秒传 58 | if (isFileExit) { 59 | result = { 60 | stat: 1, 61 | file: { 62 | isExist: true, 63 | name: filePath 64 | }, 65 | desc: 'file is exist' 66 | } 67 | } else { 68 | let isFolderExist = await isExist(folderPath) 69 | // 如果文件夹(md5值后的文件)存在, 就获取已经上传的块 70 | let fileList = [] 71 | if (isFolderExist) { 72 | fileList = await listDir(folderPath) 73 | } 74 | result = { 75 | stat: 1, 76 | chunkList: fileList, 77 | desc: 'folder list' 78 | } 79 | } 80 | callback(result) 81 | } 82 | 83 | /** 84 | * 检查md5 85 | */ 86 | app.get('/check/file', (req, resp) => { 87 | let query = req.query 88 | let fileName = query.fileName 89 | let fileMd5Value = query.fileMd5Value 90 | // 获取文件Chunk列表 91 | getChunkList( 92 | path.join(uploadDir, fileName), 93 | path.join(uploadDir, fileMd5Value), 94 | data => { 95 | resp.send(data) 96 | } 97 | ) 98 | }) 99 | 100 | app.all('/upload', (req, resp) => { 101 | const form = new formidable.IncomingForm({ 102 | uploadDir: 'nodeServer/tmp' 103 | }) 104 | form.parse(req, function(err, fields, file) { 105 | let index = fields.index 106 | let fileMd5Value = fields.fileMd5Value 107 | let folder = path.resolve(__dirname, 'nodeServer/uploads', fileMd5Value) 108 | folderIsExit(folder).then(val => { 109 | let destFile = path.resolve(folder, fields.index) 110 | copyFile(file.data.path, destFile).then( 111 | successLog => { 112 | resp.send({ 113 | stat: 1, 114 | desc: index 115 | }) 116 | }, 117 | errorLog => { 118 | resp.send({ 119 | stat: 0, 120 | desc: 'Error' 121 | }) 122 | } 123 | ) 124 | }) 125 | }) 126 | 127 | // 文件夹是否存在, 不存在则创建文件 128 | function folderIsExit(folder) { 129 | return new Promise(async (resolve, reject) => { 130 | await fs.ensureDirSync(path.join(folder)) 131 | resolve(true) 132 | }) 133 | } 134 | // 把文件从一个目录拷贝到别一个目录 135 | function copyFile(src, dest) { 136 | let promise = new Promise((resolve, reject) => { 137 | fs.rename(src, dest, err => { 138 | if (err) { 139 | reject(err) 140 | } else { 141 | resolve('copy file:' + dest + ' success!') 142 | } 143 | }) 144 | }) 145 | return promise 146 | } 147 | }) 148 | 149 | // 合并文件 150 | async function mergeFiles(srcDir, targetDir, newFileName) { 151 | let fileArr = await listDir(srcDir) 152 | fileArr.sort((x,y) => { 153 | return x-y; 154 | }) 155 | // 把文件名加上文件夹的前缀 156 | for (let i = 0; i < fileArr.length; i++) { 157 | fileArr[i] = srcDir + '/' + fileArr[i] 158 | } 159 | concat(fileArr, path.join(targetDir, newFileName), err => { 160 | if(err) { 161 | return false 162 | } 163 | return true 164 | }) 165 | } 166 | 167 | // 合成 168 | app.all('/merge', (req, resp) => { 169 | let query = req.query 170 | let md5 = query.md5 171 | let fileName = query.fileName 172 | const res = mergeFiles(path.join(uploadDir, md5), uploadDir, fileName) 173 | resp.send({ 174 | stat: res ? 1 : 0 175 | }) 176 | }) 177 | 178 | app.listen(1111, () => { 179 | console.log('服务启动完成,端口监听1111!') 180 | }) -------------------------------------------------------------------------------- /src/Upload.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useReducer } from 'react' 2 | import { Button, Progress, message } from 'antd'; 3 | import { SlideDown } from 'react-slidedown' 4 | import SparkMD5 from 'spark-md5' 5 | import 'react-slidedown/lib/slidedown.css' 6 | import './style.css' 7 | import axios from 'axios' 8 | 9 | const BaseUrl = 'http://localhost:1111' 10 | 11 | const initialState = { checkPercent: 0, uploadPercent: 0 }; 12 | 13 | function reducer(state, action) { 14 | switch (action.type) { 15 | case 'check': 16 | initialState.checkPercent = action.checkPercent 17 | return { ...initialState } 18 | case 'upload': 19 | initialState.uploadPercent = action.uploadPercent 20 | return { ...initialState } 21 | case 'init': 22 | initialState.checkPercent = 0 23 | initialState.uploadPercent = 0 24 | return { ...initialState } 25 | default: 26 | return { checkPercent: state.checkPercent, uploadPercent: state.uploadPercent } 27 | } 28 | } 29 | 30 | const Upload = () => { 31 | const [state, dispatch] = useReducer(reducer, initialState) 32 | const inputRef = useRef(null) 33 | const chunks = 100; // 切成100份 34 | const chunkSize = 5 * 1024 * 1024 // 切片大小 35 | let checkCurrentChunk = 0; // 检查,当前切片 36 | let uploadCurrentChunk = 0 // 上传,当前切片 37 | 38 | /** 39 | * 将文件转换成md5并进行切片 40 | * @returns md5 41 | */ 42 | const md5File = (file) => { 43 | return new Promise((resolve, reject) => { 44 | // 文件截取 45 | let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice, 46 | chunkSize = file?.size / 100, 47 | spark = new SparkMD5.ArrayBuffer(), 48 | fileReader = new FileReader(); 49 | 50 | fileReader.onload = function (e) { 51 | console.log('read chunk nr', checkCurrentChunk + 1, 'of', chunks); 52 | spark.append(e.target.result); 53 | checkCurrentChunk += 1; 54 | 55 | if (checkCurrentChunk < chunks) { 56 | loadNext(); 57 | } else { 58 | let result = spark.end() 59 | resolve(result) 60 | } 61 | }; 62 | 63 | fileReader.onerror = function () { 64 | message.error('文件读取错误') 65 | }; 66 | 67 | const loadNext = () => { 68 | const start = checkCurrentChunk * chunkSize, 69 | end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize; 70 | 71 | // 文件切片 72 | fileReader.readAsArrayBuffer(blobSlice.call(file, start, end)); 73 | // 检查进度条 74 | dispatch({ type: 'check', checkPercent: checkCurrentChunk + 1 }) 75 | } 76 | 77 | loadNext(); 78 | }) 79 | 80 | } 81 | 82 | /** 83 | * 校验文件 84 | * @param {*} fileName 文件名 85 | * @param {*} fileMd5Value md5文件 86 | * @returns 87 | */ 88 | const checkFileMD5 = (fileName, fileMd5Value) => { 89 | let url = BaseUrl + '/check/file?fileName=' + fileName + "&fileMd5Value=" + fileMd5Value 90 | return axios.get(url) 91 | } 92 | 93 | // 上传chunk 94 | function upload({ i, file, fileMd5Value, chunks }) { 95 | uploadCurrentChunk = 0 96 | //构造一个表单,FormData是HTML5新增的 97 | let end = (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize 98 | let form = new FormData() 99 | form.append("data", file.slice(i * chunkSize, end)) //file对象的slice方法用于切出文件的一部分 100 | form.append("total", chunks) //总片数 101 | form.append("index", i) //当前是第几片 102 | form.append("fileMd5Value", fileMd5Value) 103 | return axios({ 104 | method: 'post', 105 | url: BaseUrl + "/upload", 106 | data: form 107 | }).then(({ data }) => { 108 | if (data.stat) { 109 | uploadCurrentChunk = uploadCurrentChunk + 1 110 | const uploadPercent = Math.ceil((uploadCurrentChunk / chunks) * 100) 111 | dispatch({ type: 'upload', uploadPercent }) 112 | } 113 | }) 114 | } 115 | 116 | /** 117 | * 上传chunk 118 | * @param {*} fileMd5Value 119 | * @param {*} chunkList 120 | */ 121 | async function checkAndUploadChunk(file, fileMd5Value, chunkList) { 122 | let chunks = Math.ceil(file.size / chunkSize) 123 | const requestList = [] 124 | for (let i = 0; i < chunks; i++) { 125 | let exit = chunkList.indexOf(i + "") > -1 126 | // 如果不存在,则上传 127 | if (!exit) { 128 | requestList.push(upload({ i, file, fileMd5Value, chunks })) 129 | } 130 | } 131 | 132 | // 并发上传 133 | if (requestList?.length) { 134 | await Promise.all(requestList) 135 | } 136 | } 137 | 138 | const responseChange = async (file) => { 139 | // 1.校验文件,返回md5 140 | const fileMd5Value = await md5File(file) 141 | // 2.校验文件的md5 142 | const { data } = await checkFileMD5(file.name, fileMd5Value) 143 | // 如果文件已存在, 就秒传 144 | if (data?.file) { 145 | message.success('文件已秒传') 146 | return 147 | } 148 | // 3:检查并上传切片 149 | await checkAndUploadChunk(file, fileMd5Value, data.chunkList) 150 | // 4:通知服务器所有服务器分片已经上传完成 151 | notifyServer(file, fileMd5Value) 152 | } 153 | 154 | /** 155 | * 所有的分片上传完成,准备合成 156 | * @param {*} file 157 | * @param {*} fileMd5Value 158 | */ 159 | function notifyServer(file, fileMd5Value) { 160 | let url = BaseUrl + '/merge?md5=' + fileMd5Value + "&fileName=" + file.name + "&size=" + file.size 161 | axios.get(url).then(({ data }) => { 162 | if (data.stat) { 163 | message.success('上传成功') 164 | } else { 165 | message.error('上传失败') 166 | } 167 | }) 168 | } 169 | 170 | useEffect(() => { 171 | const changeFile = ({ target }) => { 172 | dispatch({ type: 'init' }) 173 | const file = target.files[0] 174 | responseChange(file) 175 | } 176 | 177 | document.addEventListener("change", changeFile) 178 | 179 | return () => { 180 | document.removeEventListener("change", changeFile) 181 | } 182 | // eslint-disable-next-line react-hooks/exhaustive-deps 183 | }, []) 184 | 185 | return ( 186 |
187 |
188 | 点击上传文件: 189 | 190 | 191 |
192 | {state.checkPercent > 0 && ( 193 | 194 |
195 |
校验文件进度:
196 |
197 |
198 | )} 199 | {state.uploadPercent > 0 && ( 200 | 201 |
202 | 上传文件进度: 203 |
204 |
205 | )} 206 |
207 | ) 208 | } 209 | 210 | export default Upload 211 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Upload from './Upload'; 4 | import 'antd/dist/antd.css' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | .wrap { 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | justify-content: center; 12 | width: 500px; 13 | margin: 0 auto; 14 | } 15 | 16 | .upload{ 17 | padding-top: 100px; 18 | line-height: 32px; 19 | } 20 | 21 | .uploading{ 22 | display: flex; 23 | padding-top: 30px; 24 | align-items: center; 25 | } 26 | 27 | .react-slidedown.my-dropdown-slidedown { 28 | transition-duration: .1s; 29 | /* transition-timing-function: ease-in-out; */ 30 | } 31 | 32 | #file { 33 | position: absolute; 34 | left: 0; 35 | top: 0; 36 | width: 100px; 37 | height: 40px; 38 | display: block; 39 | opacity: 0; 40 | cursor: pointer; 41 | } 42 | --------------------------------------------------------------------------------