React Hook+Ts+Antd+SpringBoot实现分片上传(前端)
大文件上传在C端还是很少见的, 曾经爆火的网盘应用如今也只剩寥寥几家,大家可能接触较多的就一些视频网站在上传视频的时候会碰到。在做企业内部应用经常会碰到大文件超大文件的上传、下载、存储等问题,我以前在做企业内部网盘和IOT设备接入管理的时候涉及大文件存储和分发, 实现过fastdfs分片上传, 腾讯oss分片上传和minio分片上传、秒传等功能,以前前端部份没有写过,后端是直接用的开源服务提供的api去实现的,本次我将从前端到后端(本地磁盘)重新捋一遍。
如下代码除了sparkmd5没有其他第三方库
前端分片
文件MD5校验已经是主流方案, 咱们是在前端上传的时候就要生成MD5
npm install spark-md5 @types/spark-md5 --save-dev//如下代码都是在一个tsx中// 计算文件的 MD5 值
function calculateMD5(file:any) {return new Promise((resolve) => {const spark = new SparkMD5.ArrayBuffer();const fileReader = new FileReader();const chunkSize = 5 * 1024 * 1024;let currentChunk = 0;fileReader.onload = function (e:any) {spark.append(e.target.result);currentChunk++;if (currentChunk < chunks) {loadNext();} else {const result = spark.end();resolve(result);}};// 加载下一个分片function loadNext() {const start = currentChunk * chunkSize;const end = Math.min(file.size, start + chunkSize);const buffer = file.slice ? file.slice(start, end) : file.webkitSlice(start, end); // 使用 slice 方法fileReader.readAsArrayBuffer(buffer);}const chunks = Math.ceil(file.size / chunkSize); // 文件划分成的分片数量loadNext(); // 开始加载第一个分片});
}// 将文件划分成多个分片
function chunkFile(file:any, chunkSize:any) {const chunks = Math.ceil(file.size / chunkSize); // 文件划分成的分片数量const chunksList = [];let currentChunk = 0;while (currentChunk < chunks) {const start = currentChunk * chunkSize;const end = Math.min(file.size, start + chunkSize);const chunk = file.slice ? file.slice(start, end) : file.webkitSlice(start, end); // 使用 slice 方法chunksList.push(chunk); // 将分片添加到列表中currentChunk++;}return chunksList; // 返回分片列表
}interface fileItem {id:stringname:stringsize:stringstatus:stringtype:stringtime:string
}interface Props{userCode:stringopen: booleanclose: () => void
}function FileUpload({open, close, userCode}: Props) {const [uploading, setUploading] = useState(false); // 是否正在上传文件的状态const [progress, setProgress] = useState(0); // 文件上传进度的状态const chunkRefs:any = useRef([]); // 保存分片引用的引用const md5Ref:any = useRef(""); // 保存 MD5 值的引用const [fileList, setFileList] = useState([] as fileItem[])const fileChunkList = [] as UploadFile[] // antd官方组件组提供const handleFileChange = async ({ file }:any) => {setUploading(true); // 开始文件上传setProgress(1) //计算MD5需要时间, 文件越大时间越长, 假装给一个进度const md5 = await calculateMD5(file); // 计算文件的 MD5 值md5Ref.current = md5; // 保存 MD5 值到引用// 将文件划分成多个分片并保存到引用对象中const chunksList:any = chunkFile(file, 5 * 1024 * 1024);chunkRefs.current = chunksList.map((chunk:any, index:any) => {const formData = new FormData();// 下面的参数根据实际需要自己定义, 注意后端接口接收入参一致即可formData.append("file", chunk);formData.append("userCode", userCode);formData.append("fileName", file.name);formData.append("uuid", uuid); // 随机生成一个UUIDformData.append("totalSize", chunksList.length);formData.append("chunkNumber", index.toString());formData.append("identifier", md5Ref.current); // 添加 MD5 参数return formData;});//结果const arr = [] as fileItem[]const uploadResult = {id: md5Ref.current || '',name: file.name,size: chunkList.length,status: 'uploading',type: '大文件上传',time: new Date().toISOString(),} as fileItem// 成功const success = () => {uploadResult.status = 'done'arr.push(uploadResult)setFileList([...fileList, ...arr])message.success('上传成功')}// 失败const error = () => {uploadResult.status = 'error'arr.push(uploadResult)setFileList([...fileList, ...arr])message.success('上传失败')}// 合并参数const mergeParams = {identifier: md5Ref.current,fileName:file.name,totalChunks: chunksList.length,userCode: userCode,uuid: uuid}// 定义递归函数用于逐个上传分片const uploadChunk = async (index:any) => {if (index >= chunkRefs.current.length) {// 所有分片上传完成await httpPost('/marge', mergeParams).then(res => {if(res && res.code === 200) {success()setUploading(false)return} else {error()return}})return;}try {// 调用上传函数上传当前分片,此处为调用上传的接口await httpPost('/chunk', chunkRefs.current[index]).then(res => {if(res && res.code === 200) {console.log(`分片 ${index + 1} 上传成功`);// 更新进度条的值const newProgress = Math.ceil(((index + 1) / chunkRefs.current.length) * 100);setProgress(newProgress);// 递归调用上传下一个分片await uploadChunk(index + 1);return;}}); } catch (error) {console.error(`分片 ${index + 1} 上传失败`, error);message.error("文件上传失败!");uploadResult.status = 'error'arr.push(uploadResult)setFileList([...fileList, ...arr])setUploading(false); // 文件上传失败,修改上传状态return;}};// 开始递归上传第一个分片await uploadChunk(0);};const handleRemove = () => {// 清空保存的分片引用、MD5 引用和重置进度条chunkRefs.current = [];md5Ref.current = "";setProgress(0);};const handleClose = () => {close()}const getIcon = (value: string) = > {// 逻辑, 根据上传状态返回不同的图标, 图标通过css自定义大小, 最好fontSize加大, 明显一些// balabalareturn '图标'}return (<Modaltitle="Upload"width="50%"open={open}footer={null}onCancel={handleClose}><Uploadname="file"multiple={false}defaultFileList={fileChunkList}listType="pictrue"beforeUpload={() => false}onChange={handleFileChange}onRemove={handleRemove} // 添加自定义的删除操作><Button loading={uploading} icon={<UploadOutlined />}>{uploading ? progress === 100 ? "合并中" : "上传中" : "选择文件"}</Button></Upload>// 显示文件上传进度条{uploading && <Progress percent={progress} status="active" />}// 我通过css隐藏了uplaod组件自带的文件列表, 自定义一个自己的list列表展示{fileList && <Listhearder={<div>上传列表</div>}itemLayout="horizontal"dataSource={fileList}randerItem={(item, index) => (<List.Item><List.Item.Matekey={index}avatar={getIcon(item.status)}title={item.name}description={`文件大小:${item.size} 文件上传状态: ${item.status}....自定义即可`}/></List.Item>)}/>}</Modal>);
}
以上就是前端的核心逻辑部份,有两个filelist是我本地还有两个上传按钮都会往这里push文件一起展示。
前端在获取文件MD5的时候随着文件大小时间成正比,所以也要考虑是否可以单独抽出一个初始化接口过渡一下,或者使用虚假的进度进行隐藏。
根据实际业务,参数自定义,还有一些拓展功能会集成到代码里面,例如上传图片压缩缩略图,上传视频按帧取图做封面活着缩略图,文档转换,大文件MD5检验算法优化等等,还有一个问题就是大文件存储问题,全局看成本最重要,可以结合云存储去交互一些热文件,而且有时候带宽的限制也不得不这么做。
下次我将更新SpringBoot部份的代码,其实网上有很多,大差不差,都是接收分片按序号命名然后合并,可以看看其他人的作品。建议,如果不是其他业务需要直接加载文件进行二次处理,文件的管理还是使用成熟的文件服务器策略比较好。