背景
当我们在做文件的导入功能的时候,如果导入的文件过大,可能会导所需要的时间够长,且失败后需要重新上传,我们需要前后端结合的方式解决这个问题
思路
我们需要做几件事情如下:
- 对文件做切片,即将一个请求拆分成多个请求,每个请求的时间就会缩短,且如果某个请求失败,只需要重新发送这一次请求即可,无需从头开始
- 通知服务器合并切片,在上传完切片后,前端通知服务器做合并切片操作
- 控制多个请求的并发量,防止多个请求同时发送,造成浏览器内存溢出,导致页面卡死
- 做断点续传,当多个请求中有请求发送失败,例如出现网络故障、页面关闭等,我们得对失败的请求做处理,让它们重复发送
实现
前端
示例代码仓库
仓库地址:
https://gitee.com/huangjinlin2022/resolve_upload_large_file
步骤1-切片,合并切片
在JavaScript中,文件FIle对象是Blob对象的子类,Blob对象包含一个重要的方法slice通过这个方法,我们就可以对二进制文件进行拆分,具体代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script> // 请求基准地址 axios.defaults.baseURL = 'http://localhost:3000' // 选中的文件 var file = null // 选择文件 document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] } // 开始上传 document.getElementById('uploadBtn').onclick = async function(){ if (!file) return // 创建切片 // let size = 1024 * 1024 * 10 //10MB 切片大小 let size = 1024 * 50 //50KB 切片大小 let fileChunks = [] let index = 0 //切片序号 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }) } // 上传切片 const uploadList = fileChunks.map((item, index) => { let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) return axios({ method: 'post', url: '/upload', data: formData }) }) await Promise.all(uploadList) // 合并切片 await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') } </script> </html>
步骤2-并发控制
结合Promise.race和异步函数实现,多个请求同时并发的数量,防止浏览器内存溢出,具体代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script> // 请求基准地址 axios.defaults.baseURL = 'http://localhost:3000' // 选中的文件 var file = null // 选择文件 document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] } // 开始上传 document.getElementById('uploadBtn').onclick = async function(){ if (!file) return // 创建切片 // let size = 1024 * 1024 * 10; //10MB 切片大小 let size = 1024 * 50 //50KB 切片大小 let fileChunks = [] let index = 0 //切片序号 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }); } // 控制并发 let pool = []//并发池 let max = 3 //最大并发量 for(let i=0;i<fileChunks.length;i++){ let item = fileChunks[i] let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) // 上传切片 let task = axios({ method: 'post', url: '/upload', data: formData }) task.then((data)=>{ //请求结束后将该Promise任务从并发池中移除 let index = pool.findIndex(t=> t===task) pool.splice(index) }) pool.push(task) if(pool.length === max){ //每当并发池跑完一个任务,就再塞入一个任务 await Promise.race(pool) } } //所有任务完成,合并切片 await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') } </script> </html>
步骤3-断点续传
在单个请求失败后,触发catch的方法的时候,讲当前请求放到失败列表中,在本轮请求完成后,重复对失败请求做处理,具体代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=s, initial-scale=1.0"> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/axios/0.24.0/axios.min.js"></script> </head> <body> <input type="file" id="fileInput"> <button id="uploadBtn">上传</button> </body> <script> // 请求基准地址 axios.defaults.baseURL = 'http://localhost:3000' // 选中的文件 var file = null // 选择文件 document.getElementById('fileInput').onchange = function({target: {files}}){ file = files[0] } // 开始上传 document.getElementById('uploadBtn').onclick = function(){ if (!file) return; // 创建切片 // let size = 1024 * 1024 * 10; //10MB 切片大小 let size = 1024 * 50; //50KB 切片大小 let fileChunks = []; let index = 0 //切片序号 for(let cur = 0; cur < file.size; cur += size){ fileChunks.push({ hash: index++, chunk: file.slice(cur, cur + size) }) } // 控制并发和断点续传 const uploadFileChunks = async function(list){ if(list.length === 0){ //所有任务完成,合并切片 await axios({ method: 'get', url: '/merge', params: { filename: file.name } }); console.log('上传完成') return } let pool = []//并发池 let max = 3 //最大并发量 let finish = 0//完成的数量 let failList = []//失败的列表 for(let i=0;i<list.length;i++){ let item = list[i] let formData = new FormData() formData.append('filename', file.name) formData.append('hash', item.hash) formData.append('chunk', item.chunk) // 上传切片 let task = axios({ method: 'post', url: '/upload', data: formData }) task.then((data)=>{ //请求结束后将该Promise任务从并发池中移除 let index = pool.findIndex(t=> t===task) pool.splice(index) }).catch(()=>{ failList.push(item) }).finally(()=>{ finish++ //所有请求都请求完成 if(finish===list.length){ uploadFileChunks(failList) } }) pool.push(task) if(pool.length === max){ //每当并发池跑完一个任务,就再塞入一个任务 await Promise.race(pool) } } } uploadFileChunks(fileChunks) } </script> </html>
后端
步骤1.安装依赖
npm i express@4.17.2 npm i multiparty@4.2.2
步骤2.接口实现
const express = require('express') const multiparty = require('multiparty') const fs = require('fs') const path = require('path') const { Buffer } = require('buffer') // 上传文件最终路径 const STATIC_FILES = path.join(__dirname, './static/files') // 上传文件临时路径 const STATIC_TEMPORARY = path.join(__dirname, './static/temporary') const server = express() // 静态文件托管 server.use(express.static(path.join(__dirname, './dist'))) // 切片上传的接口 server.post('/upload', (req, res) => { const form = new multiparty.Form(); form.parse(req, function(err, fields, files) { let filename = fields.filename[0] let hash = fields.hash[0] let chunk = files.chunk[0] let dir = `${STATIC_TEMPORARY}/${filename}` // console.log(filename, hash, chunk) try { if (!fs.existsSync(dir)) fs.mkdirSync(dir) const buffer = fs.readFileSync(chunk.path) const ws = fs.createWriteStream(`${dir}/${hash}`) ws.write(buffer) ws.close() res.send(`${filename}-${hash} 切片上传成功`) } catch (error) { console.error(error) res.status(500).send(`${filename}-${hash} 切片上传失败`) } }) }) //合并切片接口 server.get('/merge', async (req, res) => { const { filename } = req.query try { let len = 0 const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/${filename}`).map(hash => { const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/${filename}/${hash}`) len += buffer.length return buffer }); //合并文件 const buffer = Buffer.concat(bufferList, len); const ws = fs.createWriteStream(`${STATIC_FILES}/${filename}`) ws.write(buffer); ws.close(); res.send(`切片合并完成`); } catch (error) { console.error(error); } }) server.listen(3000, _ => { console.log('http://localhost:3000/') })
其他实现
如果使用腾讯云或阿里云文件上传的服务,它们提供了npm库,例如腾讯云的cos-js-sdk-v5,它自身提供的切片相关的配置。
转载作品,原作者:https://juejin.cn/post/7053658552472174605,文章来源: