大文件的切片上传
1.大致流程
- 将大文件切片
- 分别上传切片至后端
- 后端接收切片并存储在文件夹
- 合并切片成大文件,并删除切片和文件夹

1. 实现切片
文件File的原型上提供了一个 slice 方法,通过这个方法可以对文件进行切割。以每5m分一个切片。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <input type="file" @change="handleFileChange"/>
data() { return { chunkList: [], file: null }; },
function handleFileChange (e) { this.file = e. target.files[0] this.chunkList = createChunk(this.file) console.log(this.chunkList) }
function createChunk (file, size = 5 * 1024 * 1024) { let chunks = [] let cur = 0 while(cur < file.size) { chunks.push({file:file.slice(cur,cur+size)}) cur += size } return chunks }
|
我上传了个9.6m左右大小的文件,每片以5m分割,看log输出可以看到文件被切分成了两片,这样就实现了切片的效果

2. 上传切片
设置接口接收文件类型和进度条
onUploadProgress是axios的进度条事件
1 2 3 4 5 6 7 8 9 10 11
| export function uploadFile(param, cb) { return axios({ url: `/upload/file`, method: 'POST', data: param, headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress:cb }) }
|
首先我们需要组装一下chunkList,将所需要的参数填充进去
切片名称拼接索引, 是为了后续续传的告诉前端已经上传的切片。
1 2 3 4 5 6 7 8 9 10 11 12 13
| function fillParams (files, chunkList) { const suffix = files.name.lastIndexOf('.') const fileName = files.name.slice(0,suffix) let chunkFile = chunkList.map( ({ file }, index) => ({ file, size: file.size, percent: 0, chunkName: `${fileName}-${index}`, fileName: fileName, index })) return chunkFile }
|
将所需的参数通过 FormData 传递给后端
其中 e.loaded 表示当前上传的数据大小,e.total 表示整个要上传的数据大小
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| function async handleFileChange () { const _self = this this.file = e.target.files[0] this.chunkList = fillParams(createChunk(this.file))
let reqList = this.chunkList.map((item,index) => { const formData = new FormData() formData.append("file", item.file) formData.append("fileName", item.fileName) formData.append("chunkName", item.chunkName) return { formData, index } }).map(({formData,index}) => { return uploadFile(formData,(e) => { _self.chunkList[index].percent = parseInt(String((e.loaded / e.total) * 100)) }) }) await Promise.all(reqList) }
|
3. 后端处理切片
将切片存储到文件夹中
后端的话我是用node的koa来模拟实现一下
上传的话通过koa-multer中间件来实现。具体用法可以参考链接,这里就不贴出来啦。
koa-multer中间件不能通过动态参数存储在动态路径,所以需要我们手动的去移动切片到文件夹中。
fs.rename修改文件名称,更改文件的存放路径。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| router.post('/file', upload.single('file'), async(ctx, next) => { const folderName = ctx.req.body.fileName const fileName = ctx.req.file.filename const chunkName = `${ctx.req.body.chunkName}.${ctx.req.file.originalname}` const folderPath = path.join(__dirname,`../../public/file/${folderName}`) if (!fs.existsSync(folderPath)) { fs.mkdir(folderPath, err => { if (err) throw err; }); } await moveFile(fileName,folderName,chunkName) ctx.success(fileName,'上传切片成功') })
const moveFile = async function (fileName,movePath,newFileName) { var chunkPath = path.join(__dirname, `../public/file/${fileName}`); var destPath = path.join(__dirname, `../public/file/${movePath}`, newFileName); fs.rename(chunkPath, destPath, function (err) { if (err) throw err; }); }
|
每个切片上传的size = 当前切片进度 * 当前切片size
总进度 totalPercent = 每个切片上传的size ➗ 文件总size
1 2 3 4 5 6 7
| computed: { totalPercent () { let percent = this.chunkList.map(({size,percent}) => size * percent) .reduce((prev, curr) => prev + curr) return parseInt((percent/this.file.size).toFixed(2)) } }
|
4. 合并切片
切片上传完成之后,发送一个merge请求,让后端合并切片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| const pipeStream = (path, writeStream) => { return new Promise(resolve => { const readStream = fs.createReadStream(path); readStream.on("end", () => { fs.unlinkSync(path); resolve(); }); readStream.pipe(writeStream); }); }
const mergeFileChunk = async (filePath, chunkName, size = 5 * 1024 * 1024) => { const chunkDir = path.join(__dirname,`../public/file/${chunkName}`) let chunkPaths = fs.readdirSync(`public/file/${chunkName}`) const eq = (str) => parseInt(str.slice(str.lastIndexOf('-')+1,str.lastIndexOf('.'))) chunkPaths.sort((a, b) => eq(a) - eq(b)) const arr = chunkPaths.map((chunk, index) => { return pipeStream( path.resolve(chunkDir, chunk), fs.createWriteStream(filePath, { start: index * size, end: (index + 1) * size }) ) }) await Promise.all(arr) };
router.post('/mergeChunkFile', async(ctx, next) => { const {fileName, chunkName} = ctx.request.body const mergePath = path.join(__dirname,`../../public/file/mergeFile/${fileName}`) await mergeFileChunk(mergePath,chunkName) fs.rmdirSync(path.join(__dirname,`../../public/file/${chunkName}`)) ctx.success(fileName,'合并文件成功') })
|
上传成功后,可以看到合并后的音乐已经存到了mergeFile文件夹了,并且切片文件夹已经删除。

大文件秒传
秒传的实现原理其实就是后端去校验文件是否已经上传,是的返回上传成功给前端。
秒传关键在于怎么校验文件是否已经上传?用文件名来校验肯定是不靠谱的,真实场景的话是通过文件的hash值来校验
那么前端怎么生成文件的hash值呢?
本篇是通过一个spark-md5插件来生成的,具体用法可参考链接。
后端的话通过node的crypto模块生成文件的hash
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| router.post('/verifyFile', async(ctx, next) => { const {hash} = ctx.request.body const shouldUpload = hasFile(hash) ctx.success({shouldUpload},'校验成功') })
function hasFile (hash) { filePaths = fs.readdirSync(`public/file/mergeFile`) const len = filePaths.length let flag = true for(let i = 0;i<len;i++) { const hash = createHash(filePaths[i]) if (fileHash === hash){ flag = false break } } return flag }
function createHash (fileName) { const buffer = fs.readFileSync(path.join(__dirname,`../public/file/mergeFile/${fileName}`)); const fsHash = crypto.createHash('md5'); fsHash.update(buffer); const md5 = fsHash.digest('hex'); return md5 }
|
大文件续传
续传的原理就是当一个大文件切分成了10个切片,上传了5个切片的时候被暂停了,点击续传的时候后端需要告诉前端已经上传的切片索引,前端再根据后端返回的索引并过滤掉他们从新请求未上传的切片
思考:前端要怎么暂停请求?
CancelToken是axios提供取消ajax请求的,我们可以通过这个来实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| <button @click="pauseUpload">暂停</button>
export function uploadFile(param, cb,cancelToken) { return axios({ url: `/upload/file`, method: 'POST', data: param, headers: { 'Content-Type': 'multipart/form-data' }, onUploadProgress:cb, cancelToken }) }
const CancelToken = axios.CancelToken; let source = CancelToken.source();
map(({formData,index}) => { return uploadFile(formData,(e) => { _self.chunkList[index].percent = parseInt(String((e.loaded / e.total) * 100)) },source.token) })
const pauseUpload = () => { source.cancel("中断上传!"); source = CancelToken.source(); }
|
后端实现校验需要 文件夹的名称,切片总数字段
1 2 3 4 5 6 7 8 9
| const filePath = path.join(__dirname,`../../public/file/${chunkName}`) if (fs.existsSync(filePath)) { chunkFiles = fs.readdirSync(`public/file/${chunkName}`) if (chunkFiles.length < chunkLen) { const eq = (str) => parseInt(str.slice(str.lastIndexOf('-')+1,str.lastIndexOf('.'))) const uploadChunk = chunkFiles.map(item => eq(item)) } }
|
前端根据后端返回的索引,进行过滤
1
| chunkList.filter(item => !uploadChunk.includes(item.index))
|
来看看具体效果

大部分参考掘金作者三心的文章实现。