1. 概念
文件分片的核心是利用 Blod.prototype.slice 方法,将文件切分为一个个的切片,借助 http 的可并发性,同时上传多个切片
2. 实现
分片组件是基于 elementUI 的 el-upload 组件上的二次封装
<template> <div> <el-upload class="uploadComponent" ref="uploadFile" :drag="!disabled" v-bind="$attrs" :on-change="handleUpload" :disabled="disabled" > <i class="el-icon-upload" v-show="!disabled"></i> </el-upload> </div> </template>
通过属性透传 v-bind=“$attrs”实现绑定在组件上的属性传递给 el-upload 组件,因此可以在组件上绑定 el-upload 组件已实现的属性
<file-chunks-upload-component :disabled="!isEdit" ref="videoUpload" :file-list="videoFileList" action="" :auto-upload="false" list-type="fileList" accept=".mp4, .avi, .wmv, .rmvb, .rm" validType="video" validTypeTip="上传文件只能是mp4, avi, wmv, rmvb格式" :getFileChunks="handleUpload" :sliceSize="100 * 1024 * 1024" :on-preview="handleVideoPreview" :before-remove="handleVideoRemove" />
组件除了接收 el-upload 组件的属性,还额外接收几个属性:
- disabled:设置组件是否可编辑
- validType:文件校验类型,单个检验类型的话可传入字符串,如’video’、’image’,多个检验类型的话可传入数组,如[‘video’, ‘image’]
- validTypeTip:文件检验不通过时的提示文本
- chunkSize:每个分片大小
- getFileChunks:获取分片信息
- sliceSize:启动文件分片的临界点
在 el-upload 组件上绑定 on-change 属性监听文件变化,拿到文件信息,进行一系列的处理
处理过程:
- 判断文件大小是否大于 sliceSize,是,则继续实现分片处理,否,则返回文件
// 文件大小小于sliceSize时,不进行分片,直接返回文件 if(this.sliceSize && file.size <= this.sliceSize){ this.getFileChunks && this.getFileChunks({file}) return }
- 检验文件类型
通过文件头信息方式检验文件类型,封装成一个工具方法
/*** * 通过文件头信息方式检验文件类型 * https://blog.csdn.net/tjcwt2011/article/details/120333846?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3.no_search_link */ const fileType = Object.assign(Object.create(null), { 'isMP4': str => ['00 00 00 14', '00 00 00 18', '00 00 00 1c', '00 00 00 20'].some(s => str.indexOf(s) === 0), 'isAVI': str => str.indexOf('52 49 46 46') === 0, 'isWMV': str => str.indexOf('30 26 b2 75') === 0, 'isRMVB': str => str.indexOf('2e 52 4d 46') === 0, 'isJPG': str => str.indexOf('ff d8 ff') === 0, 'isPNG': str => str.indexOf('89 50 4e 47') === 0, 'isGIF': str => str.indexOf('47 49 46 38') === 0 }) const validFileType = Object.assign(Object.create(null), { 'video': str => fileType['isMP4'](str) || fileType['isAVI'](str) || fileType['isWMV'](str) || fileType['isRMVB'](str), 'image': str => fileType['isJPG'](str) || fileType['isPNG'](str) || fileType['isGIF'](str) }) const bufferToString = async buffer => { let str = [...new Uint8Array(buffer)].map(b => b.toString(16).toLowerCase().padStart(2, '0')).join(' ') return str } const readBuffer = (file, start = 0, end = 4) => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { resolve(reader.result) } reader.onerror = reject reader.readAsArrayBuffer(file.slice(start, end)) }) } const validator = async (type, file) => { const buffer = await readBuffer(file) const str = await bufferToString(buffer) switch(Object.prototype.toString.call(type)){ case '[object String]': return validFileType[type](str) case '[object Array]': return [...type].some(t => validFileType[t](str)) } } export default validator
注:各种文件类型的文件头信息可从以下链接查询 文件信息头对照表
- 生成切片
利用 blob.prototype.slice 将大文件切分成一个一个的小片段
createFileChunk(file){ let chunks = [] let cur = 0 if(file.size > this.chunkSize){ while(cur < file.size){ chunks.push({ file: file.slice(cur, cur + this.chunkSize) }) cur += this.chunkSize } }else{ chunks.push({ file: file }) } return chunks },
- 生成 hash
为了实现断点续传、秒传的效果,需要前端生成一个唯一标识提供给后端,由于唯一标识必须保持不变,所以正确的做法是根据文件内容生成 hash。这里需要用到一个库 spark-md5,它可以根据文件内容计算出文件的 hash 值。
由于计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,所有这里使用 web-worker 在 worker 线程计算 hash。由于实例化 web-worker 时,函数参数 URL
为指定的脚本且不能跨域,所以我们单独创建一个 hash.js 文件放在 public 目录下,然后通过 importScripts 函数用于导入 spark-md5
hash.js
// 引入spark-md5 self.importScripts('spark-md5.min.js') self.onmessage = e=>{ // 接受主线程传递的数据 let { chunks } = e.data const spark = new self.SparkMD5.ArrayBuffer() let count = 0 const loadNext = index=>{ const reader = new FileReader() reader.readAsArrayBuffer(chunks[index].file) reader.onload = e=>{ count ++ spark.append(e.target.result) if(count==chunks.length){ self.postMessage({ hash:spark.end() }) }else{ loadNext(count) } } } loadNext(0) }
分片越多,分片越大,计算 hash 所耗费的时间就越久,为优化计算速度,在损失一定精度的情况下,采用抽样的方式计算 hash
这里分了两种情况
1.文件大小小于 50M 时,对所有的分片计算 hash
2.文件大小大于 50M 时,以偶数位的方式抽取分片,并提取每个分片 10M 的内容去计算 hash
计算 hash 方法
async calculateHashWorker(chunks, size){ var _this = this; // 文件大小超过50M时,使用抽样方式生成hash const MAX_FILE_SIZE = 50 * 1024 * 1024 const SAMPLE_SIZE = 10 * 1024 * 1024 if(size >= MAX_FILE_SIZE){ chunks = chunks.map((item, index) => { if(index % 2 == 0){ return { file: item.file.slice(0, SAMPLE_SIZE)} } }).filter(item => item) } return new Promise(resolve => { _this.worker = new Worker('/hash.js') _this.worker.postMessage({ chunks }) _this.worker.onmessage = e => { const { hash } = e.data if (hash) { resolve(hash) } } }) },
- 返回文件切片信息
通过在组件上绑定 getFileChunks 属性获取到切片集合、hash 值以及文件信息
async handleUpload(params){ if(!params.raw) return let file = params.raw // 文件大小小于sliceSize时,不进行分片,直接返回文件 if(this.sliceSize && file.size <= this.sliceSize){ this.getFileChunks && this.getFileChunks({file}) return } // 校验文件类型 if(this.validType && !await validator(this.validType, file)){ this.$message({ type: 'warning', message: this.validTypeTip, duration: 3000, }) this.clearUploadFiles() return } const loading = this.$loading({ lock: true, text: '生成切片中,请耐心等候' }) let chunks = this.createFileChunk(file) let hash = await this.calculateHashWorker(chunks, file.size) this.getFileChunks && this.getFileChunks({chunks, hash, file}) loading.close() },
分片上传组件完整代码
<template> <div> <el-upload class="uploadComponent" ref="uploadFile" :drag="!disabled" v-bind="$attrs" :on-change="handleUpload" :disabled="disabled" > <i class="el-icon-upload" v-show="!disabled"></i> </el-upload> </div> </template> <script> import validator from '@/utils/fileTypeValidate' export default { name: 'FileChunksUploadComponent', props: { disabled: { // 是否可编辑 type: Boolean, default: false }, validType: { // 文件校验类型 type: String | Array, default(){ return '' } }, validTypeTip: { // 文件检验提示 type: String, default: '上传文件格式不正确!' }, chunkSize: { // 分片大小 type: Number, default: 50 * 1024 * 1024 }, getFileChunks: { // 获取分片信息方法 type: Function, default: null }, sliceSize: { // 文件实现分片的临界大小 type: Number, default: 0 } }, methods: { async handleUpload(params){ if(!params.raw) return let file = params.raw // 文件大小小于sliceSize时,不进行分片,直接返回文件 if(this.sliceSize && file.size <= this.sliceSize){ this.getFileChunks && this.getFileChunks({file}) return } // 校验文件类型 if(this.validType && !await validator(this.validType, file)){ this.$message({ type: 'warning', message: this.validTypeTip, duration: 3000, }) this.clearUploadFiles() return } const loading = this.$loading({ lock: true, text: '生成切片中,请耐心等候' }) let chunks = this.createFileChunk(file) let hash = await this.calculateHashWorker(chunks, file.size) this.getFileChunks && this.getFileChunks({chunks, hash, file}) loading.close() }, createFileChunk(file){ let chunks = [] let cur = 0 if(file.size > this.chunkSize){ while(cur < file.size){ chunks.push({ file: file.slice(cur, cur + this.chunkSize) }) cur += this.chunkSize } }else{ chunks.push({ file: file }) } return chunks }, async calculateHashWorker(chunks, size){ var _this = this; // 文件大小超过50M时,使用抽样方式生成hash const MAX_FILE_SIZE = 50 * 1024 * 1024 const SAMPLE_SIZE = 10 * 1024 * 1024 if(size >= MAX_FILE_SIZE){ chunks = chunks.map((item, index) => { if(index % 2 == 0){ return { file: item.file.slice(0, SAMPLE_SIZE)} } }).filter(item => item) } return new Promise(resolve => { _this.worker = new Worker('/hash.js') _this.worker.postMessage({ chunks }) _this.worker.onmessage = e => { const { hash } = e.data if (hash) { resolve(hash) } } }) }, clearUploadFiles(){ this.$refs.uploadFile.clearFiles() } } } </script>
3. 实战篇
为了实现大文件分片上传功能,需后台提供三个接口,包括分片检查接口、分片上传接口、分片合并接口。
- 分片检查接口:根据组件生成的 hash 值去检测是否有分片已上传过,文件是否已上传,实现中断续传,秒传的效果,避免文件重复上传。
- 分片上传接口:上传未上传过的分片
- 分片合并接口:文件全部分片上传完成,告知后台将分片合并成文件
分片合并过程是需要耗费一定的时间的,对于小文件来说,分片上传反而会增加上传时间,因此这里做了控制,在组件上绑定了 sliceSize 属性,控制文件小于 100M 时,组件只返回文件,直接调用文件上传接口。
3.1 分片上传流程
- 调用分片检查接口,判断文件是否已上传或部分分片已上传
2. 过滤掉已上传的分片,调用分片上传接口
3. 所有分片上传完毕后,调用分片合并接口合并文件
完整代码
<file-chunks-upload-component :disabled="!isEdit" ref="videoUpload" :file-list="videoFileList" action="" :auto-upload="false" list-type="fileList" accept=".mp4, .avi, .wmv, .rmvb, .rm" validType="video" validTypeTip="上传文件只能是mp4, avi, wmv, rmvb格式" :getFileChunks="handleUpload" :sliceSize="100 * 1024 * 1024" :on-preview="handleVideoPreview" :before-remove="handleVideoRemove" />
async handleUpload({chunks, hash, file}){ this.loading = true this.loadingText = '上传中,请耐心等候' try{ let res = null if(chunks && hash){ res = await this.sliceChunksUpload(chunks, hash, file) }else{ res = await this.videoFileUpload(file) } if(res){ this.videoFileList.push({name: res, url: res}) this.$messageInfo('上传成功') } }catch(err){ this.$messageWarn('上传失败') } this.$refs.videoUpload.clearUploadFiles() this.loading = false this.loadingText = '' }, // 分片视频文件上传 sliceChunksUpload(chunks, hash, file){ return new Promise(async (resolve, reject) => { chunks = chunks.map((item, index) => { return { file: item.file, name: `${index}_${hash}`, index, hash } }) try{ let {data} = await sewingCraftApi.checkChunkUpload({taskId: hash}) if(data && data.completeUpload){ resolve(data.uploadUrl) return } if(data.map){ chunks = chunks.filter(({file, name}) => { return !data.map[name] || data.map[name] != file.size }) } if(chunks.length){ let uploadRes = await httpUtil.multiRequest(chunks, uploadRequest) let flag = uploadRes.every(res => res.data) if(!flag){ reject('上传失败!') return } } let extName = file.name.substring(file.name.lastIndexOf('.') + 1) sewingCraftApi.mergeChunk({taskId: hash, extName}).then(res => { resolve(res.data) }) }catch(err){ reject(err) } function uploadRequest({hash, index, name, file}){ let formData = new FormData() formData.append('taskId', hash) formData.append('chunkNumber', index) formData.append('identifier', name) formData.append('file', file) return sewingCraftApi.uploadChunkUpload(formData) } }) }
引用
本文转自 稀土掘金,如有侵权,请联系删除。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于