【一步一个脚印】前端文件上传一
文件上传
文件上传本质上就是向后台发送请求,请求体为二进制文件
针对二进制文件,我们该怎么进行传递呢
再说清楚这个问题前,我们需要先了解下Content-type这个概念
【扩展】HTTP请求头Content-type
Content-Type
实体头部用于指示资源的MIME类型 media type在请求中 (如
POST
或PUT
),客户端告诉服务器实际发送的数据类型
media type比较常用的有数据接口application/json
,文本text/css
,图片image/jpeg
application/json | text/css | image/jpeg |
---|---|---|
如果想对media type有深入了解的请看这里,传送门
对于文件上传来说,Content-type就应该设置为multipart/form-data
multipart/form-data
可用于HTML表单从浏览器发送信息给服务器。作为多部分文档格式,它由边界线(一个由'--'
开始的字符串)划分出的不同部分组成。每一部分有自己的实体,以及自己的 HTTP 请求头,Content-Disposition
和Content-Type
用于文件上传领域,最常用的 (Content-Length
因为边界线作为分隔符而被忽略)
下面我们看下表单请求设置了multipart/form-data
和没设置的区别
//前端
<form action="http://localhost:8080/upload" method="post">
<input type="file" name="file" id="mfile2">
<button type="submit">上传</button>
</form>
<form action="http://localhost:8080/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="mfile3">
<button type="submit">上传</button>
</form>
//后端
router.post('/upload', ctx => {
console.log(ctx.request.files)
ctx.set("Access-Control-Allow-Origin", "*");
ctx.body = "111"
})
没加multipart/form-data | 加了multipart/form-data | |
---|---|---|
请求头 | ||
后端结果 |
很明显对于form表单,Content-type默认为application/x-www-form-urlencoded,如果你要进行文件上传就必须指定
最简单的文件上传
上个demo中,文件上传利用的是form表单,指定其enctype来达到文件上传的目的,但是使用form表单缺点实在是太多,现在文件上传推荐使用的是FormData
FormData
接口提供了一种表示表单数据的键值对key/value
的构造方式,并且可以轻松的将数据通过XMLHttpRequest.send()
方法发送出去,本接口和此方法都相当简单直接
案例如下:
使用FormData步骤如下:
- 新建一个form实例:
new FormData();
- 将需要传递传递的文件加入到form中:
form.append('file', file);
- 发送Ajax请求
<input type="file" name="" id="mfile">
<button id='btn'>上传</button>
const mfile = document.querySelector('#mfile');
const btn = document.querySelector('#btn')
const form = new FormData();
const url = 'http://localhost:8080/upload';
btn.addEventListener('click', upload)
async function upload() {
const file = mfile.files[0];
form.append('file', file);
uploadAjaxApi()
// const result = await uploadAxiosApi(url, form)
}
function uploadAjaxApi() {
//原生Ajax
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.onload = function () {
console.log(xhr.responseText)
}
xhr.send(form)
}
async function uploadAxiosApi(url, prams) {
//基于axios
return await axios.post(url, prams)
}
请求头 | 后端得到的结果 |
---|---|
制作一个上传组件
根据我自己写的上传组件,除了核心问题文件上传外(上文已说明),针对其他问题点及组件解决方式归纳如下
-
问题点一:
- 问题说明:原生input:file表单样式太丑,且不同浏览器样式各有差异,实现上传功能又必须借用input表单
- 解决思路:input:file表单样式重新定义,或者根据自定义/用户定义的按钮实现间接实现input:file
-
问题点二:
- 问题说明:进度条显示,多文件怎么实现进度条,上传的文件一一对应
- 解决思路:每一个文件都发送一次请求,进度条可以通过XMLHttpRequest.upload下的onprogress方法监听上传进度,e.loaded, e.total分别表示已上传的文件大小和总文件大小
-
【扩展】问题点三:
- 问题说明:后端代码怎么写
- 解决思路:通过nodejs,采用koa创建服务器环境,接收到文件保存在本地(其他服务器上),并将文件地址和信息保存在数据库中
了解以上三个问题后,接下来就着手解决,文件目录
ps:正常的逻辑上传的文件是放在后端代码所在的文件夹内(即上传文件存放位置一),但是我想直接保存在前端(即上传文件存放位置二),通过vue-cli起的服务能访问到,理想很丰满现实很骨感,vue-cli起的服务图片会带上hash值,方法行不通
1. input:file表单
我的方法是将input:file表单隐藏,通过插槽监听用户设置的button按钮的click事件,通过input.click()方法,使用隐藏的input元素,官方文档传送门
通过 click() 方法使用隐藏的 file input 元素
<input type="file" id="file" multiple accept="image/*" style="display:none" onchange="handleFiles(this.files)">
<button id="btn">Select some files</button>
const btn = document.getElementById("btn"),
file = document.getElementById("file");
btn.addEventListener("click", function (e) {
if (file) {
file.click();
}
}, false);
了解完方法后,下面开始着手组件的制作
<!-- app.vue -->
<upload :multiple="true" action="/api/upload">
<button class="btn">上传</button>
</upload>
<!-- upload.vue -->
<template>
<div>
<input
type="file"
name="file"
id=""
ref="input"
:multiple="multiple"
@change="handleChange"
/>
<div class="upload" @click="onClickTrigger">
<slot></slot>
</div>
</div>
</template>
<style>
input {
display: none;
}
</style>
export default {
name: "Upload",
props: {
multiple: { //是否可以上传多个文件
type: Boolean,
default: false,
},
action: { //后端接口
type: String,
default: "",
required: true,
},
},
data() {
return {
files: null,//需要上传的文件列表
uploadFinishList: [], //存储上传完成后,后端返回的数据,数据格式如下
// [{
// filesData:[], //存储后端返回的数据
// processBar:{} //存储进度条信息
// }]
};
},
methods: {
onClickTrigger() {
this.$refs.input.click();
},
async handleChange(e) {
const files = e.target.files;
this.files = files;
this.uploadFinishList = [];
files.forEach((file, index) => { //将需要上传的文件存放在formData
const form = new FormData();
form.append(`files`, file);
this.uploadFinishList.push({ //初始化进度条信息
processBar: { loaded: 0, total: 0 },
});
this.sendUpload(form, index); //发送上传请求
});
},
},
};
分析:插槽整合用户定义的button按钮,事件冒泡到包裹button的父元素触发click事件,通过 click() 方法使用隐藏的 file input 元素,当有文件上传时触发input:file表单的change事件,发送上传请求
2. 进度条
进度条可以通过XMLHttpRequest.upload下的onprogress方法监听上传进度,e.loaded, e.total分别表示已上传的文件大小和总文件大小,官方文档传送门
/*
XMLHttpRequest.upload下的onprogress方法,得到目前上传的文件大小和总文件大小
*/
// @/server/index.js
export function upload({ methods, url, form }, cb, index) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
xhr.open(methods, url);
xhr.onload = function () {
resolve(xhr.responseText);
}
xhr.upload.onprogress = function (e) {
//传递进度条需要的数据,并执行进度条函数
cb(index, e.loaded, e.total)
}
xhr.send(form);
})
}
<!-- app.vue -->
<upload :multiple="true" action="/api/upload">
<button class="btn">上传</button>
</upload>
<!-- upload.vue -->
<template>
<div>
<input
type="file"
name="file"
id=""
ref="input"
:multiple="multiple"
@change="handleChange"
/>
<div class="upload" @click="onClickTrigger">
<slot></slot>
</div>
<ul class="upload-list" >
<li v-for="(list,index) in uploadFinishList" :key="list.id">
<span>{{ files[index].name }}</span> - <span>总大小:{{ list.processBar.total }}</span> -
<span>进度:{{ list.processBar.loaded }}</span>
</li>
</ul>
</div>
</template>
import { upload } from "@/server/index.js";
export default {
name: "Upload",
props: {
...
},
data() {
return {
files: null,//得到需要上传的文件
uploadFinishList: [], //存储上传完成后,后端返回的数据
processBarIsShow: false,
};
},
methods: {
onClickTrigger() {
this.$refs.input.click();
},
async handleChange(e) {
...
this.processBarIsShow = true;
files.forEach((file, index) => { //将需要上传的文件存放在formData
...
this.sendUpload(form, index);
});
},
async sendUpload(form, index) {
//上传
const result = await upload(
{
methods: "post",
url: this.action,
form,
},
this.progress,
index
);
if (this.uploadFinishList[index]) {
this.$set(
this.uploadFinishList[index],
"filesData",
JSON.parse(result).files
);
} else {
this.uploadFinishList.push({
filesData: JSON.parse(result).files,
});
}
},
progress(index, loaded, total) {
//进度条函数,css就没做了,有了当前进度和总大小制作一个进度条就很简单了
this.uploadFinishList[index].processBar.loaded = loaded;
this.uploadFinishList[index].processBar.total = total;
},
},
};
分析:发送请求前,将上传文件,序号index和进度条函数,一并传递到api请求接口,在onprogress函数中执行进度条函数,同时通过文件序号index与上传文件之间形成联系
3. 后端代码
因为没有现成的接口可以使用,利用nodejs搭建一个简单的后端接口
const Koa = require('koa');
const serve = require("koa-static");
const Router = require('koa-router');
const Koabody = require('koa-body');
const fs = require('fs');
const path = require('path');
const mysql = require("mysql2/promise");
const app = new Koa();
const router = new Router();
app.use(Koabody({
multipart: true,
}));
class DB { //使用mysql数据库,monogo不会
constructor(options) {
this.options = Object.assign({
host: "localhost",
password: "123456",
user: "root",
database: "hcm",
}, options);
}
async initDB() {
const connection = await mysql.createConnection(this.options);
return connection;
}
}
// 初始化数据库
const db = new DB();
router.post('/upload', async ctx => {
/**
* @description: ctx.request.files必须是一个对象 传递的是[object FileList]报错
*/
ctx.set("Access-Control-Allow-Origin", "*");//允许跨域
const files = ctx.request.files.files;
if (!files) {
ctx.body = { 'msg': "没有传递图片", 'code': 401 }
return;
}
const res = await uploadControl(files); //上传处理函数
ctx.body = JSON.stringify({
files: res
});
})
function uploadControl(files) {
return new Promise(resolve => {//每一个文件都开启异步保存,所有的文件存储完成后利用Promise.all处理结果,没做错误处理
const asyncQueue = [];//异步队列
if (Array.isArray(files)) {
files.forEach(file => {
asyncQueue.push(uploadService(file));
})
} else {
asyncQueue.push(uploadService(files));
}
Promise.all(asyncQueue).then(res => {
resolve(res);
})
})
}
function uploadService(file) {
return new Promise(async resolve => {
let temp = null;
const img = file;
if (!Object.keys(img).length) {
// ctx.body = ctx.request.files.file
ctx.body = { 'msg': "没有传递图片", 'code': 401 }
return;
}
const { name = '', path: imgPath } = img;
const newTime = createNewTime();
const newName = createImgName(name);
//保存图片并提供保存地址
const imgSavePath = saveImg(newName, imgPath);
//将新得到的图片地址保存到数据库
const result = await insertToDb({ // 图片信息插入数据库
title: name,
from: '本地',//图片来源
imgUrl: imgSavePath,//图片保存的地址
newTime,
});
if (result.affectedRows > 0) {
temp = {
title: name,
imgUrl: imgSavePath,
newTime,
id: Date.now()
}
}
resolve(temp);
})
}
function saveImg(newName, imgPath) {
const readStream = fs.createReadStream(imgPath);
const uploadPath = path.resolve(__dirname, '../frontEnd/app/src/img', newName);
const writeStream = fs.createWriteStream(uploadPath);
readStream.pipe(writeStream);
return './img/' + newName;
}
async function insertToDb({ title, from, newTime, imgUrl }) {
const connection = await db.initDB();
const sql =
"INSERT INTO news (id,title,imgUrl,`from`,newTime) VALUES (0,?,?,?,?)";
const [result] = await connection.execute(sql, [title, imgUrl, from, newTime]);
return result;
}
function createImgName(name) {
//vue打包图片的hash值
const hash = '.da76812b.'
const nameArr = name.split('.');
// 1606563510414_dog.da76812b.jpg
return Date.now() + "_" + nameArr[0] + hash + nameArr[1];
}
function createNewTime() {
const date = new Date();
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
app.use(serve(path.resolve(__dirname + "/img")))
app.use(router.routes())
app.listen(8080)
分析:后端得到上传信息后,开启异步保存文件,并将文件信息和并保存路径插入到数据库中,最后返回所有的保存数据给前端
到这里最基本的文件上传功能算是搞定了,但是并不意味着完毕了,文件拖拽上传,大文件如何更快的传递,断点续传又该如何做,这些问题还有待挖掘提升