Bootstrap

基于Vue的Upload组件实现

Upload组件基本实现

仓库:https://gitee.com/aeipyuan/upload_component

前端

1. 组件结构

upload组件

<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事件

  1. 根据父组件传入的规则对选中的文件进行过滤
  2. 遍历过滤后的数组,生成监听的数组(直接监听原数组浪费性能)
  3. 设置属性监听各种操作
  4. 将请求函数存入队列,延迟执行
  5. 调用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
            }))
        }
    })
}
;