Koa、Vue、axios、小程序上传图片到七牛不用愁(更新ing)

2,926 阅读16分钟

美女图片预警,请注意流量!!!

本文内容:

  • 前端上传
    • 直接上传
    • 自定义名字上传
    • 手动上传多张图片(axios)
    • 上传前过滤
  • 图片上传到服务器(预览)
  • 服务器端上传到七牛云
  • 小程序图片上传
    • 基本api上传
    • 云存储

文章全部示例代码地址:2019-0907-qiniu-upload

最近承包了一个小程序的前后端,具备小程序登录、微信支付(欲哭无泪的支付)、后台增加商品、管理订单、编辑页面等功能,发现了不少问题,于是走上了写文章的道路,请大家海涵我这个小菜...

在以前的项目,文件一般都是直接放在服务器某个文件夹里面的,对于特定区域的to B业务当然是没问题的,但是不适合小程序这种to C业务的场景。如下

  1. 小程序有大小限制,图片等放在源代码文件里体积太大
  2. 天南地北,服务器访问图片的速度不一样,影响客户体验
  3. 服务器出现问题,数据丢失导致时间、运维等成本提升

于是决定把文件、图片放在CDN,琢磨七牛的SDK和Upload组件花费了不少时间,踩了很多坑。诸多对象存储都是key-value的存储方式,key是唯一的键值,几乎都是token+配置参数的方式,上传方式大同小异,希望下面总结对你有所帮助。

1.前端上传

七牛云上传文件可以简单地分为两种方式:

  1. 前端从业务服务器获取上传凭证(token),前端直接把文件传到七牛云存储
  2. 业务服务器直接把文件上传到七牛云存储,比如日志、数据库备份,一般都是定时并加密的

front-end-post

backend

1.1直接上传

实现简单的文件上传到七牛其实很简单,一个前端er只需要一个上传组件然后让后端提供一个token请求。而后端只需要安装七牛官方的Node.js SDKnpm install qiniu,生成token的函数是:

function getQiniuToken() {
    const mac = new qiniu.auth.digest.Mac(qiniuConfig.AK, qiniuConfig.SK)
    const options = { 
        scope: qiniuConfig.bucket,
        expires: 7200 
    }
    const putPolicy = new qiniu.rs.PutPolicy(options)
    const uploadToken = putPolicy.uploadToken(mac)
    return uploadToken;
}

qiuniuConfig是我在服务端配置里的对象,用来保存AccessKey、SecretKey、存储区域、存储区域、加速域名等信息,AK/SK对安全极为重要,官方文档建议不要放在客户端和网络中明文传送。我的前端使用了iView的Upload组件

<template>
  <div class="home">
    <div class="directly-upload">
      <Upload
        ref="upload"
        multiple
        type="drag"
        :before-upload="handleBeforeUpload"
        :on-success="handleSuccess"
        :format="['jpg', 'jpeg', 'png']"
        :max-size="4096"
        :data="{token: qiniuToken}"
        :action="postURL">
        <div style="padding: 20px 0">
            <Icon type="ios-cloud-upload" size="52" style="color: #3399ff"></Icon>
            <p>点击或者拖拽文件到此处上传</p>
        </div>
      </Upload>
    </div>
  </div>
</template>

<script>

export default {
  name: 'home',
  data() {
    return {
      postURL: 'http://upload-z2.qiniup.com',
      qiniuToken: '',
    };
  },
  components: {
  },
  methods: {
    async handleBeforeUpload() {
      await this.$http.get('/qiniu/token').then((res) => {
        console.log(res.data.token);
        this.qiniuToken = res.data.token.trim();
      })
        .catch((err) => {
          this.$Notice.error({
            title: '错误',
            desc: '上传失败',
          });
        });
    },
    handleSuccess(res) {
      console.log(res);
    },
  },
};
</script>

稍微了解一下Upload组件API的作用:

  • action: 上传的地址,必填
  • data: 上传时附带的额外参数,这些参数会放在FormData里面
  • before-upload: 上传文件之前的钩子,参数为上传的文件,若返回 false 或者 Promise 则停止上传
  • on-success: 文件上传成功时的钩子,返回字段为 response, file, fileList
  • format: 支持的文件类型
  • max-size: 文件大小限制,单位 kb

使用代码的时候需要自行修改action绑定的地址,upload-z2.qiniup.com 只是华南区空间的地址,可以在这里查看上传地址:存储区域

front

我直传了一张小乔丁香结图片,控制台输出上传成功的参数:

{
    hash: "FrAvE78xCroRtgdIdM5u3ajlcLUL", 
    key: "FrAvE78xCroRtgdIdM5u3ajlcLUL"
}

加速域名拼接上返回参数的key就可以访问图片了,是不是很简单!

qiniu.hackslog.cn/FrAvE78xCro…

xiaoqiao

1.2自定义名字上传

自定义图片的名字在后端生成token的代码options选项 中添加要重置的名字。options的配置很灵活,可以指定callbackUrl地址,比如前端上传图片成功后让七牛把结果传到后端某个接口,后端利用Redis记录的key把相关内容持久化到数据库;可以指定返回的参数returnBody,hash值、key、文件大小、所在空间等等。

var options = {
  //其他上传策略参数...
  returnBody: '{"key":"$(key)","hash":"$(etag)","fsize":$(fsize),"bucket":"$(bucket)","name":"$(x:name)","age":$(x:age)}'
}

生成token的代码:

// 获取上传Token并且能指定文件名字
function getQiniuTokenWithName(nameReWrite) {
    const mac = new qiniu.auth.digest.Mac(qiniuConfig.AK, qiniuConfig.SK)
    const options = { 
        scope: qiniuConfig.bucket + ":" + nameReWrite, 
        expires: 7200 
    }
    const putPolicy = new qiniu.rs.PutPolicy(options)
    const uploadToken = putPolicy.uploadToken(mac)
    return uploadToken 
}

知道了这些,你还不定能够成功上传,我第一次尝试就是上传失败了,那时也是忘了查看Chrome调试Network的Response,然后直接向七牛发了工单,他们工程师一上班就回复我了:

gongdan
他的意思是说file、key、token缺一不可。上传组件会帮你处理file,key和token需要在data这个API上绑定。

<template>
  <div class="with-name">
    <div class="directly-upload">
      <Upload
        ref="upload"
        multiple
        type="drag"
        :before-upload="handleBeforeUpload"
        :on-success="handleSuccess"
        :format="['jpg', 'jpeg', 'png', 'gif']"
        :max-size="4096"
        :data="{token: qiniuToken, key: keyName}"
        :action="postURL">
        <div style="padding: 20px 0">
            <Icon type="ios-cloud-upload" size="52" style="color: #3399ff"></Icon>
            <p>点击或者拖拽文件到此处上传</p>
        </div>
      </Upload>
    </div>
  </div>
</template>
<script>

export default {
  name: 'post-name',
  data() {
    return {
      postURL: 'http://upload-z2.qiniup.com',
      qiniuToken: '',
      keyName: 'dingxiangjie.jpg',
      perfix: 'blog', // 配置路径前缀
    };
  },
  methods: {
    async handleBeforeUpload(file) {
      console.log(file);
      const suffixList = file.name.split('.');
      const baseName = suffixList[0];
      const suffix = suffixList[1]; // 获取文件后缀,比如.jpg,.png,.gif
      const fileType = file.type;
      const timeStamp = new Date().getTime(); // 获取当前时间戳
      const newName = `${this.perfix}/${timeStamp}_${baseName}.${suffix}`; // 新建文件名
      const newfile = new File([file], newName, { type: fileType });
      this.keyName = newName;
      console.log(newfile);
      await this.$http.get('/qiniu/token/name', {
        params: {
          name: newName,
        },
      }).then((res) => {
        console.log(res.data.token);
        this.qiniuToken = res.data.token.trim();
      })
        .catch((err) => {
          console.log(err);
          this.$Notice.error({
            title: '错误',
            desc: '上传失败',
          });
        });
      console.log('我等到了...');
    },
    handleSuccess(res) {
      console.log(res);
    },
  },
};
</script>

我又折腾了一下,想在访问的文件路径加个前缀,比如http://qiniu.hackslog.cn/blog ,按照喜好可以有/code ,/product ,/gif 等等,结果嘛,就是不行。原因是生成的token和上传文件的name属性必须一一对应的,也就是说加了前缀比如是blog,那我上传的文件名也得是blog/xxx.jpg ,而前端选中的文件是只读属性,利用它的信息new File一个新的文件上传就可以解决问题了。

有趣的是,如果上传相同文件名,文件会被覆盖,如果源文件被删除,你可能仍然看到该链接。我猜部分节点数据并没有马上被删除,所以访问到副本的内容。比如qiniu.hackslog.cn/dingxiangji… 这个链接,我在北京看到的是小乔,重庆、深圳的朋友看到的却是公孙离,而实际上我空间上已经把这个图片删除了呢。

公孙离文件改名字后的上传

qiniu.hackslog.cn/15680735503…

公孙离

1.3 手动上传多张图片

多文件上传,目标要实现的是这样的。

manual

代码如下:

<template>
  <div class="form-data">
    <div class="directly-upload">
      <Upload
        ref="upload"
        multiple
        type="drag"
        :before-upload="handleBeforeUpload"
        :on-success="handleSuccess"
        :format="['jpg', 'jpeg', 'png', 'gif']"
        :max-size="4096"
        :data="{ token: qiniuToken, key: qiniuKey }"
        :action="postURL">
        <div style="padding: 20px 0">
            <Icon type="ios-cloud-upload" size="52" style="color: #3399ff"></Icon>
            <p>点击或者拖拽文件到此处上传</p>
        </div>
      </Upload>
      <Button type="success" style="float: right;" @click="handleUpload">确定上传</Button>
    </div>
  </div>
</template>
<script>

export default {
  name: 'form-date',
  data() {
    return {
      postURL: 'http://upload-z2.qiniup.com',
      baseURL: 'http://qiniu.hackslog.cn/',
      qiniuToken: '',
      qiniuKey: '',
      perfix: 'blog',
      keyList: [], // 存放上传的名字
      imageList: [], // 存放上传信息,
      uploadFile: [],
    };
  },
  methods: {
    handleBeforeUpload(file) {
      const suffixList = file.name.split('.');
      const baseName = suffixList[0]; // 源文件的名字
      const suffix = suffixList[1]; // 文件后缀
      const fileType = file.type; // 这个类似image/png之类,用于创建文件
      const timeStamp = new Date().getTime(); // 当前的时间戳
      const newName = `${this.perfix}/${timeStamp}_${baseName}.${suffix}`; // 新文件名
      const newFile = new File([file], newName, { type: fileType });
      const postItem = {
        file: newFile,
        key: newName,
        token: '',
      };
      this.keyList.push(newName); // 把新建的文件名放到数组,传到后端拿到对应的Token
      this.imageList.push(postItem); // 包含文件信息的对象
      this.uploadFile.push(newFile); // 其实不用这个也行,this.imageList就包含了
      return false;
    },
    async handleUpload() {
      if (this.keyList.length === 0) {
        this.$Notice.warning({
          title: '当前没有可上传的文件!',
        });
        return;
      }
      const list = encodeURIComponent(JSON.stringify(this.keyList));
      await this.$http.get('/qiniu/token/list', {
        params: {
          list,
        },
      }).then((res) => { // 获取文件对应的token
        const resultList = res.data.tokenList;
        resultList.forEach((item, index) => {
          this.imageList[index].token = item;
        });
      })
        .catch(() => {
          this.$Notice.error({
            title: '错误',
            desc: '获取Token失败',
          });
        });
      console.log(this.imageList);
      this.imageList.forEach(async (item, index) => {
        this.qiniuToken = item.token;
        this.qiniuKey = item.key;
        await this.$nextTick(async () => {
          console.log(this.qiniuToken);
          console.log(this.uploadFile[index]); // 调用组件内部的直接上传逻辑
          await this.$refs.upload.post(this.uploadFile[index]);
        });
      });
    },
    handleSuccess(res) {
      console.log(res);
      this.$Notice.success({
        title: `文件${this.baseURL}${res.key}已经可以访问!`,
      });
    },
  },
};
</script>

但是很遗憾iView的Upload组件貌似无法做到这样的效果,以上代码存在问题 ! 如果你使用的Token是相同的并且使用列表循环方式顺序地控制上传(正确的多文件上传代码 ),可以模拟多文件上传效果的,可是它的data不能绑定多个不同的信息。我测试的情况是:把文件名(key值)作为数组传递到后端生成对应的token数组同样返回到前端,前端遍历包含token的数组,同时把token、key放到Upload组件props的data里,调用组件里的post方法上传(iView Upload组件源码第207行 ),打开控制台的Network你就会发现file、token和key都以FormData的方式上传了,只可惜Upload组件没有对data做相应的监听,数据还不能实时更新,导致FormData传递的key和token不是一一对应的,因此403报错。

token duplicated

改变一下方式,我们使用原始的input来实现多文件上传吧,逻辑控制也不复杂。于是axios批量上传文件,不用第三方UI组件的写法变成了(原谅我偷偷用个Button):

<template>
  <div class="multiple">
    <div class="directly-upload">
      <input
        ref="upload"
        @change="handleChange"
        type="file"
        name="file"
        accept="image/*"
        multiple>
      <Button type="success" style="float: right;" @click="handleUpload">确定上传</Button>
    </div>
  </div>
</template>
<script>
import axios from 'axios';
import qs from 'qs';

export default {
  name: 'multiple',
  data() {
    return {
      postURL: 'http://upload-z2.qiniup.com',
      baseURL: 'http://qiniu.hackslog.cn/',
      perfix: 'blog',
      keyList: [], // 存放上传的名字
      imageList: [], // 存放上传信息
    };
  },
  methods: {
    handleChange(e) { // 选择文件后将文件交给handleBeforeUpload进行处理
      const { files } = e.target;
      if (!files) {
        return;
      }
      Array.from(files).forEach((file) => {
        this.handleBeforeUpload(file);
      });
    },
    handleBeforeUpload(file) { // 将文件名变成:前缀/时间戳_原文件名.后缀
      const suffixList = file.name.split('.');
      const baseName = suffixList[0];
      const suffix = suffixList[1];
      const fileType = file.type;
      const timeStamp = new Date().getTime();
      const newName = `${this.perfix}/${timeStamp}_${baseName}.${suffix}`;
      const newFile = new File([file], newName, { type: fileType });
      const postItem = {
        file: newFile,
        key: newName,
        token: '',
      };
      this.keyList.push(newName);
      this.imageList.push(postItem);
      return false;
    },
    async handleUpload() {  // 执行真正的上传逻辑
      if (this.keyList.length === 0) {
        this.$Notice.warning({
          title: '当前没有可上传的文件!',
        });
        return;
      }
      // 去后台请求每个文件名对应的token
      // const list = encodeURIComponent(JSON.stringify(this.keyList));
      await this.$http.get('/qiniu/token/list', {
        params: {
          list: this.keyList,
        },
        paramsSerializer(params) {
          return qs.stringify(params, { arrayFormat: 'repeat' });
        },
      }).then((res) => {  // 保存token信息
        const resultList = res.data.tokenList;
        resultList.forEach((item, index) => {
          console.log(res.data.keyList[index]);
          this.imageList[index].token = item;
        });
      })
        .catch(() => {
          this.$Notice.error({
            title: '错误',
            desc: '获取Token失败',
          });
        });
      const queue = [];
      // 遍历,执行上传
      this.imageList.forEach((item) => {
        queue.push(this.postToQiniu(item));
      });
      this.$refs.upload.value = null;
    },
    postToQiniu(data) {
      const that = this;
      const formData = new FormData();
      formData.append('file', data.file);
      formData.append('key', data.key);
      formData.append('token', data.token);
      axios({
        method: 'POST',
        url: this.postURL,
        data: formData,
      })
        .then((res) => {
          const fileLink = `${that.baseURL}${res.data.key}`;
          this.$Notice.success({
            title: '上传成功!',
            desc: fileLink,
          });
        })
        .catch((err) => {
          console.log(err);
          this.$Notice.error({
            title: '上传失败!',
            desc: data.key,
          });
        });
    },
  },
};
</script>

写出上面的代码之前,还是403错误,看看Response报的错:

error

这个问题困惑了很久,我一直在想axios的设置是不是有啥问题,首先是headers,如果数据是FormData,请求头是自动设置multipart/form-data的,错误的请求带的数据也没问题,file、token都对应...

问题的根源是什么呢?我原本以为我生成的token对应着每个文件名,事实上是错误的。问题出在axios传递数组,我将他们encode了一下(上面被注释了)传递以免在get请求中出现错误(因为get请求不能直接携带/ [ ] =之类的字符)。

const list = encodeURIComponent(JSON.stringify(this.keyList));

然而后端拿到的数据我以为这样处理就万事大吉了:

    const decodeString = decodeURIComponent(ctx.query.list)
    const keyList = decodeString.split(',');

实际上里面keyList里面的值被改变了,没有察觉到!反正是axios的锅,为什么没有直接就把params自动encode,比如变成list?list=xxx&&list=xxx,后端拿到直接ctx.query.list就不会错了呀。看看下面后端处理list的结果,太难看了!

[ '["blog/1568196438856_flower.jpg"',
  '"blog/1568196438857_snow-street.jpg"]' ]

细心的同学发现我上面iView手动上传的代码有错了(我并没偷偷改过来),不过这样的错误并不证明iView支持多文件多信息的上传,因为它的问题出在多信息无法实时更新。于是axios传递数组采用了这样的方式:

import qs from 'qs';
this.$http.get('/qiniu/token/list', {
    params: {
    	list: this.keyList,
    },
	paramsSerializer(params) {
		return qs.stringify(params, { arrayFormat: 'repeat' });
	},
})

结果嘛...当然能上传的!不过后端还是出了一点点问题,比如只传一张图片的时候ctx.query.lis 拿到的是字符串而不是数组,我用instanceof来过滤了一下就好了。前端代码 && 后端代码

qiniu_manual

1.4 上传前的过滤

上传过滤一般限制大小,Upload组件已经提供了on-exceeded-sizeAPI来实现了。我遇到的需求是:上传前限制上传的尺寸,具体场景是小程序的轮播图、商品详情图,如果尺寸不一致很不美观。

        handleUpload(file){
            this.uploadForm = {
                stationId: this.currentStation
            }
            return this.checkImageWH(file, 1080, 900);
        },
        checkImageWH(file, width, height) {
            let self = this;
            return new Promise(function (resolve, reject) {
                let filereader = new FileReader();
                filereader.onload = e => {
                    let src = e.target.result;
                    const image = new Image();
                    image.onload = function () {
                        if (width && this.width < width) {
                            self.$Message.error(`请上传宽大于${width}的图片`);
                            reject();
                        } else if (height && this.height < height) {
                            self.$Message.error(`请上传高大于${height}的图片`);
                            reject();
                        } else {
                            resolve();
                        }
                    };
                    image.onerror = reject;
                    image.src = src;
                };
                filereader.readAsDataURL(file);
            });
        },  

2.直接上传到生产服务器(前端可预览)

后端使用Koa,解析请求的时候我们一般用的是koa-bodyparser ,它支持json、表单、文本类型的格式数据,但是不支持form-data(文件上传),改用koa-body 替代

const Koa = require('koa')
const koaBody = require('koa-body')
const koaStatic = require('koa-static')
const fs = require('fs')
const path = require('path')
const app = new Koa()
const cors = require('koa2-cors')
const routing = require('./routes')

// 生成年月日
function yearMonthDay() {
    const today = new Date()
    const year = today.getFullYear()
    const month = today.getMonth() + 1
    const day = today.getDate()
    return `${year}${suppleZero(month)}${suppleZero(day)}`;
}

app.use(cors())  // 解决跨域请求问题
// 打印请求日志
app.use(async (ctx, next) => {
    const start = new Date()
    await next()
    const ms = new Date() - start
    console.log(`${ctx.method}${ctx.url}-${ms}ms`)
})
app.use(koaStatic(path.join(__dirname, 'public')))
app.use(koaBody({
    multipart: true, // 指的是multipart/form-data,支持文件上传
    formidable: {
      maxFieldsSize: 4 * 1024 * 1024, // 限制上传文件的体积
      uploadDir: path.join(__dirname, '/public/uploads'),  // 上传到的文件路径
      keepExtensions: true,  // 保留文件后缀
      onFileBegin(name, file){
        const originalFileName = file.name  // 获取上传文件的原名字
        const ext = path.extname(originalFileName)  // node自带的可以获取文件后缀名的方法,它是带.的
        const timeStamp = new Date().getTime()
        const randomNum = Math.floor(Math.random()*10000 + 1) 
        const fileName = `${timeStamp}${randomNum}${ext}` // 文件名是时间戳+10000以内的随机数,我就不信会重名
        const dir = path.join(__dirname, `/public/uploads/${yearMonthDay()}`) // 按期日创建文件夹
        if(!fs.existsSync(dir)){
          fs.mkdirSync(dir) 
        }
        file.path = `${dir}/${fileName}` // 完成文件名的修改
      },
    },
  }))
routing(app);

app.listen(5000, () => console.log('服务启动于5000端口...'))

koa-static可以给我设定可以供外界访问的静态资源。koa-body可以自定将上传的文件做一些转换,默认情况下上传的文件会被重命名,通过fromidable可以自定义文件名,比如我自定义成当前时间戳+10000以内的随机数

server name

其实如果是手动上传,我们还是可以用iView的,只需要返回before-upload的值为false或者Promise,再用一个按钮调用axios上传的逻辑。在iView文档自定义上传列表中的示例,可以看出我们可以利用this.$refs.upload.fileList 来保存文件信息,也可以实现预览、删除等功能。相对elementUI而言,iView Upload组件的示例有一定局限性,因此我自己写样式展示文件预览:

directly_to_serve

<template>
    <div class="server-upload">
      <div class="upload-box">
        <div class="demo-upload-list" v-for="(item, index) in imageList" :key="item">
          <img :src="item">
          <div class="demo-upload-list-cover">
              <Icon type="ios-eye-outline" @click.native="handleView(item)"></Icon>
              <Icon type="ios-trash-outline" @click.native="handleRemove(index)"></Icon>
          </div>
        </div>
        <Upload
            ref="upload"
            :show-upload-list="false"
            :on-success="handleSuccess"
            :format="['jpg','jpeg','png', 'gif']"
            :max-size="4096"
            :on-exceeded-size="handleMaxSize"
            :before-upload="handleBeforeUpload"
            multiple
            type="drag"
            :action="postURL"
            style="display: inline-block;width:180px;">
            <div style="width: 180px;height:180px;line-height: 180px;">
                <Icon type="ios-camera" size="20"></Icon>
            </div>
        </Upload>
        <div class="upload-submit-btn">
          <Button type="success" @click="confirmUpload">确认上传</Button>
        </div>
      </div>
      <Modal title="查看图片" v-model="visible">
          <img :src="imgURL" v-if="visible" style="width: 100%">
      </Modal>
    </div>
</template>
<script>


export default {
  name: 'server',
  data() {
    return {
      postURL: 'http://localhost:5000/api/qiniu/directly',
      imgBaseURL: 'http://localhost:5000/uploads/',
      uploadList: [],
      imageList: [],
      visible: false,
      imgURL: '',
    };
  },
  methods: {
  	// 上传之前检测是否超出限制,使用FileReader获取本地图片用来展示
    handleBeforeUpload(file) {
      const check = this.uploadList.length < 5;
      if (!check) {
        this.$Notice.warning({
          title: '上传的文件不得超过5张.',
        });
        return false;
      }
      let exist = false;
      this.uploadList.forEach((item) => {
        if (item.name === file.name) {
          exist = true;
        }
      });
      if (exist) {
        this.$Notice.error({
          title: '文件重复了!',
        });
        return false;
      }
      this.uploadList.push(file);
      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onloadend = () => {
        this.imageList.push(reader.result);
      };
      return false;
    },
    handleMaxSize(file) {
      this.$Notice.warning({
        title: '文件大小限制',
        desc: `文件${file.name}太大了,单个文件不允许超过4M`,
      });
    },
    handleView(url) {
      this.imgURL = url;
      this.visible = true;
    },
    // 移除某一张图片
    handleRemove(index) {
      this.uploadList.splice(index, 1);
      this.imageList.splice(index, 1);
      this.$Notice.info({
        title: '移除一张图片',
      });
    },
    // 确定上传
    confirmUpload() {
      if (this.uploadList.length === 0) {
        this.$Notice.error({
          title: '还没有可上传的文件!',
        });
        return;
      }
      this.uploadList.forEach((file) => {
        this.$refs.upload.post(file);
      });
      this.$Notice.success({
        title: '上传成功!',
      });
      this.imageList.splice(0, this.imageList.length);
      this.uploadList.splice(0, this.uploadList.length);
    },
    handleSuccess(res) {
      console.log(res.url);
    },
  },
};
</script>

看了上传的演示,读者是否发现代码存在一个bug?我删除的一张图片却出现到了成功上传的文件夹里,嫦娥这张图片却没有上传!导致这个问题的原因是FileReader读取文件是一个异步操作,使用它就无法保证上传的文件列表和展示图片都是同一个数组下标,在workers里可以使用FileReaderSync以同步的方式读取File或者Blob对象中的内容,只是主线程里进行同步I/O操作可能会阻塞用户界面,可以考虑用URL.createObjectURL()

this.imageList.push(window.URL.createObjectURL(file));

3.服务器端上传到七牛云

服务器端上传到七牛仅指服务器有这样的需求才执行的代码,不建议从前端上传到服务器的临时文件夹然后再传输到七牛云存储,读写文件的操作确实有点耗时。如果你非要那么做,可以用前面的后端路由代码结合下面的示例来实现。

首先服务器读取特定文件夹的文件,获取文件名去生成token,然后调用Node.js SDK的表单上传组件。前端上传时需要指定上传域名,后端上传的时候变成了配置参数,我的bucket属于华南区,配置就是qiniu.zone.Zone_z2 。上传完毕之后把相应的文件删除掉,可以节省空间。读写、删除的操作最好加个try/catch防止异常。

// 读取某个目录下的所有文件,不包含递归方式,也没有判断路径不存在的时候
function realLocalFiles(dir) {
    const fileList = []
    const files = fs.readdirSync(dir)
    files.forEach(file => {
        console.log(file)
        fileList.push(file)
    })
    return fileList
}

// 服务器端上传到七牛
function formUploadPut(fileName, file) {
    const uploadToken = getQiniuTokenWithName(fileName)  // 生成token这个方法调用了好多次
    const config = new qiniu.conf.Config()
    config.zone = qiniu.zone.Zone_z2  // 根据你的上传空间选择zone对象
    const formUploader = new qiniu.form_up.FormUploader(config)
    const putExtra = new qiniu.form_up.PutExtra()
    return new Promise((resolve, reject) => {
        formUploader.putFile(uploadToken, fileName, file, putExtra, (respErr,
            respBody, respInfo) => {
            if (respErr) {
              reject(respErr)
            }
            if (respInfo.statusCode == 200) {
              resolve(respBody)
            } else {
              console.log(respInfo.statusCode);
              resolve(respBody)
            }
          })
    })   
}

// 由服务器上传到七牛云,这是路由
qiniuRouter.get('/serversidepost', async(ctx, next) => {
    const currentPath = '../public/uploads/' + yearMonthDay()
    const fileFoldPath = path.resolve(__dirname, currentPath) // 图片所在的路径
    const fileList = realLocalFiles(fileFoldPath)
    fileList.forEach(async(file) => {
        const filePath = fileFoldPath +'/' +  file  // 实现上传的函数只须文件名和文件路径
        const resInfo = await formUploadPut(file, filePath)
        console.log(resInfo)
        const fileLink = qiniuConfig.domain  + '/' + file
        console.log(fileLink)
        removeOnefile(filePath)
    })
    ctx.body = {
        fileList
    }
})
// 删除已经上传了的图片
function removeOnefile(path) {
    fs.unlink(path, (err) => {
        if (err) {
          throw err
        }
        console.log('文件删除成功')
    })    
}

因为没有前端上传的参与,读者使用我的代码可以使用http://localhost:8080/server 先上传几张图片到某个文件夹,比如是20190913,然后在访问服务器请求地址: http://localhost:5000/api/qiniu/serversidepost 它将返回图片的文件名列表,并且完成上传到七牛云、删除本地文件的操作。命令行输出如下:

luna

fs.unlink 是异步操作,需要等上传完毕再删除。然后你们可以欣赏一下紫霞仙子小姐姐的风韵~~~

qiniu.hackslog.cn/15683981411…

https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/9/14/16d2ff01fb0d7576~tplv-t2oaga2asx-image.image

4.小程序上传文件

4.1 直接上传

小程序上传文件的场景比如在政府服务的应用上传身份证、一些电商应用发布图片等。目前普遍使用了别人封装好的轮子:qiniu-wxapp-sdk ,如果自己要实现的话,主要用到wx.request来获取token,wx.chooseImage选择文件,wx.uploadFile 上传文件,因为小程序文件是有个临时路径的,我就不玩自定义文件名了,读者可以自己玩(๑′ᴗ‵๑)

想象一下点击选择文件后wx.chooseImage返回临时文件列表,当点击上传按钮之后应用从服务器中拿到token,然后在使用wx.uploadFile上传。wx.request是个异步操作,让他们顺序执行可以把处理逻辑放在回调中,更好的方式是使用async/await,不过小程序不是天然支持,可以引入第三方的包,比如facebook/regenerator ,我们只需要用runtime.js这个文件。不过我暂且用onLoad获取共用的token

  onLoad() {
	this.fetchToken()
  },
  async fetchToken() {
    const res = await http('GET', tokenURL, {})
    const token = res.data.token
    console.log(token) 
    this.setData({
      token
    })
  },
  http(method, url, data) {
      return new Promise((resolve, reject) => {
        wx.request({
          url,
          data,
          method,
          success(res) {
            resolve(res)
          },
          fail() {
            reject({
              msg: `${url}请求错误`
            })
          }
        })
      }) 
   },
   // 七牛上传逻辑
   qiniuUpload() {
    const that = this
    if (content.trim() === '') {
      wx.showModal({
        title: '请输入内容',
        content: '',
      })
      return
    }

    wx.showLoading({
      title: '发布中',
      mask: true,
    })    
    let promiseArr = []
    let fileList = []  // 记录上传成功后的图片地址
    for (let i = 0, len = this.data.images.length; i < len; i++) {
      let p = new Promise((resolve, reject) => {
        let item = this.data.images[i]
        wx.uploadFile({
          url: qiniuPostURL, // 华南区的是http://upload-z2.qiniup.com
          filePath: item,
          name: 'file',
          formData: {
            token: this.data.token
          },
          success(res) {
            const data = JSON.parse(res.data) // 这是重点!!!
            console.log(data)
            const key = data['key']
            console.log(key)
            console.log(accessURL)  // 这是加速域名,我的是http://qiniu.hackslog.cn
            const link = accessURL + '/' + key
            console.log(link)
            fileList = fileList.concat(link)
            resolve()
          },
          fail: (err) => {
            console.error(err)
            reject()
          }
        })
      })
      promiseArr.push(p)    
    }
    Promise.all(promiseArr).then((res) => {
      console.log(res)
      console.log(fileList)
      app.globalData.imgList = fileList
      wx.hideLoading()
      wx.showToast({
        title: '发布成功',
        icon: 'success',
        duration: 2000
      })
      console.log('上传完毕了')
      wx.navigateTo({
        url: '/pages/display/index',
      })
    }).catch((err) => {
      wx.hideLoading()
      wx.showToast({
        title: '发布失败',
      })
    })    
  },

我又处于调bug状态了...调用wx.uploadFile出现了个错误:name属性不是随便调的,因为内部调用的是FormData, name其实是指定文件所属的键名,七牛上传指定的就是file;success的回调拿不到res.data.key,在axios里面是自动转换的,小程序里还需要自己JSON.parse()一下。

展示下成果:

shuoshuo

相关代码

4.2 云存储

小程序云开发在经过逐步完善,已经开始支持HTTP API的操作了,也就是说可以实现不购置服务器的情况下完成小程序和Web管理控制台,非常有吸引力。云储存 提供了很方便的API让小程序直接调用函数上传图片,还可以在云开发控制台手动上传,本质来说也是一个对象存储(多副本)。与之配合的有云函数可以完成一些数据处理工作、数据库有很多貌似MongoDB的API,前端的工作越来越重了/(ㄒoㄒ)/~~

mini_upload

这里引用谢成老师 的代码,模拟发布朋友圈的过程,实现主要使用了wx.chooseImagewx.cloud.uploadFile 两个API。手动上传过程可能会增加、删除部分图片,需要使用数组暂存数据;真机使用时聚焦会抬起键盘,需要把下面的发布按钮的定位调整一下;wx.cloud.uploadFile上传参数filePath字段可以通过wx.chooseImage获取,cloudPath设置存储路径,可以直接字段拼接不用再自己new File了,填了前缀会在存储控制台多出一个文件夹;等待每个文件都上传完之后再跳转到展示页面,可以用Promise.all()方法。

// index.wxml
<view class="container">
  <textarea class="content" placeholder="这一刻的想法..."
    bindinput="onInput" auto-focus
    bindfocus="onFocus" bindblur="onBlur"
  ></textarea>

  <view class="image-list">
    <!-- 显示图片 -->
    <block wx:for="{{images}}" wx:key="*this">
      <view class="image-wrap">
        <image class="image" src="{{item}}" mode="aspectFill" bind:tap="onPreviewImage" data-imgsrc="{{item}}"></image>
        <i class="iconfont icon-chuyidong" bind:tap="onDelImage" data-index="{{index}}"></i>
      </view>
    </block>

    <!-- 选择图片 -->
    <view class="image-wrap selectphoto" hidden="{{!selectPhoto}}" bind:tap="onChooseImage">
      <i class="iconfont icon-addTodo-nav"></i>
    </view>
  
  </view>
</view>

<view class="footer" style="bottom:{{footerBottom}}px">
  <text class="words-num">{{wordsNum}}</text>
  <button class="send-btn" bind:tap="upload">发布</button>
</view>

// index.js
var app = getApp();
// 最大上传图片数量
const MAX_IMG_NUM = 9
// 输入的文字内容
let content = ''
let userInfo = {}
Page({

  /**
   * 页面的初始数据
   */
  data: {
    // 输入的文字个数
    wordsNum: 0,
    footerBottom: 0,
    images: [],
    selectPhoto: true, // 添加图片元素是否显示
    imagePaths: []
  },
  // 文字输入
  onInput(event) {
    let wordsNum = event.detail.value.length
    this.setData({
      wordsNum
    })
    content = event.detail.value
  },

  onFocus(event) {
    // 模拟器获取的键盘高度为0
    // console.log(event)
    this.setData({
      footerBottom: event.detail.height,
    })
  },
  onBlur() {
    this.setData({
      footerBottom: 0,
    })
  },

  onChooseImage() {
    // 还能再选几张图片
    let max = MAX_IMG_NUM - this.data.images.length
    wx.chooseImage({
      count: max,
      sizeType: ['original', 'compressed'],
      sourceType: ['album', 'camera'],
      success: (res) => {
        console.log(res)
        this.setData({
          images: this.data.images.concat(res.tempFilePaths)
        })
        // 还能再选几张图片
        max = MAX_IMG_NUM - this.data.images.length
        this.setData({
          selectPhoto: max <= 0 ? false : true
        })
      },
    })
  },
  // 删除图片
  onDelImage(event) {
    this.data.images.splice(event.target.dataset.index, 1)
    this.setData({
      images: this.data.images
    })
    if (this.data.images.length == MAX_IMG_NUM - 1) {
      this.setData({
        selectPhoto: true,
      })
    }
  },

  onPreviewImage(event) {
    // 6/9
    wx.previewImage({
      urls: this.data.images,
      current: event.target.dataset.imgsrc,
    })
  },
  upload() {
    const that = this
    if (content.trim() === '') {
      wx.showModal({
        title: '请输入内容',
        content: '',
      })
      return
    }

    wx.showLoading({
      title: '发布中',
      mask: true,
    })

    let promiseArr = []
    let fileIds = []
    // 图片上传
    for (let i = 0, len = this.data.images.length; i < len; i++) {
      let p = new Promise((resolve, reject) => {
        let item = this.data.images[i]
        // 文件扩展名
        let suffix = /\.\w+$/.exec(item)[0]
        wx.cloud.uploadFile({
          cloudPath: 'code/' + Date.now() + '-' + Math.random() * 1000000 + suffix,
          filePath: item,
          success: (res) => {
            console.log(res.fileID)
            fileIds = fileIds.concat(res.fileID)
            resolve()
          },
          fail: (err) => {
            console.error(err)
            reject()
          }
        })
      })
      promiseArr.push(p)
    }
    // 全部上传完之后跳转到展示页面
    Promise.all(promiseArr).then((res) => {
      console.log(res)
      console.log(fileIds)
      app.globalData.imgList = fileIds
      wx.hideLoading()
      wx.showToast({
        title: '发布成功',
        icon: 'success',
        duration: 2000        
      })
      console.log('上传完毕了')
      wx.navigateTo({
        url: '/pages/display/index',
      })
    }).catch((err) => {
      wx.hideLoading()
      wx.showToast({
        title: '发布失败',
      })
    })
  },
})

嘿嘿,完结了!

参考链接:

new File-MDN

File对象, FileList对象,FileReader对象

FormData-MDN

iView + axios实现图片预览及单请求批量上传

限制上传文件的大小

七牛云上传图片,从前端到后端

koa-body

图片上传的需求各种各样,这里只介绍了基本的上传方式,可以预见的需求比如上传裁剪、断点续传、分片上传、剪贴上传等等需要去补充和实现,我会持续地更新。如果有更复杂的业务和需求、如果我写的有疏漏有bug,欢迎留言交流,我会尽可能完善这篇文章,解决大家的上传之痛。

如果觉得好,来个star好吧ヽ( ̄▽ ̄)ノ