背景
最近在寫畢設的時候,涉及到了一些文件上傳的功能,其中包括了普通文件上傳,大文件上傳,斷點續傳等等
服務端依賴
- koa(node.js框架)
- koa-router(Koa路由)
- koa-body(Koa body 解析中間件,可以用于解析post請求內容)
- koa-static-cache(Koa 靜態資源中間件,用于處理靜態資源請求)
- koa-bodyparser(解析 request.body 的內容)
后端配置跨域
1
2
3
4
5
6
7
8
9
10
11
12
13
|
app.use(async (ctx, next) => { ctx.set( 'Access-Control-Allow-Origin' , '*' ); ctx.set( 'Access-Control-Allow-Headers' , 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild' , ); ctx.set( 'Access-Control-Allow-Methods' , 'PUT, POST, GET, DELETE, OPTIONS' ); if (ctx.method == 'OPTIONS' ) { ctx.body = 200; } else { await next(); } }); |
后端配置靜態資源訪問 使用 koa-static-cache
1
2
3
4
5
6
7
8
|
// 靜態資源處理 app.use( KoaStaticCache( './pulbic' , { prefix: '/public' , dynamic: true , gzip: true , }), ); |
后端配置requst body parse 使用 koa-bodyparser
1
2
|
const bodyParser = require( 'koa-bodyparser' ); app.use(bodyParser()); |
前端依賴
- React
- Antd
- axios
正常文件上傳
后端
后端只需要使用 koa-body 配置好options,作為中間件,傳入router.post('url',middleware,callback)即可
后端代碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// 上傳配置 const uploadOptions = { // 支持文件格式 multipart: true , formidable: { // 上傳目錄 這邊直接上傳到public文件夾,方便訪問 文件夾后面要記得加/ uploadDir: path.join(__dirname, '../../pulbic/' ), // 保留文件擴展名 keepExtensions: true , }, }; router.post( '/upload' , new KoaBody(uploadOptions), (ctx, next) => { // 獲取上傳的文件 const file = ctx.request.files.file; const fileName = file.path.split( '/' )[file.path.split( '/' ).length-1]; ctx.body = { code:0, data:{ url:`public/${fileName}` }, message: 'success' } }); |
前端
我這里使用的是formData傳遞的方式,前端通過<input type='file'/> 來訪問文件選擇器,通過onChange事件 e.target.files[0] 即可獲取選擇的文件,而后創建FormData 對象將獲取的文件formData.append('file',targetFile)即可
前端代碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
const Upload = () => { const [url, setUrl] = useState<string>( '' ) const handleClickUpload = () => { const fileLoader = document.querySelector( '#btnFile' ) as HTMLInputElement; if (isNil(fileLoader)) { return ; } fileLoader.click(); } const handleUpload = async (e: any) => { //獲取上傳文件 const file = e.target.files[0]; const formData = new FormData() formData.append( 'file' , file); // 上傳文件 const { data } = await uploadSmallFile(formData); console.log(data.url); setUrl(`${baseURL}${data.url}`); } return ( <div> <input type= "file" id= "btnFile" onChange={handleUpload} style={{ display: 'none' }} /> <Button onClick={handleClickUpload}>上傳小文件</Button> <img src={url} /> </div> ) } |
其他可選方法
- input+form 設置form的aciton為后端頁面,enctype="multipart/form-data",type=‘post'
- 使用fileReader讀取文件數據進行上傳 兼容性不是特別好
大文件上傳
文件上傳的時候,可能會因為文件過大,導致請求超時,這時候就可以采取分片的方式,簡單來說就是將文件拆分為一個個小塊,傳給服務器,這些小塊標識了自己屬于哪一個文件的哪一個位置,在所有小塊傳遞完畢后,后端執行merge 將這些文件合并了完整文件,完成整個傳輸過程
前端
- 獲取文件和前面一樣,不再贅述
- 設置默認分片大小,文件切片,每一片名字為 filename.index.ext,遞歸請求直到整個文件發送完請求合并
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
const handleUploadLarge = async (e: any) => { //獲取上傳文件 const file = e.target.files[0]; // 對于文件分片 await uploadEveryChunk(file, 0); } const uploadEveryChunk = ( file: File, index: number, ) => { console.log(index); const chunkSize = 512; // 分片寬度 // [ 文件名, 文件后綴 ] const [fname, fext] = file.name.split( '.' ); // 獲取當前片的起始字節 const start = index * chunkSize; if (start > file.size) { // 當超出文件大小,停止遞歸上傳 return mergeLargeFile(file.name); } const blob = file.slice(start, start + chunkSize); // 為每片進行命名 const blobName = `${fname}.${index}.${fext}`; const blobFile = new File([blob], blobName); const formData = new FormData(); formData.append( 'file' , blobFile); uploadLargeFile(formData).then((res) => { // 遞歸分片上傳 uploadEveryChunk(file, ++index); }); }; |
后端
后端需要提供兩個接口
上傳
將上傳的每一個分塊存儲到對應name 的文件夾,便于之后合并
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
const uploadStencilPreviewOptions = { multipart: true , formidable: { uploadDir: path.resolve(__dirname, '../../temp/' ), // 文件存放地址 keepExtensions: true , maxFieldsSize: 2 * 1024 * 1024, }, }; router.post( '/upload_chunk' , new KoaBody(uploadStencilPreviewOptions), async (ctx) => { try { const file = ctx.request.files.file; // [ name, index, ext ] - 分割文件名 const fileNameArr = file.name.split( '.' ); const UPLOAD_DIR = path.resolve(__dirname, '../../temp' ); // 存放切片的目錄 const chunkDir = `${UPLOAD_DIR}/${fileNameArr[0]}`; if (!fse.existsSync(chunkDir)) { // 沒有目錄就創建目錄 // 創建大文件的臨時目錄 await fse.mkdirs(chunkDir); } // 原文件名.index - 每個分片的具體地址和名字 const dPath = path.join(chunkDir, fileNameArr[1]); // 將分片文件從 temp 中移動到本次上傳大文件的臨時目錄 await fse.move(file.path, dPath, { overwrite: true }); ctx.body = { code: 0, message: '文件上傳成功' , }; } catch (e) { ctx.body = { code: -1, message: `文件上傳失敗:${e.toString()}`, }; } }); |
合并
根據前端傳來合并請求,攜帶的name去臨時緩存大文件分塊的文件夾找到屬于該name的文件夾,根據index順序讀取chunks后,合并文件fse.appendFileSync(path,data) (按順序追加寫即合并),然后刪除臨時存儲的文件夾釋放內存空間
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
router.post( '/merge_chunk' , async (ctx) => { try { const { fileName } = ctx.request.body; const fname = fileName.split( '.' )[0]; const TEMP_DIR = path.resolve(__dirname, '../../temp' ); const static_preview_url = '/public/previews' ; const STORAGE_DIR = path.resolve(__dirname, `../..${static_preview_url}`); const chunkDir = path.join(TEMP_DIR, fname); const chunks = await fse.readdir(chunkDir); chunks .sort((a, b) => a - b) .map((chunkPath) => { // 合并文件 fse.appendFileSync( path.join(STORAGE_DIR, fileName), fse.readFileSync(`${chunkDir}/${chunkPath}`), ); }); // 刪除臨時文件夾 fse.removeSync(chunkDir); // 圖片訪問的url const url = `http: //${ctx.request.header.host}${static_preview_url}/${fileName}`; ctx.body = { code: 0, data: { url }, message: 'success' , }; } catch (e) { ctx.body = { code: -1, message: `合并失敗:${e.toString()}` }; } }); |
斷點續傳
大文件在傳輸過程中,如果刷新頁面或者臨時的失敗導致傳輸失敗,又需要從頭傳輸對于用戶的體驗是很不好的。因此就需要在傳輸失敗的位置,做好標記,下一次直接在這里進行傳輸即可,我采取的是在localStorage讀寫的方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
const handleUploadLarge = async (e: any) => { //獲取上傳文件 const file = e.target.files[0]; const record = JSON.parse(localStorage.getItem( 'uploadRecord' ) as any); if (!isNil(record)) { // 這里為了便于展示,先不考慮碰撞問題, 判斷文件是否是同一個可以使用hash文件的方式 // 對于大文件可以采用hash(一塊文件+文件size)的方式來判斷兩文件是否相同 if (record.name === file.name){ return await uploadEveryChunk(file, record.index); } } // 對于文件分片 await uploadEveryChunk(file, 0); } const uploadEveryChunk = ( file: File, index: number, ) => { const chunkSize = 512; // 分片寬度 // [ 文件名, 文件后綴 ] const [fname, fext] = file.name.split( '.' ); // 獲取當前片的起始字節 const start = index * chunkSize; if (start > file.size) { // 當超出文件大小,停止遞歸上傳 return mergeLargeFile(file.name).then(()=>{ // 合并成功以后刪除記錄 localStorage.removeItem( 'uploadRecord' ) }); } const blob = file.slice(start, start + chunkSize); // 為每片進行命名 const blobName = `${fname}.${index}.${fext}`; const blobFile = new File([blob], blobName); const formData = new FormData(); formData.append( 'file' , blobFile); uploadLargeFile(formData).then((res) => { // 傳輸成功每一塊的返回后記錄位置 localStorage.setItem( 'uploadRecord' ,JSON.stringify({ name:file.name, index:index+1 })) // 遞歸分片上傳 uploadEveryChunk(file, ++index); }); }; |
文件相同判斷
通過計算文件MD5,hash等方式均可,當文件過大時,進行hash可能會花費較大的時間。 可取文件的一塊chunk與文件的大小進行hash,進行局部的采樣比對, 這里展示 通過 crypto-js庫進行計算md5,FileReader讀取文件的代碼
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// 計算md5 看是否已經存在 const sign = tempFile.slice(0, 512); const signFile = new File( [sign, (tempFile.size as unknown) as BlobPart], '' , ); const reader = new FileReader(); reader.onload = function (event) { const binary = event?.target?.result; const md5 = binary && CryptoJs.MD5(binary as string).toString(); const record = localStorage.getItem( 'upLoadMD5' ); if (isNil(md5)) { const file = blobToFile(blob, `${getRandomFileName()}.png`); return uploadPreview(file, 0, md5); } const file = blobToFile(blob, `${md5}.png`); if (isNil(record)) { // 直接從頭傳 記錄這個md5 return uploadPreview(file, 0, md5); } const recordObj = JSON.parse(record); if (recordObj.md5 == md5) { // 從記錄位置開始傳 //斷點續傳 return uploadPreview(file, recordObj.index, md5); } return uploadPreview(file, 0, md5); }; reader.readAsBinaryString(signFile); |
總結
之前一直對于上傳文件沒有過太多的了解,通過畢設的這個功能,對于上傳文件的前后端代碼有了初步的認識,可能這些方法也只是其中的選項并不包括所有,希望未來的學習中能夠不斷的完善。
第一次在掘金寫博客,在參加實習以后,發現自己的知識體量的不足,希望能夠通過堅持寫博客的方式,來梳理自己的知識體系,記錄自己的學習歷程,也希望各位大神在發現問題時不吝賜教,thx
以上就是React+Koa實現文件上傳的示例的詳細內容,更多關于React+Koa實現文件上傳的資料請關注服務器之家其它相關文章!
原文鏈接:https://juejin.cn/post/6947613143141089287