背景
最近在写毕设的时候,涉及到了一些文件上传的功能,其中包括了普通文件上传,大文件上传,断点续传等等
服务端依赖
- 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