大文件上传(React + axios)

您所在的位置:网站首页 resolve(true) 大文件上传(React + axios)

大文件上传(React + axios)

2023-04-14 19:32| 来源: 网络整理| 查看: 265

我们开发中肯定涉及到不少的文件上传的需求,一般来说通过FormData然后POST直传就行了,但是文件如果过大的话,如果不加任何处理,这个请求就会一直处于PENDING状态(最后肯定是超时的),体验很不好。所以本文就梳理一下前端大文件上传的方案

文章内会贴前端的代码片段,然后服务端的代码片段在文章最后统一贴

不然不连贯我摘得难受,你们看的也心累

流程

梳理一下最基本的大文件上传:

首先,前端对大文件进行分片,规避超时问题前端对文件生成hash值,用于标识文件的唯一性发送 check 请求,根据文件名和hash值判断文件是否存在或者切片是否已经上传了部分组装分片包发送 upload 请求 上传分片包服务端根据hash值生成临时目录存放切片切片上传完成之后,前端发送 merge 请求,提示后端合成文件后端合成文件上传完成

下面一步步来实现对应的函数

分片

前端将原本一个大的文件进行分片,将一个http请求分解成多个http并发请求,规避超时的问题。此处就会涉及到两个问题:

如何保证后端正确合并切片?

如何通知前端合并完成(即上传完成)?

针对问题1,主要就是要解决后端什么时候开始合并以及如何怎么正确按序合并?

一种思路是上传请求中携带总片数,当后端收到对应数量的文件后自动进行合并,但这有个问题就是如何通知前端已经合并完成

所以用另一种思路,就是将上传和合并分成两块,upload完成之后,前端发送一个merge接口,后端收到merge接口之后开始合并,合并完成之后响应,前端收到该响应视为整个上传流程完成

这样同时也就解决了问题2

因为File对象是继承Blob对象的,所以我们可以用 Blob.prototype.slice 对文件进行切片

App.tsx

// 切片大小,先定为10M const SLICE_SIZE = 10 * 1024 * 1024; const createFileSlices: (file: File) => Blob[] = (file: File) => { if (!file) return []; const slices = []; let start = 0; while (start Promise = (slices) => { return new Promise((resolve, reject) => { try { worker.current = new Worker('./hash.js') worker.current.postMessage({ slices }) worker.current.onmessage = e => { const { hash, progress } = e.data setHashProgress(progress); if (hash) { resolve(hash) } } } catch (e) { reject(e) } }) };

hash.js

importScripts("spark-md5.min.js"); // 生成文件 hash onmessage = e => { const { slices } = e.data // 创建 const spark = new SparkMD5.ArrayBuffer() let progress = 0 let count = 0 const loadNext = index => { const reader = new FileReader() reader.readAsArrayBuffer(slices[index]) reader.onload = event => { count++ // 利用 spark-md5 对文件内容进行计算得到 hash 值 spark.append(event.target.result) if (count === slices.length) { postMessage({ progress: 100, hash: spark.end() }) // 关闭 worker self.close() } else { progress += 100 / slices.length self.postMessage({ progress: parseInt(progress) }) // 递归计算下一个切片 loadNext(count) } } } loadNext(0) // 开启第一个切片的 hash 计算 } wasm进行计算hash值

用worker计算只不过是防止页面阻塞,时间消耗还是在的,大文件分片之后的文件还是很大的话计算也很费时间

所以这里扩展一下(了解即可,生产环境还是先观望吧)就是我们可以引入wasm来提高计算速度

搜了一下有个 md5-wasm 的库(js我已经下载下来放到仓库里了):

// wasm计算文件hash值 const calculateFileHashWasm: (file: File) => Promise = (file) => { return new Promise((resolve, reject) => { try { const reader = new FileReader() reader.readAsArrayBuffer(file) reader.onload = (event: any) => { const buffer = event.target.result; md5WASM(buffer).then((res: string) => { resolve(res); }).catch(() => { reject(''); }); } } catch (e) { reject(e) } }) };

因为源码限制了内存上限是255M,所以我用245M的文件进行了测试,测试下来的对比还是挺明显的:

spark-md5:

md5-wasm:

速度快了将近一倍,如果能引入的话,这里的上传时间还能进一步优化。

但这个工作量就大了,得用别的语言什么rust、c++先集成一下md5,然后编成wasm,然后js再使用wasm,还是挺费劲的,所以只是拓宽一下思路,大家了解一波即可

判断文件或者切片是否存在

这里主要是服务端的工作,前端发送 /check 接口,将文件名和上一步获取的hash值发给后端校验

App.tsx

interface ICheckParams { fileName: string; hash: string; } interface ICheckRes { fileExist: boolean; uploadedChunks: string []; } const check = (params: ICheckParams) => axios.post('http://xxxxxx/check', params);

返回两个信息:

fileExist:当前文件是否存在uploadedChunks:已经上传的分片包的名称数组

根据这两个信息,我们就可以决定传还是不传,是重新传还是可以续传

组装分片包

经过上述判断之后,当文件不存在的话我们就需要组装分片包准备上传了

interface IChunk { // 切片对象 chunk: Blob; // hash值,用来标识文件的唯一性 hash: string; // 文件名 fileName: string; // 请求进度 progress: number; // 下标,标记哪些分片包已上传完成 index: number; // abort上传请求 cancel: () => void; } const createFileChunks: (file: File, slices: Blob[]) => IChunk[] = (file, slices) => { if (!slices?.length) return []; return slices.map((slice, index) => ({ index, chunk: slice, hash: hash.current + '-' + index, fileName: file.name, progress: 0, cancel: () => { } })); };

先不管progress、cancel这些花里胡哨的参数,上传我们实际上用到的只有前几个参数,其他的参数是用来扩展比如进度条、断点续传使用的,先无视

这样可以得到分片包数组

上传分片包

上传就很简单了,和单文件上传一样,记得过滤一下已经上传的切片:

const upload = (param: FormData) => axios.post('http://xxxxx/upload', param); const uploadFileChunks: (chunks: IChunk[], upLoadedChunks: string[]) => Promise | undefined = (chunks, upLoadedChunks) => { if (!chunks?.length) return; const requests = chunks.filter(({ hash }) => !upLoadedChunks.includes(hash)).map((item) => { const { chunk, hash, fileName, index } = item; const data = new FormData(); data.append('chunk', chunk) data.append('hash', hash) data.append('fileName', fileName) return upload(data); }) Promise.all(requests).then(res => { merge({ fileName: chunks[0].fileName, hash: chunks[0].hash, size: SLICE_SIZE }) }).catch(err => { console.log(err) }) }; 上传完成之后发送合并请求

这里需要传一下每个分片的大小,因为后端合并创建读写管道流的话需要标记 start 和 end:

interface IMergeParams { fileName: string; hash: string; size: number; } const merge = (param: IMergeParams) => axios.post('http://xxxxx/merge', param); 后端

这里就直接贴下后端代码了,因为真实情况下后端肯定不是我们负责的,另外后端的代码主要负责的就是读写文件:

const http = require('http') const path = require('path') const fs = require('fs') const qs = require('qs') // 用于处理body const multiparty = require('multiparty') const server = http.createServer() // 服务端保存上传文件的目录 const FILES_DIR = path.resolve(__dirname, './upload') // 处理body信息 // 处理json和test都比较简单,只需要监听 data end事件,然后拼接即可 const resolvePost = (req) => ( new Promise((resolve) => { let chunk = [] req.on('data', buff => { chunk.push(buff) }) req.on('end', () => { let chunks = Buffer.concat(chunk); resolve(JSON.parse(chunks.toString())); }) }) ); // 接收分片 const uploadFile = (req, res) => { const multipart = new multiparty.Form() multipart.parse(req, async(err, fields, files) => { if (err) { res.end(JSON.stringify({ code: 500, mes: 'Upload Failed' })) throw err } const [chunk] = files.chunk; const [hash] = fields.hash; const [fileName] = fields.fileName; // 临时文件目录 const tempDir = hash.split('-')[0]; const chunkDir = path.resolve(FILES_DIR, tempDir) if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir) } // chunk.path指的是经过multiparty处理之后的文件的存放处,将那个地址移动到我们需要的指定地址 fs.copyFileSync(chunk.path, `${chunkDir}\\${hash}`) // 删除临时文件 fs.rmSync(chunk.path) res.end(JSON.stringify({ code: 0, mes: '上传完成' })) }) }; // 合并文件 const mergeFile = async (req, res) => { const { hash, fileName, size } = await resolvePost(req); const fileDir = path.resolve(FILES_DIR, hash.split('-')[0]); const fileNameList = fs.readdirSync(fileDir); // 按序读取文件 const sortableFileNameList = fileNameList.sort((a, b) => a.split("-")[1] - b.split("-")[1]) const fns = sortableFileNameList.map((tempFileName, index) => { const readPath = path.resolve(fileDir, tempFileName) const readStream = fs.createReadStream(readPath) // 因为是创建多个管道流,所以需要给每个写入流定位开始位置和结束位置 const writeStream = fs.createWriteStream(path.resolve(FILES_DIR, fileName), { start: size * index, end: (index + 1) * size }) readStream.pipe(writeStream) return new Promise((resolve, reject) => { readStream.on('close', () => { console.log('读取的文件路径为:' + readStream.path) resolve(true) }) }) }); await Promise.all(fns).catch(e => { console.log(e) }) fs.rmSync(fileDir, { recursive: true, force: true }) res.end(JSON.stringify({ code: 0, mes: '合并完成' })) }; // 检查文件是否已上传 const checkCache = async (req, res) => { const { hash, fileName } = await resolvePost(req); // 先判断有没有文件 const fileDir = path.resolve(FILES_DIR, fileName); if (fs.existsSync(fileDir)) { res.end(JSON.stringify({ code: 0, fileExist: true })); return; } // 判断切片包文件夹是否存在 const chunkDir = path.resolve(FILES_DIR, hash.split('-')[0]); let uploadedChunks = []; if (fs.existsSync(chunkDir)) { const fileDir = path.resolve(FILES_DIR, hash.split('-')[0]); uploadedChunks = fs.readdirSync(fileDir); } res.end(JSON.stringify({ code: 0, fileExist: false, uploadedChunks })); }; server.on('request', async (req, res) => { res.setHeader('Content-Type', "application/json;charset=utf-8") // 设置跨域 res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-control-Allow-Headers', '*') res.setHeader('Access-control-Allow-Methods', '*') // 兼容post if(req.method === 'OPTIONS') { res.status = 200 res.end() return } const { url } = req; switch(url) { case '/upload': uploadFile(req, res); break; case '/merge': mergeFile(req, res); break; case '/check': checkCache(req, res); break; } }) server.listen(3001, () => console.log('running at 3001'))

上面的代码已经能够基本还原一个大文件上传的流程了

断点续传

续传实际上就是判断服务端已存在的分片包,前端拿到之后将已经有的分片包从所有分片包中移除出去,这一块我们实际上在上面的流程中已经实现了

现在来讲讲断点,断点无非就是两种情况,退出浏览器被动断开或者点击暂停手动断开,退出浏览器,xhr请求就会abort所以我们不需要管。

所以手动暂停的时候,我们就需要手动调用一下每个upload的abort了,因为我用的ajax库是axios,在axios里是利用cancelToken 和 cancel来abort请求

我们调整一下uploadFileChunks函数 upload函数

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; const upload = (param: FormData, confg: AxiosRequestConfig) => axios.post('http://xxxx/upload', param, confg); const uploadFileChunks: (chunks: IChunk[], upLoadedChunks: string[]) => Promise | undefined = (chunks, upLoadedChunks) => { if (!chunks?.length) return; const requests = chunks.filter(({ hash }) => !upLoadedChunks.includes(hash)).map((item) => { const { chunk, hash, fileName, index } = item; const data = new FormData(); data.append('chunk', chunk) data.append('hash', hash) data.append('fileName', fileName) const cancelToken = createCancelAction(item); return upload(data, { cancelToken }); }) return Promise.all(requests) }; // 生成个cancelToken,并给每个chunk添加cancel函数 const createCancelAction = (chunk: IChunk) => { const { cancel, token }= axios.CancelToken.source(); chunk.cancel = cancel; return token; };

然后写两个按钮绑定下暂停上传和恢复上传:

// 暂停上传 const handlePauseUpload = () => { // axios的cancel在调用abort前会判断请求是否存在,所以针对所有的请求直接调用cancel即可 chunkList.forEach(chunk => chunk.cancel()) }; // 恢复上传 const handleResumeUpload = async () => { await uploadFileChunks(chunkList, uploadedChunk); merge({ fileName: file?.name || '', hash: hash.current, size: SLICE_SIZE }) };

好了大文件上传的实现梳理完毕,打完收工

下面是github地址:

https://github.com/GogoWwz/tt-uploader/tree/main

环境很简单,就是create-react-app脚手架 + antd,应该能很快跑起来,

文章只是写了函数的实现,详细的代码repo即可

其他思考关于hash计算的方式,主要就是阻塞问题,看了下网上的方案是引入worker,自己后面又思考了一下还可以利用requestIdleCallback,利用空闲时间计算关于最大并发数,浏览器http请求的最大并发数一般为6,所以真实使用的话需要进行最大并发队列限制总结大文件上传的思路就是分片,将一个http请求转为多个http并发请求断点续传思路其实就是利用xhr对象的abort断开请求,然后利用服务端缓存的切片重新请求了解了MD5的一些知识,后续如果需要实现判断文件有没有改动的功能可以利用该知识点实现了解了Node中body的text和json格式的数据的提取方式,监听req的data和end hooks来拼接buffer一个搞笑的issue:剧情是这样的,之前一直使用bodyParser解析post的body,这次直接用发现解析之后的res.body一直是个空对象,然后看了下issue好像确实不支持,作者开了个discussion还专门讨论了一下:https://github.com/expressjs/body-parser/issues/88我感觉作者主要是觉得multipart兼容进库里有点麻烦(个人猜测),反正目前还是不支持然后往下翻翻到了一个issue三个感叹号,很好奇就点进去看了:https://github.com/expressjs/body-parser/issues/64使用的这个老哥觉得不支持multipart格式的content-type,碰到的时候好歹给我个报错啊,没报错让我理解了半天。然后作者无语了说文档上清清楚楚写了不支持formdata,然后你使用的时候也并没有引入任何和formdata有关的东西,他不明白为什么要给你报错,觉得挺逗的顺带记录下吧


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3