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) (按顺序追加写即合并),然后删除临时存储的文件夹释放内存空间
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 = `${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读写的方式
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读取文件的代码
// 计算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);
总结