├── .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 |
197 |
198 | )}
199 | {state.uploadPercent > 0 && (
200 |
201 |
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 |
--------------------------------------------------------------------------------