在微信小程序中使用 wx.request + FormData 上传图片

2,088 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第7天,点击查看活动详情

最近在开发小程序的过程中,使用了 Vant Uploader 组件,同时使用 wx.request 批量上传图片。在此将趟过的过程做个记录

前期准备

相关内容简介

MinIO 图床相关

minIO 的搭建请参考文章:使用Docker搭建MinIO私有图库并提供API调用: https://juejin.cn/post/7176544729130074172。主要是通过 Java SDK 实现自定义的 API。方便用户使用图床进行图片的存储。

// 最终使用 API 请求上传图片,java 后端端口 40002
URL: http://xxx:40002/minio/upload
FormData: file=xxx

介绍 wx.request

文档地址developers.weixin.qq.com/miniprogram…

功能描述:发起 HTTPS 网络请求。

示例代码 如下

从描述和代码上来看,接口比较简单。但是想要使用它上传图片的时候,我们经常使用的 FormData 发现不灵了,微信本身没有 FormData 对象,所以无法使用 new FormData()

wx.request({
  url: 'example.php', //仅为示例,并非真实的接口地址
  data: {
    x: '',
    y: ''
  },
  header: {
    'content-type': 'application/json' // 默认值
  },
  success (res) {
    console.log(res.data)
  }
})

van-uploader 组件

文档地址vant-contrib.gitee.io/vant-weapp/…

功能描述:用于将本地的图片或文件上传至服务器,并在上传过程中展示预览图和上传进度。目前 Uploader 组件不包含将文件上传至服务器的接口逻辑,该步骤需要自行实现。

<!-- wxml 界面代码 -->
<van-uploader
  file-list="{{ fileList }}"
  accept="media"
  use-before-read
  mutiple="true"
  bind:before-read="beforeRead"
  bind:after-read="afterRead"
/>

js 代码如下,代码看起来,还是比较简单的:

Page({
  data: {
    fileList: [],
  },
  beforeRead(event) {
    const { file, callback } = event.detail;
    callback(file.type === 'image');
  },
  afterRead(event) {
    // 当设置 mutiple 为 true 时, file 为数组格式,否则为对象格式
    const { file } = event.detail;
  }
});

编写代码

代码思路

  • 使用 MinIO 搭建好图床之后,还不太够,需要生成 Access Keys ,并且使用代码中提供的后端代码,生成 jar 包并运行,确保可以使用 api 上传图片
  • 微信小程序引入 vant-uploader 组件,按照官方的推荐方式引入即可,官网地址: https://vant-contrib.gitee.io/vant-weapp/#/quickstart
  • 使用 wx.request + formData 上传图片

部分代码实现

  • vant-uploader 组件使用 npm 的方式引入

    # 在小程序路径 miniprogram 目录下运行
    npm i @vant/weapp -S --production
    ​
    # 工具 -> 构建 npm
    # 等待完成即可
    
  • 创建小程序页面 image,并在 image.json 中引入 vant-uploader

    ...
    "usingComponents": {
      ...
      "van-uploader": "@vant/weapp/uploader/index",
      "van-button": "@vant/weapp/button/index"
    }
    
  • image.wxml 页面添加 vant 组件

    <van-uploader accept="image" multiple="true" file-list="{{ photoList }}" bind:after-read="photoAfterRead" /><!-- 也可以用其他的按钮 -->
    <van-button type="primary" block round bind:click="btnSaveClick">保存</van-button>
    
  • image.js 中完成图片的上传

    // pages/add/index.js
    import Dialog from '@vant/weapp/dialog/dialog';
    const db = wx.cloud.database()
    const app = getApp()
    const FormData = require('../../utils/formData')
    ​
    Page({
    ​
      /**
       * 页面的初始数据
       */
      data: {
        // 图片
        photoList: [],
      },
    ​
      photoAfterRead(event) {
        const _this = this
        const {
          file
        } = event.detail;
    ​
        let allPhoto = _this.data.photoList
        allPhoto = [...allPhoto, ...file]
    ​
        _this.setData({
          photoList: allPhoto
        })
      },
    ​
      btnSaveClick() {
        const _this = this
       
        wx.showLoading({
          title: '正在上传图片',
          mask: true,
        })
    ​
        // 准备上传图片
        _this.uploadToCloud(_this.data.photoList).then((allPhotoUrls) => {
          wx.hideLoading()
        }).catch((e) => {
          wx.hideLoading()
    ​
          wx.showToast({
            title: '图片上传异常了' + JSON.stringify(e),
            icon: 'none'
          });
        })
      },
    ​
      // 上传图片
      uploadToCloud(fileList) {
        return new Promise((resolve, reject) => {
          if (!fileList.length) {
            wx.showToast({
              title: '请选择图片',
              icon: 'none'
            });
    ​
            reject(null)
          } else {
            const uploadTasks = fileList.map((file, index) => this.uploadFilePromise(`cook-photo-${new Date().getTime()}-${index}.png`, file));
            Promise.all(uploadTasks)
              .then(data => {
                wx.showToast({
                  title: '上传成功',
                  icon: 'none'
                });
                resolve(data)
              })
              .catch(e => {
                wx.showToast({
                  title: '上传失败',
                  icon: 'none'
                });
                console.log(e);
                reject(null)
              });
          }
        })
      },
    ​
      uploadFilePromise(name, file) {
        return new Promise((resolve, reject) => {
          // ⚠️ 此处使用到了 FormData,微信小程序本身是不支持 FormData 的,需要我们自己定义
          const formData = new FormData()
          const fileBuff = formData.getBuffByPath(file.url)
          formData.appendFile('file', fileBuff, file.url)
          const data = formData.getData()
          
          // 使用 wx.request,在体验版里打开调试模式就可以使用 http 请求完成上传
          wx.request({
            url: "http://ip:40002/minio/upload",
            header: {
              'accept': 'application/json',
              'content-type': data.contentType,
            },
            method: 'POST',
            data: data.buffer,
            success(res) {
              if (res.data.success) {
                resolve(res.data.data.url)
              } else {
                reject('错误了')
              }
            },
            fail(e) {
              Dialog.alert({
                message: `请打开调试模式: ${JSON.stringify(e)}`,
              }).then(() => {
                reject('异常了')
              })
            }
          })
      },
      ...
    })
    
  • 定义 FormData,以支持上方请求

    // miniprogram/utils 目录下// minprogram/utils/formData.js
    const mimeMap = require('./mimeMap.js')
    const fileManager = wx.getFileSystemManager()
    ​
    function FormData() {
      let data = {}
      let files = []
    ​
      this.appendFile = (name, buffer, path) => {
        files.push({
          name: name,
          buffer: buffer,
          fileName: getFileNameFromPath(path)
        })
        return true
      }
    ​
      this.getData = () => convert(data, files)
    ​
      /**
       * 根据文件路径获取文件 buff
       * @param {*} filePath 
       */
      this.getBuffByPath = (filePath) => {
        return fileManager.readFileSync(filePath)
      }
    }
    ​
    function getFileNameFromPath(path) {
      let idx = path.lastIndexOf('/')
      return path.substr(idx + 1)
    }
    ​
    function convert(data, files) {
      let boundaryKey = 'wxmpFormBoundary' + randString() // 数据分割符,一般是随机的字符串
      let boundary = '--' + boundaryKey
      let endBoundary = boundary + '--'
    ​
      let postArray = []
      //拼接参数
      if (data && Object.prototype.toString.call(data) == '[object Object]') {
        for (let key in data) {
          postArray = postArray.concat(formDataArray(boundary, key, data[key]))
        }
      }
      //拼接文件
      if (files && Object.prototype.toString.call(files) == '[object Array]') {
        for (let i in files) {
          let file = files[i]
          postArray = postArray.concat(
            formDataArray(boundary, file.name, file.buffer, file.fileName)
          )
        }
      }
      //结尾
      let endBoundaryArray = []
      for (var i = 0; i < endBoundary.length; i++) {
        // 最后取出结束boundary的charCode
        endBoundaryArray.push(...endBoundary.utf8CodeAt(i))
      }
      postArray = postArray.concat(endBoundaryArray)
      return {
        contentType: 'multipart/form-data; boundary=' + boundaryKey,
        buffer: new Uint8Array(postArray).buffer
      }
    }
    ​
    function randString() {
      let res = ''
      for (let i = 0; i < 17; i++) {
        let n = parseInt(Math.random() * 62)
        if (n <= 9) {
          res += n
        } else if (n <= 35) {
          res += String.fromCharCode(n + 55)
        } else {
          res += String.fromCharCode(n + 61)
        }
      }
      return res
    }
    ​
    function formDataArray(boundary, name, value, fileName) {
      let dataString = ''
      let isFile = !!fileName
    ​
      dataString += boundary + '\r\n'
      dataString += 'Content-Disposition: form-data; name="' + name + '"'
      if (isFile) {
        dataString += '; filename="' + fileName + '"' + '\r\n'
        // 此处可以根据不同的文件名称来配置不同的 mime,例如 image/jpeg 等
        dataString += 'Content-Type: application/octet-stream\r\n\r\n'
      } else {
        dataString += '\r\n\r\n'
        dataString += value
      }
    ​
      var dataArray = []
      for (var i = 0; i < dataString.length; i++) {
        // 取出文本的charCode(10进制)
        dataArray.push(...dataString.utf8CodeAt(i))
      }
    ​
      if (isFile) {
        let fileArray = new Uint8Array(value)
        dataArray = dataArray.concat(Array.prototype.slice.call(fileArray))
      }
      dataArray.push(...'\r'.utf8CodeAt())
      dataArray.push(...'\n'.utf8CodeAt())
    ​
      return dataArray
    }
    ​
    String.prototype.utf8CodeAt = function (i) {
      var str = this
      var out = [],
        p = 0
      var c = str.charCodeAt(i)
      if (c < 128) {
        out[p++] = c
      } else if (c < 2048) {
        out[p++] = (c >> 6) | 192
        out[p++] = (c & 63) | 128
      } else if (
        (c & 0xfc00) == 0xd800 &&
        i + 1 < str.length &&
        (str.charCodeAt(i + 1) & 0xfc00) == 0xdc00
      ) {
        // Surrogate Pair
        c = 0x10000 + ((c & 0x03ff) << 10) + (str.charCodeAt(++i) & 0x03ff)
        out[p++] = (c >> 18) | 240
        out[p++] = ((c >> 12) & 63) | 128
        out[p++] = ((c >> 6) & 63) | 128
        out[p++] = (c & 63) | 128
      } else {
        out[p++] = (c >> 12) | 224
        out[p++] = ((c >> 6) & 63) | 128
        out[p++] = (c & 63) | 128
      }
      return out
    }
    ​
    module.exports = FormData
    

注意事项

  • 使用 http 的时候,需要在 详情 -> 本地设置 -> 勾选 不校验合法域名... 进行开发
  • 提交为体验版后,需要在手机上点击右上角 ··· ,打开调试模式,即可享用~