React+Koa實(shí)現(xiàn)文件上傳的示例
背景
最近在寫(xiě)畢設(shè)的時(shí)候,涉及到了一些文件上傳的功能,其中包括了普通文件上傳,大文件上傳,斷點(diǎn)續(xù)傳等等
服務(wù)端依賴
- koa(node.js框架)
- koa-router(Koa路由)
- koa-body(Koa body 解析中間件,可以用于解析post請(qǐng)求內(nèi)容)
- koa-static-cache(Koa 靜態(tài)資源中間件,用于處理靜態(tài)資源請(qǐng)求)
- koa-bodyparser(解析 request.body 的內(nèi)容)
后端配置跨域
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();
}
});
后端配置靜態(tài)資源訪問(wèn) 使用 koa-static-cache
// 靜態(tài)資源處理
app.use(
KoaStaticCache('./pulbic', {
prefix: '/public',
dynamic: true,
gzip: true,
}),
);
后端配置requst body parse 使用 koa-bodyparser
const bodyParser = require('koa-bodyparser');
app.use(bodyParser());
前端依賴
- React
- Antd
- axios
正常文件上傳
后端
后端只需要使用 koa-body 配置好options,作為中間件,傳入router.post('url',middleware,callback)即可
后端代碼
// 上傳配置
const uploadOptions = {
// 支持文件格式
multipart: true,
formidable: {
// 上傳目錄 這邊直接上傳到public文件夾,方便訪問(wèn) 文件夾后面要記得加/
uploadDir: path.join(__dirname, '../../pulbic/'),
// 保留文件擴(kuò)展名
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傳遞的方式,前端通過(guò)<input type='file'/> 來(lái)訪問(wèn)文件選擇器,通過(guò)onChange事件 e.target.files[0] 即可獲取選擇的文件,而后創(chuàng)建FormData 對(duì)象將獲取的文件formData.append('file',targetFile)即可
前端代碼
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 設(shè)置form的aciton為后端頁(yè)面,enctype="multipart/form-data",type=‘post'
- 使用fileReader讀取文件數(shù)據(jù)進(jìn)行上傳 兼容性不是特別好
大文件上傳
文件上傳的時(shí)候,可能會(huì)因?yàn)槲募^(guò)大,導(dǎo)致請(qǐng)求超時(shí),這時(shí)候就可以采取分片的方式,簡(jiǎn)單來(lái)說(shuō)就是將文件拆分為一個(gè)個(gè)小塊,傳給服務(wù)器,這些小塊標(biāo)識(shí)了自己屬于哪一個(gè)文件的哪一個(gè)位置,在所有小塊傳遞完畢后,后端執(zhí)行merge 將這些文件合并了完整文件,完成整個(gè)傳輸過(guò)程
前端
- 獲取文件和前面一樣,不再贅述
- 設(shè)置默認(rèn)分片大小,文件切片,每一片名字為 filename.index.ext,遞歸請(qǐng)求直到整個(gè)文件發(fā)送完請(qǐng)求合并
const handleUploadLarge = async (e: any) => {
//獲取上傳文件
const file = e.target.files[0];
// 對(duì)于文件分片
await uploadEveryChunk(file, 0);
}
const uploadEveryChunk = (
file: File,
index: number,
) => {
console.log(index);
const chunkSize = 512; // 分片寬度
// [ 文件名, 文件后綴 ]
const [fname, fext] = file.name.split('.');
// 獲取當(dāng)前片的起始字節(jié)
const start = index * chunkSize;
if (start > file.size) {
// 當(dāng)超出文件大小,停止遞歸上傳
return mergeLargeFile(file.name);
}
const blob = file.slice(start, start + chunkSize);
// 為每片進(jìn)行命名
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);
});
};
后端
后端需要提供兩個(gè)接口
上傳
將上傳的每一個(gè)分塊存儲(chǔ)到對(duì)應(yīng)name 的文件夾,便于之后合并
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)) {
// 沒(méi)有目錄就創(chuàng)建目錄
// 創(chuàng)建大文件的臨時(shí)目錄
await fse.mkdirs(chunkDir);
}
// 原文件名.index - 每個(gè)分片的具體地址和名字
const dPath = path.join(chunkDir, fileNameArr[1]);
// 將分片文件從 temp 中移動(dòng)到本次上傳大文件的臨時(shí)目錄
await fse.move(file.path, dPath, { overwrite: true });
ctx.body = {
code: 0,
message: '文件上傳成功',
};
} catch (e) {
ctx.body = {
code: -1,
message: `文件上傳失敗:${e.toString()}`,
};
}
});
合并
根據(jù)前端傳來(lái)合并請(qǐng)求,攜帶的name去臨時(shí)緩存大文件分塊的文件夾找到屬于該name的文件夾,根據(jù)index順序讀取chunks后,合并文件fse.appendFileSync(path,data) (按順序追加寫(xiě)即合并),然后刪除臨時(shí)存儲(chǔ)的文件夾釋放內(nèi)存空間
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}`),
);
});
// 刪除臨時(shí)文件夾
fse.removeSync(chunkDir);
// 圖片訪問(wèn)的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()}` };
}
});
斷點(diǎn)續(xù)傳
大文件在傳輸過(guò)程中,如果刷新頁(yè)面或者臨時(shí)的失敗導(dǎo)致傳輸失敗,又需要從頭傳輸對(duì)于用戶的體驗(yàn)是很不好的。因此就需要在傳輸失敗的位置,做好標(biāo)記,下一次直接在這里進(jìn)行傳輸即可,我采取的是在localStorage讀寫(xiě)的方式
const handleUploadLarge = async (e: any) => {
//獲取上傳文件
const file = e.target.files[0];
const record = JSON.parse(localStorage.getItem('uploadRecord') as any);
if (!isNil(record)) {
// 這里為了便于展示,先不考慮碰撞問(wèn)題, 判斷文件是否是同一個(gè)可以使用hash文件的方式
// 對(duì)于大文件可以采用hash(一塊文件+文件size)的方式來(lái)判斷兩文件是否相同
if(record.name === file.name){
return await uploadEveryChunk(file, record.index);
}
}
// 對(duì)于文件分片
await uploadEveryChunk(file, 0);
}
const uploadEveryChunk = (
file: File,
index: number,
) => {
const chunkSize = 512; // 分片寬度
// [ 文件名, 文件后綴 ]
const [fname, fext] = file.name.split('.');
// 獲取當(dāng)前片的起始字節(jié)
const start = index * chunkSize;
if (start > file.size) {
// 當(dāng)超出文件大小,停止遞歸上傳
return mergeLargeFile(file.name).then(()=>{
// 合并成功以后刪除記錄
localStorage.removeItem('uploadRecord')
});
}
const blob = file.slice(start, start + chunkSize);
// 為每片進(jìn)行命名
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);
});
};
文件相同判斷
通過(guò)計(jì)算文件MD5,hash等方式均可,當(dāng)文件過(guò)大時(shí),進(jìn)行hash可能會(huì)花費(fèi)較大的時(shí)間。 可取文件的一塊chunk與文件的大小進(jìn)行hash,進(jìn)行局部的采樣比對(duì), 這里展示 通過(guò) crypto-js庫(kù)進(jìn)行計(jì)算md5,F(xiàn)ileReader讀取文件的代碼
// 計(jì)算md5 看是否已經(jīng)存在
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)) {
// 直接從頭傳 記錄這個(gè)md5
return uploadPreview(file, 0, md5);
}
const recordObj = JSON.parse(record);
if (recordObj.md5 == md5) {
// 從記錄位置開(kāi)始傳
//斷點(diǎn)續(xù)傳
return uploadPreview(file, recordObj.index, md5);
}
return uploadPreview(file, 0, md5);
};
reader.readAsBinaryString(signFile);
總結(jié)
之前一直對(duì)于上傳文件沒(méi)有過(guò)太多的了解,通過(guò)畢設(shè)的這個(gè)功能,對(duì)于上傳文件的前后端代碼有了初步的認(rèn)識(shí),可能這些方法也只是其中的選項(xiàng)并不包括所有,希望未來(lái)的學(xué)習(xí)中能夠不斷的完善。
第一次在掘金寫(xiě)博客,在參加實(shí)習(xí)以后,發(fā)現(xiàn)自己的知識(shí)體量的不足,希望能夠通過(guò)堅(jiān)持寫(xiě)博客的方式,來(lái)梳理自己的知識(shí)體系,記錄自己的學(xué)習(xí)歷程,也希望各位大神在發(fā)現(xiàn)問(wèn)題時(shí)不吝賜教,thx
以上就是React+Koa實(shí)現(xiàn)文件上傳的示例的詳細(xì)內(nèi)容,更多關(guān)于React+Koa實(shí)現(xiàn)文件上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
React實(shí)現(xiàn)二級(jí)聯(lián)動(dòng)效果(樓梯效果)
這篇文章主要為大家詳細(xì)介紹了React實(shí)現(xiàn)二級(jí)聯(lián)動(dòng)效果,樓梯效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-09-09
react?實(shí)現(xiàn)表格列表拖拽排序的示例
本文主要介紹了react?實(shí)現(xiàn)表格列表拖拽排序,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2023-02-02
解決React報(bào)錯(cuò)Rendered more hooks than during
這篇文章主要為大家介紹了React報(bào)錯(cuò)Rendered more hooks than during the previous render解決方法詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12

