# fed-e-task-files **Repository Path**: frontend_site/fed-e-task-files ## Basic Information - **Project Name**: fed-e-task-files - **Description**: 文件分片上传及断点续传原理探索 - **Primary Language**: JavaScript - **License**: ISC - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2020-06-10 - **Last Updated**: 2023-09-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: files ## README ## 1.文件唯一标识 ​ 文件分片上传需要一个识别文件的唯一标识,而md5是非常合适的。spark-md5.js可以根据文件内容计算出文件的 hash 值,是前端在文件上传前在本地计算md5的很可靠的方案。 **使用方法:** 1. 用SparkMD5.hashBinary( ) 直接将整个文件的二进制码传入直接返回文件的md5、这种方法对于**小文件**会比较有优势——简单并且速度快; 2. 利用JavaScript中File对象的slice( )方法(File.prototype.slice( ))将文件分片后逐个传spark.appendBinary( )方法来计算、最后通过spark.end( )方法输出结果,很明显,这种方法对于**大文件**会非常有利——不容易出错,并且能够提供计算的进度信息; ```javascript const dataSolve = async (count = 0) => { const length = WrapperFileChunk.length; const reader = new FileReader(); const spark = new self.SparkMD5.ArrayBuffer(); const dataAccept = WrapperFileChunk[count]; if (dataAccept) { reader.readAsArrayBuffer(dataAccept.file); count++; reader.onload = (e) => { spark.append(e.target.result); if (count === length) { self.postMessage({ percentage: "100.00", hash: spark.end() }); } else { self.postMessage({ percentage: (count * 100 / length).toFixed(2) }); } dataSolve(count) } } } ``` ​ 另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以使用 web-worker 在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互。 ## 2. 文件上传与读取 ```javascript //Usage:FormData,使用说明-示例 //文件上传时,参数需做特殊处理,即使用FormData包装参数,进行上传操作,使用FormData实例对象(payload)作为上传参数对象 //可:const formData = new FormData(someFormElement); const payload = new FormData(); payload.append("file", chunk); payload.append("hash", `${count}_${hash_accept}`); payload.append("file_name", name); payload.append("size", size); payload.append("hash_accept", hash_accept); //链接:https://developer.mozilla.org/zh-CN/docs/Web/API/FormData/Using_FormData_Objects //Usage:FileReader,使用说明-示例 const fileReader = new FileReader(); fileInput.onchange = function(e){ //获取原声 File 对象 let file = event.target.files[0]; //以二进制读取文件对象 fileReader.readAsArrayBuffer(file); //fileReader.readAsDataURL(file); // 以data:URL 格式的字符串以表示读取文件的内容 //fileReader.readAsText(file); //以字符串形式表示读取到的文件内容 } //读取操作完成后 fileReader.onload = function (e) { console.log(e) } //链接:https://developer.mozilla.org/zh-CN/docs/Web/API/FileReader //提交表单和上传文件 //XMLHttpRequest 的实例有两种方式提交表单: //-使用 AJAX //-使用 FormData API //链接:https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest ``` ## 3. 文件秒传 ​ 文件秒传需要依赖上一步生成的 hash,即在`上传前`,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可。 ## 4. 上传显示进度 ```javascript //代码示例说明:axios axios.put(this.uploadUrl, this.files[0], { headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress: progressEvent => { const { loaded, total } = progressEvent; this.percentage = this.progressEventSolve(current, loaded, total, fileSize); } }) // 在文件切片时,计算出每个文件切片占整个文件的比例,在根据每个文件切片的上传进度计算出整个文件的上传进度; progressEventSolve(current, loaded, total, fileSize) { const dataSaved = current + (total * (loaded / total)); const progress = (dataSaved * 100 / fileSize).toFixed(2); return parseFloat(progress); }, ``` ## 5. koa注意事项 ```javascript //支持文件上传,配置中间件 const koaBody = require('koa-body'); app.use(koaBody({ jsonLimit: '50mb', formLimit: '50mb', textLimit: '50mb', multipart: true, // 支持文件上传 // encoding: 'gzip', // 启用这个会报错 formidable: { // uploadDir: path.join(__dirname, './public'), // 设置文件上传目录 keepExtensions: true, // 保持文件的后缀 maxFieldsSize: 1024 * 1024 * 1024, // 文件上传大小 // onFileBegin: (name, file) => { } // 文件上传前的设置 } })); ``` ## 6. hash计算 https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback https://juejin.im/post/6844904055819468808#heading-8 https://blog.csdn.net/jtracydy/article/details/52366461 1.全量hash计算 ```javascript //全量hash计算,读取文件内容计算 hash 是非常耗费时间的:1.时间切片计算hash值;2.Worker计算hash值 // 时间切片计算--考虑requestIdleCallback兼容性 async calculateHashIdle(chunks) { return new Promise(resolve => { const spark = new SparkMD5.ArrayBuffer(); let count = 0; // 根据文件内容追加计算 const appendToSpark = async file => { return new Promise(resolve => { const reader = new FileReader(); reader.readAsArrayBuffer(file); reader.onload = e => { spark.append(e.target.result); resolve(); }; }); }; const workLoop = async deadline => { // 有任务,并且当前帧还没结束 while (count < chunks.length && deadline.timeRemaining() > 1) { await appendToSpark(chunks[count].file); count++; // 没有了 计算完毕 if (count < chunks.length) { // 计算中 this.hashProgress = Number( ((100 * count) / chunks.length).toFixed(2) ); // console.log(this.hashProgress) } else { // 计算完毕 this.hashProgress = 100; resolve(spark.end()); } } window.requestIdleCallback(workLoop); }; window.requestIdleCallback(workLoop); }); } ``` 2.抽样hash计算 ```javascript /** * 抽样hash计算方式 * 根据样本长度、抽样标本动态计算出hash值 * 只计算部分文件切片,减少计算时间 * 适当增加准确性: * 可考虑将文件大小作为影响因素,作为数组最后一个元素,由此计算hash值 * 代码示例: * result.push({file:new Blob(["7832324"], {type: 'text/plain'})}); * @param {Array} listChunk * @param {Number} length */ const createNewPayload = (listChunk, length) => { //只计算部分文件切片,减少计算时间 let rank = length <= 256 ? 2 : 4; let result = [listChunk[0]], marked = rank; while (marked < length) { result.push(listChunk[marked]); marked = marked * rank; } result.push(listChunk[length-1]); return result; } ``` ## 7. 慢启动 **慢启动算法的思路**:主机开发发送数据报时,如果立即将大量的数据注入到网络中,可能会出现网络的拥塞。慢启动算法就是在主机刚开始发送数据报的时候先探测一下网络的状况,如果网络状况良好,发送方每发送一次文段都能正确的接受确认报文段。那么就从小到大的增加拥塞窗口的大小,即增加发送窗口的大小。 **动态改变数据传输量,其实就是根据当前网络情况,动态调整切片的大小;** ```javascript // 比如我们理想是30秒传递一个 // 初始大小定为1M,如果上传花了10秒,那下一个区块大小变成3M // 如果上传花了60秒,那下一个区块大小变成500KB 以此类推 async fnSlowStart() { if (this.payload) { const { file, name, size } = this.payload; const fileChunkSlice = createFileChunk(file); console.time("hash_accept"); const hash_accept = await hashSendFnCreate(fileChunkSlice); this.payload.hash_name = `${hash_accept}_${name}`; const fileSize = file.size; let current = 0, count = 0, offset = 2 * 1024 * 1024; while (current < fileSize) { const endSend = current + offset; // 计算最后一次传输数据 const chunk = file.slice(current, endSend > fileSize ? fileSize : endSend); const payload = new FormData(); payload.append("file", chunk); payload.append("hash", `${count}_${hash_accept}`); payload.append("file_name", name); payload.append("size", size); payload.append("hash_accept", hash_accept); let start = new Date().getTime(); await axios.post("/file", payload, { headers: { "Content-Type": "application/x-www-form-urlencoded", }, }); const now = new Date().getTime(); const time = ((now - start) / 1000).toFixed(4); console.log(time); let rate = this.Standard / time; // 速率有最大和最小 可以考虑更平滑的过滤 比如1/tan if (rate <= 0.25) rate = 0.25; if (rate >= 4) rate = 4; // 新的切片大小等比变化 console.log(`切片${count}大小是${this.showFormat(offset)},耗时${time}秒,是30秒的${rate}倍`); console.log(`修正大小为${this.showFormat(offset*rate)}`); offset = parseInt(offset * rate); current += offset; count++; } } else { this.$message.error("请先选择上传文件"); } }, ``` ## 8.并发重试+报错 ```javascript limitSendSameTime(requestSlice = [], limit = 4) { let marked = 0, isClosed = true; const fnStart = () => { while (marked < limit && requestSlice.length) { const payload = requestSlice.shift(); marked++; if (!payload.isCrossed || payload.isCrossed <= 2) { axios.post("/file", payload, { headers: { "Content-Type": "application/x-www-form-urlencoded", }, }).then((res) => { marked--; if (requestSlice.length) { fnStart(); } else { if (isClosed) { isClosed = false; this.$message.success("该文件已上传成功"); } } }).catch(error => { marked--; payload.isCrossed = (payload.isCrossed || 0) + 1; requestSlice.unshift(payload); fnStart(); }) } else { requestSlice.push(payload); console.log("终止该请求"); if (requestSlice.every(item => item.isCrossed)) { console.log(requestSlice); if (isClosed) { isClosed = false; this.$message.error("本次请求终止,且部分数据未上传"); } } } } } fnStart(); } ```