最近在思考一个问题:我们大部分前端开发者都是在做业务功能需求,积累了很多的项目经验,但如何将普通的项目功能做成亮点,有深度有价值,是值得我们一直不断思考和探究的!
比如文件上传,这个功能在普遍的业务场景都会用到。我们就来聊聊上传文件的几种实现方式,看看如何将普通功能做成项目亮点~~
以下代码案例client端是采用vue、elementui,server端是采用eggjs来实现的
一、普通版
FormData实现上传文件
html:
<input type="file" name="file" @change="uploadFile"/>
JS:
function uploadFile() {
const form = new FormData();
form.append('filename', this.file.name);
form.append('file', this.file);
axios({
url : "/upload",
type : "post",
data : formData,
}).then(data=> {
console.log(data)
}).catch(err => {
console.log(err)
})
}
二、优化交互体验版
2.1 上传进度条
elmentui进度条:
<el-progress :stroke-width="20" :percentage="uploadProgress"></el-progress>
js:
axios.post('/uploadfile', form, {
onUploadProgress: progress => {
this.uploadProgress = Number(((progress.loaded/progress.total)*100).toFixed(2))
}
})
2.2 文件格式校验(文件头信息截取)
文件格式校验,大家很容易想到根据文件后缀名进行判断。这一方式比较简单,但碰到有人恶意修改文件后缀,非法格式的文件可以通过校验上传到服务器,存在安全问题。
因而可以选择截取文件头信息的方式进行校验,用js的slice方法截取文件头信息,转换成十六进制字符进行相应格式判断。
下面代码是校验图片格式(png、jpg、gif):
// png:判断前8个字节
async isPng(file) {
const ret = await this.blobToString(file.slice(0,8));
const isPng = (ret == '89 50 4E 47 0D 0A 1A 0A');
return isPng;
},
// jpg:判断文件头2个字节、文件结尾2个字节
async isJpg(file) {
const len = file.size;
const start = await this.blobToString(file.slice(0,2));
const tail = await this.blobToString(file.slice(-2));
const isJpg = (start == 'FF D8') && (tail == 'FF D9');
return isJpg;
},
// gif:判断前6个字节
async isGif(file){
// GIF89a 和 GIF87a(2种gif图片规范)
// 47 49 46 38 39(37) 61
const ret = await this.blobToString(file.slice(0,6));
console.log(ret)
const isGif = (ret == '47 49 46 38 39 61') || (ret == '47 49 46 38 37 61');
return isGif;
},
// 读取文件信息转换成十六进制
async blobToString(blob){
return new Promise(resolve=> {
const reader = new FileReader();
reader.onload = function() {
const ret = reader.result.split('')
.map(v=> v.charCodeAt())
.map(v=>v.toString(16).toUpperCase())
.join('')
resolve(ret)
}
reader.readAsBinaryString(blob);
})
}
三、亮点版(大文件断点续传)
3.1 计算文件hash
方式一:Web Worker计算hash
采用Web Worker,创造多线程环境,将计算md5值的任务分配给worker线程,计算完成再把结果返回给主线程作为hash值。
// webWorker多线程计算hash
async calculateHashWorker() {
return new Promise(resolve => {
this.worker = new Worker('/hash.js');
this.worker.postMessage({chunks: this.chunks})
this.worker.onmessage = e => {
const {progress, hash} = e.data;
this.hashProgess = Number(progress.toFixed(2))
if(hash) {
resolve(hash)
}
}
})
}
hash.js:
// 引入spark-md5
self.importScripts('spark-md5.min.js')
self.onmessage = e => {
// 接收主线程传递的数据
const {chunks} = e.data;
const spark = new self.SparkMD5.ArrayBuffer()
let progress = 0;
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({
progress: 100,
hash: spark.end()
})
}else{
progress += 100/chunks.length
self.postMessage({
progress
})
loadNext(count)
}
}
}
loadNext(0)
}
方式二:时间切片计算hash
采用requestIdleCallback方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作。requestIdleCallback执行方法时,会传递deadline参数,能够知道当前帧的剩余时间,具体代码如下:
// 时间切片计算hash
async calculateHashIdle() {
const chunks = this.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 => {
// timeRemaining获取当前帧的剩余时间
while(count<chunks.length && deadline.timeRemaining()>1) {
// 有空闲时间,且有任务
await appendToSpark(chunks[count].file);
count++;
if(count<chunks.length) {
// 计算中
this.hashProgess = Number(
((100*count)/chunks.length).toFixed(2)
)
}else {
this.hashProgess = 100;
resolve(spark.end())
}
}
window.requestIdleCallback(workLoop)
}
// 浏览器一旦空闲,就会调用workLoop
window.requestIdleCallback(workLoop)
})
}
方式三:抽样计算hash
计算文件md5值的作用,无非就是为了判定文件是否存在,我们可以考虑设计一个抽样的hash,牺牲一些命中率的同时,提升效率,设计思路如下:
- 文件切成2M的切片
- 第一个和最后一个切片全部内容,其他切片的取 首中尾三个地方各2个字节
- 合并后的内容,计算md5,称之为影分身Hash
- 这个hash的结果,就是文件存在,有小概率误判,但是如果不存在,是100%准的的 ,和布隆过滤器的思路有些相似, 可以考虑两个hash配合使用
- 自己电脑上试了下1.5G的文件,全量大概要20秒,抽样大概1秒还是很不错的, 可以先用来判断文件是不是不存在
// 抽样计算hash
async calculateHashSample(){
// 1个G的文件,抽样后5M以内
return new Promise(resolve=>{
const spark = new sparkMD5.ArrayBuffer()
const reader = new FileReader()
const file = this.file
const size = file.size
const offset = 2*1024*1024
// 第一个2M,最后一个区块数据全要
let chunks = [file.slice(0,offset)]
let cur = offset
while(cur<size){
if(cur+offset>=size){
// 最后一个区快
chunks.push(file.slice(cur, cur+offset))
}else{
// 中间的区块
const mid = cur+offset/2
const end = cur+offset
chunks.push(file.slice(cur, cur+2))
chunks.push(file.slice(mid, mid+2))
chunks.push(file.slice(end-2, end))
}
cur+=offset
}
// 中间的,取前中后各2各字节
reader.readAsArrayBuffer(new Blob(chunks))
reader.onload = e=>{
spark.append(e.target.result)
this.hashProgress = 100
resolve(spark.end())
}
})
},
3.2 文件切片上传
将切割的文件chunk转换成formData数据,发起异步请求,上传到后端,具体代码如下:
// 切片上传
async uploadChunks() {
const requests = this.chunks.map((chunk, index)=>{
const form = new FormData();
form.append('hash', chunk.hash)
form.append('name', chunk.name)
form.append('chunk', chunk.chunk)
return {form,index: chunck.index};
}).map(({form, index})=> axios.post('/uploadfile', form, {
onUploadProgress: progress => {
this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
}
}))
await Promise.all(requests)
},
3.3 断点续传/秒传
上传文件前,先询问下后端文件是否存在,如果存在直接提示秒传成功。
client端:
const {data:{uploaded, uploadedList}} = await axios.post('/checkfile', {
hash: this.hash,
ext: this.file.name.substring(this.file.name.lastIndexOf('.')+1)
})
if(uploaded) {
// 秒传
return this.$message('秒传成功')
}
server端:
const fse = require('fs-extra')
async checkfile() {
const { ctx } = this
const { ext, hash } = ctx.request.body
const filePath = path.resolve(this.config.UPLOAD_DIR, `${hash}.${ext}`)
let uploaded = false
let uploadedList = []
if (fse.existsSync(filePath)) {
// 文件存在
uploaded = true
} else {
uploadedList = await this.getUploadedList(path.resolve(this.config.UPLOAD_DIR, hash))
}
this.success({
uploaded,
uploadedList,
})
}
// 过滤.文件(如.DS_Strore)
async getUploadedList(dirPath) {
return fse.existsSync(dirPath)
? (await fse.readdir(dirPath)).filter(name => name[0] !== '.')
: []
}
上传文件前,询问后端文件是否上传过,如果没有,是否存在切片uploadedList。断点续传实质,就是在上传切片前使用filter过滤,具体代码如下:
async uploadChunks(uploadedList=[]) {
const requests = this.chunks
.filter(chunk=> uploadedList.indexOf(chunk.name) == -1)
.map((chunk, index)=>{
const form = new FormData();
form.append('hash', chunk.hash)
form.append('name', chunk.name)
form.append('chunk', chunk.chunk)
return form;
}).map((form, index)=> axios.post('/uploadfile', form, {
onUploadProgress: progress => {
this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
}
}))
await Promise.all(requests)
}
3.4 异步请求控制并发数量
上传切片,如果切片过多,同时发起的异步请求就会过多。同一时间申请tcp连接过多的话,浏览器也会造成卡顿,因而需要控制异步请求并发数。
实现思路,可以把请求放一个队列,比如并发数是4,就先同时发起4个请求,然后有请求结束了,再发起下一个请求即可,思路清楚,具体代码如下:
async sendRequest(chunks, limit=4) {
return new Promise((resolve, reject)=> {
const len = chunks.length;
let count = 0;
const start = async () => {
const task = chunks.shift()
if(task) {
const {form, index} = task
await axios.post('/uploadfile', form, {
onUploadProgress: progress => {
this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
}
})
if(count == len-1) {
// 最后一任务
resolve()
}else{
count++
start()
}
}
}
while(limit>0) {
start()
limit-=1
}
})
}
3.5 并发报错重试
上传切片可能报错(比如网速比较差的时候),在上传切片请求中可以添加报错重试的逻辑。实现思路如下:
- 请求出错.catch 把任务重新放在队列中
- 出错后进度条值progress设置为-1
- 请求任务定义error字段,记录报错重试的次数,当超过3次,就立即停止上传请求。
server端先模拟报错:
if(Math.random()>0.3){
return this.ctx.status = 500
}
client端:
// 上传可能报错
// 报错之后,进度条变红,然后开始重试
// 一个切片重试失败3次,整体全部终止
async sendRequest(chunks, limit=4) {
return new Promise((resolve, reject)=> {
const len = chunks.length;
let count = 0;
let isStop = false;
const start = async () => {
if(isStop) {
return
}
const task = chunks.shift()
if(task) {
const {form, index} = task
try {
await axios.post('/uploadfile', form, {
onUploadProgress: progress => {
this.chunks[index].progress = Number(((progress.loaded/progress.total)*100).toFixed(2))
}
})
if(count == len-1) {
// 最后一任务
resolve()
}else{
count++
start()
}
} catch (e) {
this.chunks[index].progress = -1
if(task.error<3) {
task.error++
chunks.unshift(task)
start()
}else{
// 报错3次终止任务
isStop = true;
reject()
}
}
}
}
while(limit>0) {
start()
limit-=1
}
})
}
3.6 文件碎片清理
如果用户上传文件到一半就离开,这些切片文件就不存在意义,可以考虑定期清理。比如server端使用node-schedule来管理定时任务,每天扫一次目标target目录,如果文件修改时间是一个月以前的就清理删除。
小结
- 上传小文件简单,上传大文件复杂;单机简单,分布式很难
- 任何看似简单的功能,如果量级提升,难度就会提升 之所以分享文件上传的案例(从小文件到大文件断点续传,复杂度是逐渐加深),主要是用来梳理记录自己对上传文件问题的思考,其次是想把探究问题逐步加深的精神传递给大家~~