简单版
<template>
<div id="app">
<input type="file" name="file" @change="handleFileChange" />
<button @click="uploadFile">上传文件</button>
</div>
</template>
<script>
import axios from 'axios';
export default {
data() {
return {
file: null,
};
},
methods: {
uploadFile() {
const form = new FormData();
form.append('name', 'file');
form.append('file', this.file);
axios.post('/uploadFile', form);
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
this.file = file;
},
},
};
</script>
拖拽+进度条版
<template>
<div id="app">
<div ref="drag" id="drag">
<input type="file" name="file" @change="handleFileChange" />
</div>
<button @click="uploadFile">上传文件</button>
<!-- network 改成fast 3G 观察效果-->
<el-progress :text-inside="true" :stroke-width="26" :percentage="uploadProgress"></el-progress>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'App',
data() {
return {
file: null,
uploadProgress: 0,
};
},
mounted() {
this.bindEvent();
},
methods: {
bindEvent() {
const drag = this.$refs.drag;
drag.addEventListener('dragover', e => {
drag.style.borderColor = 'red';
e.preventDefault();
});
drag.addEventListener('dragleave', e => {
drag.style.borderColor = '#ccc';
e.preventDefault();
});
drag.addEventListener('drop', e => {
console.log(e.dataTransfer.files);
const fileList = e.dataTransfer.files;
drag.style.borderColor = '#ccc';
this.file = fileList[0];
e.preventDefault();
});
},
uploadFile() {
const form = new FormData();
form.append('name', 'file');
form.append('file', this.file);
axios.post('/uploadFile', form, {
onUploadProgress: progress => {
console.log(progress);
this.uploadProgress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
this.file = file;
},
},
};
</script>
<style lang="less">
#drag {
height: 100px;
border: 1px dashed #ccc;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
</style>
判断文件类型
安装hexdump for VSCode 插件可以方便查看图片头信息
代码越来越多,从这里开始省略重复代码,只提供跟标题相关代码,最后会有完整版
blobToString(blob) {
console.log('blob', blob);
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = function () {
console.log('reader.result', reader.result)
const ret = reader.result
.split('')
.map(v => v.charCodeAt())
.map(v => v.toString(16).toUpperCase())
.map(v => v.padStart(2, '0'))
.join(' ');
resolve(ret);
};
reader.readAsBinaryString(blob);
});
},
async isGif(file) {
// 前面6个十六进制'47 49 46 38 39 61''47 49 46 38 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 isPng(file) {
const ret = await this.blobToString(file.slice(0, 8));
console.log(ret);
const isPng = ret === '89 50 4E 47 0D 0A 1A 0A';
return isPng;
},
async isJpg(file) {
const len = file.size;
const start = await this.blobToString(file.slice(0, 2));
const tail = await this.blobToString(file.slice(-2, len));
console.log(start, tail);
const isjpg = start === 'FF D8' && tail === 'FF D9';
return isjpg;
},
async isImage(file) {
return (await this.isGif(file)) || (await this.isPng(file)) || (await this.isJpg(file));
},
async uploadFile() {
if (!(await this.isImage(this.file))) {
console.log('文件格式不对');
return;
} else {
console.log('文件格式正确')
}
const form = new FormData();
form.append('name', 'file');
form.append('file', this.file);
axios.post('/uploadFile', form, {
onUploadProgress: progress => {
console.log(progress);
this.uploadProgress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
},
计算md5值
web-worker计算md5值
- npm i --save spark-md5
- 把安装好的spark-md5中的spark-md5.min.js拷贝到public目录中
- public目录中新建文件hash.js
// hash.js
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)
}
<template>
<div id="app">
......
<div>
<p>计算hash进度</p>
<el-progress :text-inside="true" :stroke-width="26" :percentage="hashProgress"></el-progress>
</div>
</div>
</template>
<script>
import axios from 'axios';
const CHUNK_SIZE = 0.5 * 1024 * 1024;
export default {
data() {
return {
......
hashProgress: 0,
};
},
......
methods: {
......
createFileChunk(file, size = CHUNK_SIZE) {
const chunks = [];
let cur = 0;
while (cur < this.file.size) {
chunks.push({
index: cur,
file: file.slice(cur, cur + size),
});
cur += size;
}
return chunks;
},
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.hashProgress = Number(progress.toFixed(2));
if (hash) {
resolve(hash);
}
};
});
},
async uploadFile() {
......
this.chunks = this.createFileChunk(this.file);
const hash = await this.calculateHashWorker();
console.log('hash', hash);
const form = new FormData();
form.append('name', 'file');
form.append('file', this.file);
axios.post('/uploadFile', form, {
onUploadProgress: progress => {
this.uploadProgress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
},
},
};
</script>
requestIdleCallback计算文件md5
calculateHashIdle() {
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<this.chunks.length && deadline.timeRemaining() > 1) {
await appendToSpark(this.chunks[count].file);
count++;
if (count<this.chunks.length) {
this.hashProgress = Number((100*count) / this.chunks.length.toFixed(2))
} else {
this.hashProgress = 100;
resolve(spark.end())
}
}
window.requestIdleCallback(workLoop)
}
window.requestIdleCallback(workLoop)
})
}
抽样hash
- 不算全量
- 布隆过滤器 损失一部分的精度 换取效率
calculateHashSample() {
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;
}
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = e => {
spark.append(e.target.result);
this.hashProgress = 100;
resolve(spark.end());
};
});
}
切片上传
<template>
<div class="cube-container" :style="{ width: cubeWidth + 'px' }">
<div class="cube" v-for="chunk in chunks" :key="chunk.name">
<div
:class="{
uploading: chunk.progress > 0 && chunk.progress < 100,
success: (chunk.progress == 100),
error: chunk.progress < 0,
}"
:style="{height: chunk.progress + '%'}"
>
<i
class="el-icon-loading"
style="color: #f56c6c"
v-if="chunk.progress < 100 && chunk.progress > 0"
></i>
</div>
</div>
</div>
</template>
......
data() {
return {
file: null,
hashProgress: 0,
chunks: [],
hash: ''
};
},
computed: {
uploadProgress() {
if (!this.file || this.chunks.length) {
return 0;
}
const loaded = this.chunks
.map(item => {
item.chunk.size * item.progress;
})
.reduce((acc, cur) => acc + cur, 0);
return Number((loaded * 100) / this.file.size.toFixed(2));
},
cubeWidth() {
return Math.ceil(Math.sqrt(this.chunks.length)) * 16;
},
},
methods: {
async uploadFile() {
const chunks = this.createFileChunk(this.file);
const hash = await this.calculateHashWorker(chunks);
this.hash = hash;
this.chunks = chunks.map((chunk, index) => {
const name = hash + '-' + index;
return {
hash,
name,
index,
chunk: chunk.file,
progress: 0
};
});
await this.uploadChunks();
},
async uploadChunks() {
const requests = this.chunks
.map(({ chunk, hash, name }) => {
// 转成promise
const form = new FormData();
form.append('chunk', chunk);
form.append('hash', hash);
form.append('name', name);
return form;
})
.map((form, index) => {
axios.post('/uploadFile', form, {
onUploadProgress: progress => {
// 不是整体的进度条了,而是每个区块有自己的进度条,整体的进度条需要计算
this.chunks[index].progress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
});
// @todo 并发量控制
await Promise.all(requests);
},
}
......
<style>
.cube-container {
.cube {
width: 14px;
height: 14px;
line-height: 12px;
border: 1px solid #000;
background: #ccc;
float: left;
.success {
background: green;
}
.uploading {
background: blue;
}
.error {
background: red;
}
}
}
</style>
文件合并
mergeRequest() {
axios.post('/merge', {
ext: this.file.name.split('.').pop(),
size: CHUNK_SIZE,
hash: this.hash
}).then(res => {
if (res.code === 200) {
this.$message({
type: 'success',
message: '上传成功'
})
}
})
},
秒传功能 & 断点续传
- 问一下后端,文件是否上传过,如果没有,是否有存在的切片
- 把不存在的切片上传,已经存在的切片不上传
async uploadFile() {
const chunks = this.createFileChunk(this.file);
const hash = await this.calculateHashWorker(chunks);
this.hash = hash;
const {
data: { uploaded, uploadedList },
} = axios.post('/checkFile', {
hash: this.hash,
ext: this.file.name.split('.').pop(),
});
if (uploaded) {
// 秒传
return this.$message({
type: 'success',
message: '秒传成功',
});
}
this.chunks = chunks.map((chunk, index) => {
const name = hash + '-' + index;
return {
hash,
name,
index,
chunk: chunk.file,
progress: uploadedList.indexOf(name) > -1 ? 100 : 0
};
});
await this.uploadChunks(uploadedList);
},
async uploadChunks(uploadedList) {
const requests = this.chunks
.filter(chunk => uploadedList.indexOf(chunk.name) === -1)
.map(({ chunk, hash, name, index }) => {
// 转成promise
const form = new FormData();
form.append('chunk', chunk);
form.append('hash', hash);
form.append('name', name);
return {
form,
index,
error: 0
};
})
.map(({form, index}) => {
axios.post('/uploadFile', form, {
onUploadProgress: progress => {
// 不是整体的进度条了,而是每个区块有自己的进度条,整体的进度条需要计算
this.chunks[index].progress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
},
});
});
// @todo 并发量控制
await Promise.all(requests);
await this.mergeRequest();
},
并发数控制
- 尝试申请tcp链接过多,也会造成卡顿
sendRequest(chunks, limit = 3) {
return new Promise(resolve => {
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
}
})
}
报错重试 & 报错次数限制
- 报错之后,进度条变红,开始重试
- 一个切片重试失败三次后,整体全部终止
sendRequest(chunks, limit = 3) {
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 {
isStop = true;
reject()
}
}
}
};
while (limit > 0) {
start();
limit -= 1;
}
});
}
完整版
<template>
<div id="app">
<div ref="drag" id="drag">
<input type="file" name="file" @change="handleFileChange" />
</div>
<button @click="uploadFile">上传文件</button>
<!-- network 改成fast 3G 观察效果-->
<el-progress :text-inside="true" :stroke-width="26" :percentage="uploadProgress"></el-progress>
<div>
<p>计算hash进度</p>
<el-progress :text-inside="true" :stroke-width="26" :percentage="hashProgress"></el-progress>
</div>
<div>
<div class="cube-container" :style="{ width: cubeWidth + 'px' }">
<div class="cube" v-for="chunk in chunks" :key="chunk.name">
<div
:class="{
uploading: chunk.progress > 0 && chunk.progress < 100,
success: chunk.progress == 100,
error: chunk.progress < 0,
}"
:style="{ height: chunk.progress + '%' }"
>
<i
class="el-icon-loading"
style="color: #f56c6c"
v-if="chunk.progress < 100 && chunk.progress > 0"
></i>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import sparkMD5 from 'spark-md5';
const CHUNK_SIZE = 0.5 * 1024 * 1024;
export default {
data() {
return {
file: null,
// uploadProgress: 0,
hashProgress: 0,
chunks: [],
hash: '',
};
},
mounted() {
this.bindEvent();
},
computed: {
uploadProgress() {
if (!this.file || this.chunks.length) {
return 0;
}
const loaded = this.chunks
.map(item => {
item.chunk.size * item.progress;
})
.reduce((acc, cur) => acc + cur, 0);
return Number((loaded * 100) / this.file.size.toFixed(2));
},
cubeWidth() {
return Math.ceil(Math.sqrt(this.chunks.length)) * 16;
},
},
methods: {
blobToString(blob) {
return new Promise(resolve => {
const reader = new FileReader();
reader.onload = function () {
console.log('reader.result', reader.result);
const ret = reader.result
.split('')
.map(v => v.charCodeAt()) // unicode编码
.map(v => v.toString(16).toUpperCase()) //十六进制的字符串
.map(v => v.padStart(2, '0')) //ES2017 引入了字符串补全长度的功能。如果某个字符串不够指定长度,会在头部补全。
.join(' ');
resolve(ret);
};
reader.readAsBinaryString(blob);
});
},
async isGif(file) {
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 isPng(file) {
const ret = await this.blobToString(file.slice(0, 8));
console.log(ret);
const isPng = ret === '89 50 4E 47 0D 0A 1A 0A';
return isPng;
},
async isJpg(file) {
const len = file.size;
const start = await this.blobToString(file.slice(0, 2));
const tail = await this.blobToString(file.slice(-2, len));
console.log(start, tail);
const isjpg = start === 'FF D8' && tail === 'FF D9';
return isjpg;
},
async isImage(file) {
return (await this.isGif(file)) || (await this.isPng(file)) || (await this.isJpg(file));
},
bindEvent() {
const drag = this.$refs.drag;
drag.addEventListener('dragover', e => {
drag.style.borderColor = 'red';
e.preventDefault();
});
drag.addEventListener('dragleave', e => {
drag.style.borderColor = '#ccc';
e.preventDefault();
});
drag.addEventListener('drop', e => {
console.log(e.dataTransfer.files);
const fileList = e.dataTransfer.files;
drag.style.borderColor = '#ccc';
this.file = fileList[0];
e.preventDefault();
});
},
createFileChunk(file, size = CHUNK_SIZE) {
const chunks = [];
let cur = 0;
while (cur < this.file.size) {
chunks.push({
index: cur,
file: file.slice(cur, cur + size),
});
cur += size;
}
return chunks;
},
calculateHashWorker(chunks) {
return new Promise(resolve => {
this.worker = new Worker('/hash.js');
this.worker.postMessage({
chunks,
});
this.worker.onmessage = e => {
const { progress, hash } = e.data;
this.hashProgress = Number(progress.toFixed(2));
if (hash) {
resolve(hash);
}
};
});
},
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));
} else {
this.hashProgress = 100;
resolve(spark.end());
}
}
window.requestIdleCallback(workLoop);
};
window.requestIdleCallback(workLoop);
});
},
calculateHashSample() {
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;
}
reader.readAsArrayBuffer(new Blob(chunks));
reader.onload = e => {
spark.append(e.target.result);
this.hashProgress = 100;
resolve(spark.end());
};
});
},
async uploadFile() {
// if (!(await this.isImage(this.file))) {
// console.log('文件格式不对');
// return;
// } else {
// console.log('文件格式正确')
// }
const chunks = this.createFileChunk(this.file);
const hash = await this.calculateHashWorker(chunks);
this.hash = hash;
// console.log('hash', hash);
// const hash1 = await this.calculateHashIdle();
// console.log('hash1', hash1);
// const hash2 = await this.calculateHashSample();
// console.log('hash2', hash2);
const {
data: { uploaded, uploadedList },
} = axios.post('/checkFile', {
hash: this.hash,
ext: this.file.name.split('.').pop(),
});
if (uploaded) {
// 秒传
return this.$message({
type: 'success',
message: '秒传成功',
});
}
this.chunks = chunks.map((chunk, index) => {
// 切片的名字 hash + index
const name = hash + '-' + index;
return {
hash,
name,
index,
chunk: chunk.file,
progress: uploadedList.indexOf(name) > -1 ? 100 : 0,
};
});
await this.uploadChunks(uploadedList);
},
async uploadChunks(uploadedList) {
const requests = this.chunks
.filter(chunk => uploadedList.indexOf(chunk.name) === -1)
.map(({ chunk, hash, name, index }) => {
// 转成promise
const form = new FormData();
form.append('chunk', chunk);
form.append('hash', hash);
form.append('name', name);
return {
form,
index,
error: 0
};
});
// .map(({form, index}) => {
// axios.post('/uploadFile', form, {
// onUploadProgress: progress => {
// // 不是整体的进度条了,而是每个区块有自己的进度条,整体的进度条需要计算
// this.chunks[index].progress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
// },
// });
// });
await this.sendRequest(requests);
// await Promise.all(requests);
await this.mergeRequest();
// const form = new FormData();
// form.append('name', 'file');
// form.append('file', this.file);
// axios.post('/uploadFile', form, {
// onUploadProgress: progress => {
// this.uploadProgress = Number(((progress.loaded / progress.total) * 100).toFixed(2));
// },
// });
},
sendRequest(chunks, limit = 3) {
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 {
isStop = true;
reject()
}
}
}
};
while (limit > 0) {
start();
limit -= 1;
}
});
},
mergeRequest() {
axios
.post('/merge', {
ext: this.file.name.split('.').pop(),
size: CHUNK_SIZE,
hash: this.hash,
})
.then(res => {
if (res.code === 200) {
this.$message({
type: 'success',
message: '上传成功',
});
}
});
},
handleFileChange(e) {
const [file] = e.target.files;
if (!file) return;
this.file = file;
},
},
};
</script>
<style lang="less">
#drag {
height: 100px;
border: 1px dashed #ccc;
display: flex;
width: 100%;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.cube-container {
.cube {
width: 14px;
height: 14px;
line-height: 12px;
border: 1px solid #000;
background: #ccc;
float: left;
.success {
background: green;
}
.uploading {
background: blue;
}
.error {
background: red;
}
}
}
</style>