【一步一个脚印】前端文件上传一:文件上传

5,117 阅读5分钟

【一步一个脚印】前端上拉加载,下拉刷新

【一步一个脚印】前端文件上传一

【一步一个脚印】前端文件上传二:大文件快速上传

文件上传

文件上传本质上就是向后台发送请求,请求体为二进制文件

针对二进制文件,我们该怎么进行传递呢

再说清楚这个问题前,我们需要先了解下Content-type这个概念

【扩展】HTTP请求头Content-type

Content-Type 实体头部用于指示资源的MIME类型 media type

在请求中 (如POSTPUT),客户端告诉服务器实际发送的数据类型

media type比较常用的有数据接口application/json,文本text/css,图片image/jpeg

application/jsontext/cssimage/jpeg

如果想对media type有深入了解的请看这里,传送门

对于文件上传来说,Content-type就应该设置为multipart/form-data

multipart/form-data 可用于HTML表单从浏览器发送信息给服务器。作为多部分文档格式,它由边界线(一个由'--'开始的字符串)划分出的不同部分组成。每一部分有自己的实体,以及自己的 HTTP 请求头,Content-DispositionContent-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步骤如下:

  1. 新建一个form实例:new FormData();
  2. 将需要传递传递的文件加入到form中:form.append('file', file);
  3. 发送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)
}
请求头后端得到的结果

制作一个上传组件

根据我自己写的上传组件,除了核心问题文件上传外(上文已说明),针对其他问题点及组件解决方式归纳如下

  1. 问题点一:

    1. 问题说明:原生input:file表单样式太丑,且不同浏览器样式各有差异,实现上传功能又必须借用input表单
    2. 解决思路:input:file表单样式重新定义,或者根据自定义/用户定义的按钮实现间接实现input:file
  2. 问题点二:

    1. 问题说明:进度条显示,多文件怎么实现进度条,上传的文件一一对应
    2. 解决思路:每一个文件都发送一次请求,进度条可以通过XMLHttpRequest.upload下的onprogress方法监听上传进度,e.loaded, e.total分别表示已上传的文件大小和总文件大小
  3. 【扩展】问题点三:

    1. 问题说明:后端代码怎么写
    2. 解决思路:通过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)

分析:后端得到上传信息后,开启异步保存文件,并将文件信息和并保存路径插入到数据库中,最后返回所有的保存数据给前端

到这里最基本的文件上传功能算是搞定了,但是并不意味着完毕了,文件拖拽上传,大文件如何更快的传递,断点续传又该如何做,这些问题还有待挖掘提升