文件的上传、删除、下载基本是每个项目都有的功能,免不了和后端小伙伴的对接。本文从前端的角度,实现简易的demo,帮助梳理这些功能前后端的交互流程。
1、页面功能描述
- 上传:只能单个文件上传,上传成功后,刷新文件列表。一个文件可以重复上传多次,但是内存中只存一份(以文件名唯一来判断,不考虑其它复杂情况,可以考虑存文件hash);
- 删除:先删除在数据库中文件的上传记录,若该文件记录已经已经为0,那么从内存中删除该文件;
- 下载:下载指定文件名的文件(get请求,利用a标签download属性);
2、前端部分
前端使用vue
、file-saver
,请提前npm install
2.1 前端整体代码(可能缺失部分css样式)
<template>
<div class="container">
<span class="loading" v-show="isLoading"></span>
<p>
<input type="file" id="file" @change="changeFile">
<button type="button" @click="uploadFile">上传文件</button>
</p>
<table>
<thead>
<tr>
<th>序号</th>
<th>上传时间</th>
<th>文件名</th>
<th>文件大小</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-if="fileList.length">
<tr v-for="(item, index) in fileList">
<td>{{index + 1}}</td>
<td>{{item.dateTime}}</td>
<td>{{item.fileName}}</td>
<td>{{item.size}}</td>
<td>
<button type="button" @click="downloadFile(item.fileName)">下载</button>
<button type="button" @click="deleteFile(item)" style="background-color: #e52050; margin-left: 10px;">删除</button>
</td>
</tr>
</template>
<template v-else>
<tr>
<td colspan="5" class="tc">无数据</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script>
// 通过blob下都会先获取文件,然后生成文件,再下载 没有直接下载方式友好(直接下载会马上出来浏览器下载进度)。
// 在相同来源内使用URL只会使用a[download]
import {saveAs} from 'file-saver';
export default {
name: 'FileList',
data() {
return {
isLoading: true,
fileName: '',
fileList: []
}
},
methods: {
getFileList() {
this.isLoading = true;
// 设置了es的刷新间隔为1s 所以要用定时器。(后面看后端处理方式)
setTimeout(() => {
fetch('/api/fileList', {
method: 'get'
}).then(res => res.json())
.then(data => {
this.fileList = data.status === 200 ? data.data : [];
this.isLoading = false;
})
.catch(error => {
this.isLoading = false;
console.error('Error:', error);
});
}, 1000);
},
deleteFile(item) {
fetch(`/api/file?fileName=${item.fileName}&id=${item.id}`, {
method: 'delete'
}).then(res => res.json())
.then(data => {
data.status === 200 && this.getFileList();
})
.catch(error => console.error('Error:', error));
},
uploadFile() {
if (!this.fileName) {
window.alert('请选择需要上传的文件');
return 0;
}
let formData = new FormData();
let fileField = document.getElementById('file');
formData.append('fileName', this.fileName);
formData.append('file', fileField.files[0]);
fetch('/api/file', {
method: 'post',
body: formData
}).then(res => res.json())
.then(data => {
if (data.status === 200) {
fileField.value = '';
this.fileName = '';
this.getFileList();
}
})
.catch(error => console.error('Error:', error));
},
// 这种方式是把文件流读取到浏览器内存中
downloadFileByFileSaver(url, method) {
let fileName = url.split('fileName=')[1];
fetch(url, {
method
}).then(res => res.blob())
.then(data => {
let blob = new Blob([data], {
type: 'application/octet-stream'
});
saveAs(blob, fileName);
})
.catch(error => console.error('Error:', error));
},
downloadFileByIframe(url) {
let iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(() => document.body.removeChild(iframe), 100);
},
downloadFileByAtagClick(url, fileName = '') {
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
document.body.appendChild(a);
fileName && a.setAttribute('download', fileName);
a.click();
document.body.removeChild(a);
// 方法二: saveAs(url);
},
// 这种方式是把文件流读取到浏览器内存中
downloadFileByBlob(url, method = 'get') {
// 使用这种方式多了创建请求的时间
let fileName = url.split('fileName=')[1];
fetch(url, {
method
}).then(res => {
console.log(res.headers.get('content-length'));
return res.blob();
}).then(data => {
let blob = new Blob([data], {
type: 'application/octet-stream'
});
// url表示指定的 File 对象或 Blob 对象。
let url = URL.createObjectURL(blob);
this.downloadFileByAtagClick(url, fileName);
// 释放URL对象
URL.revokeObjectURL(url);
})
.catch(error => console.error('Error:', error));
},
downloadFile(fileName) {
// this.downloadFileByIframe(`/api/file?fileName=${fileName}`);
this.downloadFileByAtagClick(`/api/file?fileName=${fileName}`);
// this.downloadFileByBlob(`/api/file?fileName=test3.zip`, 'get');
// this.downloadFileByFileSaver(`/api/file?fileName=${fileName}`, 'get');
},
changeFile(e) {
this.fileName = e.target.files.length ? e.target.files[0].name : '';
}
},
mounted() {
this.getFileList();
}
}
</script>
<style scoped>
.loading {
position: fixed;
left: 50%;
top: 50%;
width: 150px;
height: 100px;
background: url("../assets/loading.gif");
background-size: cover;
transform: translateX(-50%) translateY(-50%);
}
th {
padding: 10px;
background-color: #60bb63;
color: white;
font-size: 15px;
}
td {
padding: 6px 10px;
}
</style>
2.2 前端文件下载的几种方式
前端文件下载主要有2种方式:
- 利用浏览器提供的get请求,来直接下载文件,前端常见处理方式为:
a [href][download]
、iframe [src]
;
选这种方式要看后端下载的实现方式,是否能让前端直接下载。这种方式比好的是下载是同步的,点击下载按钮后,能直接看到浏览器的反应。 - 利用blob,获取后端文件流,再生成URL,利用
a [href][download]
来下载文件(这种方式通用度比较高);
这种方式通用度比较高,不好的是前端要先把后端返回的文件流全部获取,才能进行下载。在获取文件流这段时间是前端ajax异步加载过程。
这2种方式都是把文件流存在浏览器内存中,比较依赖于客户端硬件配置。手机端下载大文件一般会直接写到硬盘(手机存储)中。
2.2.1 直接下载-利用a标签
downloadFileByAtagClick(url, fileName = '') {
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
document.body.appendChild(a);
fileName && a.setAttribute('download', fileName);
a.click();
document.body.removeChild(a);
// 方法二: saveAs(url);
},
这个可以直接利用file-saver的saveAs
方法,它会根据传入的参数,来选择使用blob
还是a [href]
2.2.2 直接下载-利用iframe标签
downloadFileByIframe(url) {
let iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(() => document.body.removeChild(iframe), 100);
},
iframe [src]
标签跟a [href]
标签原理差不多。
2.2.3 blob-原生js
downloadFileByBlob(url, method = 'get') {
// 使用这种方式多了创建请求的时间
let fileName = url.split('fileName=')[1];
fetch(url, {
method
}).then(res => {
console.log(res.headers.get('content-length'));
return res.blob();
}).then(data => {
let blob = new Blob([data], {
type: 'application/octet-stream'
});
// url表示指定的 File 对象或 Blob 对象。
let url = URL.createObjectURL(blob);
this.downloadFileByAtagClick(url, fileName);
// 释放URL对象
URL.revokeObjectURL(url);
})
.catch(error => console.error('Error:', error));
},
2.2.3 blob-"file-saver"saveAs方法
downloadFileByFileSaver(url, method) {
let fileName = url.split('fileName=')[1];
fetch(url, {
method
}).then(res => res.blob())
.then(data => {
let blob = new Blob([data], {
type: 'application/octet-stream'
});
saveAs(blob, fileName);
})
.catch(error => console.error('Error:', error));
},
3、后端部分
后端主要依赖库:fs、express、multer、elasticsearch
,除了fs
,其它模块请提前npm install。
3.1 后端代码-web交互
/**用来实现文件上传、下载、删除功能**/
let fs = require('fs');
let express = require('express');
let multer = require('multer');
let esUtil = require('./esUtil');
let app = express();
let storage = multer.diskStorage({
destination: 'file/',
filename: function (req, file, cb) {
cb(null, file.originalname);
}
});
let upload = multer({ storage: storage });
esUtil.initEsUtil({ host: 'localhost:9200' });
app.listen(3100);
// 单文件上传,file指代上传时候的文件属性名
app.post('/file', upload.single('file'), async (req, res, next) => {
let file = req.file;
let fileInfo = {};
// 获取文件信息
fileInfo.mimetype = file.mimetype;
fileInfo.fileName = file.originalname;
fileInfo.size = file.size;
fileInfo.path = file.path;
fileInfo.dateTime = new Date();
// 存入es
let result = await esUtil.post('files', 'file', fileInfo);
res.send(result);
});
// 文件下载
app.get('/file', (req, res, next) => {
let fileName = req.query.fileName;
let path = './file/' + fileName;
let size = fs.statSync(path).size; // 文件大小
res.writeHead(200, {
// 告诉浏览器文件是二进制文件,不想直接显示内容
'Content-type': 'application/octet-stream',
// 告诉浏览器这是一个需要下载的文件(以附件的形式下载),设置下载文件名
'Content-Disposition': 'attachment; filename=' + encodeURI(fileName),
'Content-Length': size
});
let readStream = fs.createReadStream(path); // 得到输入文件流
readStream.pipe(res);
});
// 文件删除
app.delete('/file', async (req, res, next) => {
let fileName = req.query.fileName;
let results = await esUtil.get('files', 'file', {
term: {
'fileName.keyword': {
value: fileName
}
}
});
// 删除指定数据
let result = await esUtil.delete('files', 'file', req.query.id);
if (results.data.length === 1 && result.status === 200) fs.unlinkSync('./file/' + fileName);
res.send(result);
});
// 获取文件列表
app.get('/fileList', async (req, res, next) => {
let results = await esUtil.getAll('files', 'file');
res.send(results);
});
app.use((req, res, next, err) => {
res.send({status: 500});
});
3.1.1 文件上传说明
multer实现文件的上传依赖库,可配置文件上传路径、单个文件上传和多个文件上传等。本文配置的单文件上传,上传目录是当前执行代码相对路径file/
。
3.1.2 文件下载说明
后端返回响应头需要添加:
- 'Content-type': 'application/octet-stream',告诉浏览器文件是二进制文件,不想直接显示内容。
- 'Content-Disposition': 'attachment; filename=' + encodeURI(fileName),告诉浏览器这是一个需要下载的文件(以附件的形式下载),并设置下载文件名。
- 'Content-Length': size,设置文件大小,这样前端在直接下载文件的时候会显示文件大小,对用户交互界面也更加友好。
3.2 后端代码-es操作
let elasticsearch = require('elasticsearch');
let client;
const ERROR_STATUS = 500;
const SUCCESS_STATUS = 200;
exports.initEsUtil = (config) => {
client = elasticsearch.Client(config);
return client;
};
// 插入一条数据
exports.post = (index, type, data) => {
return new Promise((resolve, reject) => {
client.index({
index,
type,
body: data
}, (err, res) => {
err ? reject({status: ERROR_STATUS}) : resolve({status: SUCCESS_STATUS});
});
});
};
// 获取所有数据
exports.getAll = async (index, type) => {
let res = await client.search({
index,
type,
body: {
sort: [
{
dateTime: {
order: 'desc'
}
}
],
}
});
let list = [];
if (res instanceof Error) return {status: ERROR_STATUS, data: list};
if (res && res.hits && res.hits.hits) {
let arr = res.hits.hits;
for (let item of arr) {
list.push(Object.assign({id: item._id}, item._source));
}
}
return {status: SUCCESS_STATUS, data: list};
};
// 查询指定数据
exports.get = (index, type, param) => {
return new Promise((resolve, reject) => {
client.search({
index,
type,
body: {
query: param
}
}, (err, res) => {
let list = [];
if (res && res.hits && res.hits.hits) {
let arr = res.hits.hits;
for (let item of arr) {
list.push(Object.assign({id: item._id}, item._source));
}
}
err ? reject({status: ERROR_STATUS, data: []}) : resolve({status: SUCCESS_STATUS, data: list});
});
});
};
// 删除指定数据
exports.delete = (index, type, id) => {
return new Promise((resolve, reject) => {
client.delete({
index,
type,
id
}, (err, res) => {
err ? reject({status: ERROR_STATUS}) : resolve({status: SUCCESS_STATUS});
});
});
};
3.2.1 es操作说明
es操作还是新手级别,只会用很简单的操作。不过最好把es相关操作和web交互代码分开,看着舒服一点。代码种用了async awite promise
相关写法,代码看起来直观一点,也避免回调地狱。
4、遗留问题
- 超大文件的上传和下载,如何友好处理;