Upload组件基本实现
仓库:https://gitee.com/aeipyuan/upload_component
前端
1. 组件结构
<template>
<div class="uploadWrap">
<!-- 按钮 -->
<div class="upload">
<input type="file" class="fileUp" @change="uploadChange" multiple :accept="acceptTypes">
<button>点击上传</button>
</div>
<!-- 提示文字 -->
<span class="tips">
只能上传小于{{maxSize}}M的
<span v-for="type in fileTypes" :key="type">
{{type}}
</span>
格式图片,自动过滤
</span>
<transition-group appear tag="ul">
<!-- 上传标签 -->
<li class="imgWrap" v-for="item in fileList" :key="item.src">
<!-- 图片 -->
<div class="left">
<img :src="item.src" @load="revokeSrc(item.src)">
</div>
<!-- 右边文字和进度 -->
<div class="right">
<span class="name">{{item.name}} </span>
<span class="num">
<span>{{item.progress}} %</span>
<span class="continue" v-if="item.isFail" @click="continueUpload(item)">重试</span>
</span>
<div class="bar" :style="`width:${item.progress}%`"></div>
</div>
<!-- 取消上传标签 -->
<span class="cancle" @click="removeImg(item)">×</span>
<!-- 上传成功和失败tips -->
<span v-if="item.isFinished||item.isFail" :class="['flag',item.isFail?'redBd':(item.isFinished?'greenBd':'')]">
<span>{{item.isFail?'✗':(item.isFinished?'✓':'')}}</span>
</span>
</li>
</transition-group>
</div>
</template>
2. 响应式数据
data() {
return {
fileList: [],/* 文件列表 */
maxLen: 6,/* 请求并发数量 */
finishCnt: 0/* 已完成请求数 */
}
}
3. 父子传值
父组件可以通过属性传值设置上传的url,文件大小,文件类型限制,并且可监听上传输入改变和上传完成事件获取文件列表信息
/* 父组件 */
<Upload
:uploadUrl="`http://127.0.0.1:4000/multi`"
:maxSize="5"
:reqCnt="6"
:fileTypes="['gif','jpeg','png']"
@fileListChange="upChange"
@finishUpload="finishAll" />
/* 子组件 */
props: {
maxSize: {
type: Number,
default: 2
},
fileTypes: {
type: Array,
default: () => ['img', 'png', 'jpeg']
},
uploadUrl: {
type: String,
default: 'http://127.0.0.1:4000/multi'
},
reqCnt: {/* 最大请求并发量,在created赋值给maxLen */
default: 4,
validator: val => {
return val > 0 && val <= 6;
}
}
}
4. 所有upload组件公用的属性和方法
// 请求队列
let cbList = [], map = new WeakMap;
// 过滤不符合条件的文件
function filterFiles(files, fileTypes, maxSize) {
return files.filter(file => {
let index = file.name.lastIndexOf('.');
let ext = file.name.slice(index + 1).toLowerCase();
// 处理jepg各种格式
if (['jfif', 'pjpeg', 'jepg', 'pjp', 'jpg'].includes(ext))
ext = 'jpeg';
if (fileTypes.includes(ext) && file.size <= maxSize * 1024 * 1024) {
return true;
} else {
return false;
}
})
}
// 格式化文件名
function formatName(filename) {
let lastIndex = filename.lastIndexOf('.');
let suffix = filename.slice(0, lastIndex);
let fileName = suffix + new Date().getTime() + filename.slice(lastIndex);
return fileName;
}
// 请求
function Ajax(options) {
// 合并
options = Object.assign({
url: 'http://127.0.0.1:4000',
method: 'POST',
progress: Function.prototype
}, options);
// 返回Promise
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest;
/* 触发进度条更新 */
xhr.upload.onprogress = e => {
options.progress(e, xhr);
}
xhr.open(options.method, options.url);
xhr.send(options.data);
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (/^(2|3)\d{2}$/.test(xhr.status)) {
resolve(JSON.parse(xhr.responseText));
} else {
reject({ msg: "请求已中断" });
}
}
}
})
}
5. input标签change事件
- 根据父组件传入的规则对选中的文件进行过滤
- 遍历过滤后的数组,生成监听的数组(直接监听原数组浪费性能)
- 设置属性监听各种操作
- 将请求函数存入队列,延迟执行
- 调用request,若有剩余并发量则发起请求
/* <input type="file" class="fileUp" @change="uploadChange" multiple :accept="acceptTypes"> */
uploadChange(e) {
let files = filterFiles([...e.target.files], this.fileTypes, this.maxSize);//过滤
this.fileList = this.fileList.concat(files.map((file, index) => {
// 创建新对象,不直接监听file提高性能
let newFile = {};
newFile.name = formatName(file.name);
newFile.src = window.URL.createObjectURL(file);// 临时图片预览src
newFile.progress = 0;
newFile.abort = false;// 取消上传事件
newFile.imgSrc = "";// 返回的真实src
// 成功和失败标记
newFile.isFinished = false;
newFile.isFail = false;
// 上传起始和结束点
newFile.start = 0;
newFile.total = file.size;
// 存入队列后发起上传
cbList.push(() => this.handleUpload(file, newFile));
this.request();
return newFile;
}));
},
6. request函数
request函数用于实现请求并发
request() {
// 还有剩余并发数则执行队头函数
while (this.maxLen > 0 && cbList.length) {
let cb = cbList.shift();
this.maxLen--;
cb();
}
}
7. handleUpload函数
handleUpload函数用于文件切片,发起Ajax请求,触发各种请求处理事件等功能
handleUpload(file, newFile) {
let chunkSize = 1 * 2048 * 1024;// 切片大小2M
// 设置文件上传范围
let fd = new FormData();
let start = newFile.start;
let total = newFile.total;
let end = (start + chunkSize) > total ?
total : (newFile.start + chunkSize);
// 上传文件信息
let fileName = newFile.name;
fd.append('chunk', file.slice(start, end));
fd.append('fileInfo', JSON.stringify({
fileName, start
}));
return Ajax({
url: this.uploadUrl,
data: fd,
progress: (e, xhr) => {
// 因为会加上文件名和文件夹信息占用字节,还要等待响应回来,所以取小于等于95
let proNum = Math.floor((newFile.start + e.loaded) / newFile.total * 100);
newFile.progress = Math.min(proNum, 95);
// 手动中断上传
if (newFile.abort) {
xhr.abort();
}
}
}).then(res => {
if (end >= total) {
// 跳至100
newFile.progress = 100;
// 存url
newFile.imgSrc = res.imgSrc;
// 状态改变通知
newFile.isFinished = true;
this.finishCnt++;
this.fileListChange();
} else {
// 新的起始点
newFile.start = end + 1;
// 发送剩余资源
cbList.push(() => this.handleUpload(file, newFile));
}
}, err => {
newFile.isFail = true;
// 建立映射,点击重传使用
map.set(newFile, file);
}).finally(() => {
// 处理完一个请求,剩余并发数+1,重新调用request
this.maxLen++;
this.request();
});
}
8. 清理图片缓存
window.URL.createObjectURL(file)
创建的src
对应图片加载完毕以后需要移除缓存
/* <img :src="item.src" @load="revokeSrc(item.src)"> */
// 移除url缓存
revokeSrc(url) {
window.URL.revokeObjectURL(url);
}
9. 取消上传
/* <span class="cancle" @click="removeImg(item)">×</span> */
removeImg(item) {
item.abort = true;//触发中断
let index = this.fileList.indexOf(item);
if (index !== -1) {
this.fileList.splice(index, 1);
this.fileListChange();
}
}
10. 重试
遇到断网等特殊情况请求处理失败后可通过点击重试重新发起请求
/* <span class="continue" v-if="item.isFail" @click="continueUpload(item)">重试</span> */
continueUpload(newFile) {
newFile.isFail = false;
let file = map.get(newFile);
cbList.push(() => this.handleUpload(file, newFile));
this.request();
}
后端
1. 路由处理
/* app.js */
const app = require('http').createServer();
const fs = require('fs');
const CONFIG = require('./config');
const controller = require('./controller');
const path = require('path');
app.on('request', (req, res) => {
/* 跨域 */
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', '*');
/* 处理请求 */
let { method, url } = req;
console.log(method, url)
method = method.toLowerCase();
if (method === "post") {
/* 上传 */
if (url === '/multi') {
controller.multicleUpload(req, res);
}
}
else if (method === 'options') {
res.end();
}
else if (method === 'get') {
/* 静态资源目录 */
if (url.startsWith('/static/')) {
fs.readFile(path.join(__dirname, url), (err, data) => {
if (err)
return res.end(JSON.stringify({ msg: err }));
res.end(data);
})
}
}
})
app.listen(CONFIG.port, CONFIG.host, () => {
console.log(`Server start at ${CONFIG.host}:${CONFIG.port}`);
})
2. 文件解析和写入
function multicleUpload(req, res) {
new multiparty.Form().parse(req, (err, fields, file) => {
if (err) {
res.statusCode = 400;
res.end(JSON.stringify({
msg: err
}))
}
try {
// 提取信息
let { fileName, start } = JSON.parse(fields.fileInfo[0]);
// 文件块
let chunk = file.chunk[0];
let end = start + chunk.size;
// 文件路径
let filePath = path.resolve(__dirname, CONFIG.uploadDir, fileName);
// 创建IO流
console.log(start, end);
let ws;
let rs = fs.createReadStream(chunk.path);
if (start == 0)
ws = fs.createWriteStream(filePath, { flags: 'w' });//创建
else
ws = fs.createWriteStream(filePath, { flags: 'r+', start });//选定起始位修改
rs.pipe(ws);
rs.on('end', () => {
res.end(JSON.stringify({
msg: '上传成功',
imgSrc: `http://${CONFIG.uploadIP}:${CONFIG.port}/${CONFIG.uploadDir}/${fileName}`
}))
})
} catch (err) {
res.end(JSON.stringify({
msg: err
}))
}
})
}