业务场景

微信端项目是基于Vux + Axios构建的,关于图片上传的业务场景有以下几点需求:

1、单张图片上传(如个人头像,实名认证等业务)
2、多张图片上传(如某类工单记录)
3、上传图片时期望能按指定尺寸压缩处理
4、上传图片可以从相册中选择或者直接拍照

遇到的坑

采用微信JSSDK上传图片

在之前开发的项目中(mui + jquery),有使用过微信JSSDK的接口上传图片,本想应该能快速迁移至此项目。事实证明编程没有简单的事:
1、按指定尺寸压缩图片
JSSDK提供的接口wx.chooseImage 是不能指定图片压缩尺寸的,只能在后端的接口通过localId获取图片时,再转换成指定的尺寸。
2、微信JSSDK的接口权限验证
只要是单页面应用项目,微信JSSDK注入权限验证都会有这个坑,而这个与路由模式(hash 或 history)也有关联。有关此坑, 后续会再次写文总结。参考解决方案[微信JSSDK] 解决SDK注入权限验证 安卓正常,IOS出现config fail
经过权衡考虑网页可能需要在微信以外的浏览器上也能上传文件,顾后来放弃了采用微信JSSDK接口上传图片的方式。

android版微信,input onchange事件不触发

这个坑,圈内有很多人踩过了。在PC端测试是正常的,发布之后,微信端上传时能选择文件,但之后没有任何效果。日志跟踪,后台的api都未调用,由此判断是input的onchange事件未被触发。
解决方案, 更改input的 accept属性:

<input ref="file" type="file" accept="image/jpeg,image/png" @change="selectImgs" />

将以上代码更改为:

<input ref="file" type="file" accept="image/*" @change="selectImgs" />

如果不允许从相册中选择,只能拍照,增加capture="camera":

<input ref="file" type="file" accept="image/*" capture="camera" @change="selectImgs" />

(注:如果场景支持从相册选择或拍照,测试发现某些机型拍照后返回到了主页。哈哈,也有可能是其他因素引起的问题,未做深究了)

使用Lrz.js压缩图片

目前手机拍照的图片文件大小一般在3-4M,如果在上传时不做压缩处理会相当浪费流量并且占用服务器的存储空间(期望上传原图的另做讨论)。如果能够在前端压缩处理,那肯定是最理想的方案。而lrz.js则提供了前端图片文件的压缩方案,并且可以指定尺寸压缩。实测:3M左右的图片文件,按宽度450px尺寸压缩上传后的文件大小在500kb左右,上传时间2s以内。
其核心源码,如下:

selectImgs () {
  let file = this.$refs.file.files[0]
  lrz(file, { width: 450, fieldName: 'file' }).then((rst) => {
    var xhr = new XMLHttpRequest()
    xhr.open('POST', 'http://xxx.com/upload')

    xhr.onload = () => {
      if (xhr.status === 200 || xhr.status === 304) {
        // 无论后端抛出何种错误,都会走这里
        try {
          // 如果后端跑异常,则能解析成功, 否则解析不成功
          let resp = JSON.parse(xhr.responseText)
          console.log('response: ', resp)
        } catch (e) {
          this.imageUrl = xhr.responseText
        }
      }
    }

    // 添加参数
    rst.formData.append('folder', 'wxAvatar') // 保存的文件夹
    rst.formData.append('base64', rst.base64)
    // 触发上传
    xhr.send(rst.formData)

    return rst
  })
}

单个图片上传组件完整代码,如下(注: icon图标使用的是svg-icon组件):

<template>
  <div class="imgUploader">
    <section v-if="imageUrl"
             class="file-item ">
      <img :src="imageUrl"
           alt="">
      <span class="file-remove"
            @click="remove()">+</span>
    </section>
    <section v-else
             class="file-item">
      <div class="add">
        <svg-icon v-if="!text"
                  class="icon"
                  icon-class="plus" />
        <span v-if="text"
              class="text">{{text}}</span>
        <input type="file"
               accept="image/*"
               @change="selectImgs"
               ref="file">
      </div>
    </section>
  </div>
</template>

<script>
import lrz from 'lrz'
export default {
  props: {
    text: String,
    // 压缩尺寸,默认宽度为450px
    size: {
      type: Number,
      default: 450
    }
  },
  data () {
    return {
      img: {
        name: '',
        src: ''
      },
      uploadUrl:  'http://ff-ff.xxx.cn/UploaderV2/Base64FileUpload',
      imageUrl: ''
    }
  },
  watch: {
    imageUrl (val, oldVal) {
      this.$emit('input', val)
    },
    value (val) {
      this.imageUrl = val
    }
  },
  mounted () {
    this.imageUrl = this.value
  },
  methods: {
    // 选择图片
    selectImgs () {
      let file = this.$refs.file.files[0]
      lrz(file, { width: this.size, fieldName: 'file' }).then((rst) => {
        var xhr = new XMLHttpRequest()
        xhr.open('POST', this.uploadUrl)

        xhr.onload = () => {
          if (xhr.status === 200 || xhr.status === 304) {
            // 无论后端抛出何种错误,都会走这里
            try {
              // 如果后端跑异常,则能解析成功, 否则解析不成功
              let resp = JSON.parse(xhr.responseText)
              console.log('response: ', resp)
            } catch (e) {
              this.imageUrl = xhr.responseText
            }
          }
        }

        // 添加参数
        rst.formData.append('folder', this.folder) // 保存的文件夹
        rst.formData.append('base64', rst.base64)
        // 触发上传
        xhr.send(rst.formData)

        return rst
      })
    },
    // 移除图片
    remove () {
      this.imageUrl = ''
    }
  }
}
</script>

<style lang="less" scoped>
.imgUploader {
  margin-top: 0.5rem;
  .file-item {
    float: left;
    position: relative;
    width: 100px;
    text-align: center;
    left: 2rem;
    img {
      width: 100px;
      height: 100px;
      border: 1px solid #ececec;
    }
    .file-remove {
      position: absolute;
      right: 0px;
      top: 4px;
      width: 14px;
      height: 14px;
      color: white;
      cursor: pointer;
      line-height: 12px;
      border-radius: 100%;
      transform: rotate(45deg);
      background: rgba(0, 0, 0, 0.5);
    }

    &:hover .file-remove {
      display: inline;
    }
    .file-name {
      margin: 0;
      height: 40px;
      word-break: break-all;
      font-size: 14px;
      overflow: hidden;
      text-overflow: ellipsis;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
    }
  }
  .add {
    width: 100px;
    height: 100px;
    float: left;
    text-align: center;
    line-height: 100px;
    font-size: 30px;
    cursor: pointer;
    border: 1px dashed #40c2da;
    color: #40c2da;
    position: relative;
    background: #ffffff;
    .icon {
      font-size: 1.4rem;
      color: #7dd2d9;
      vertical-align: -0.25rem;
    }
    .text {
      font-size: 1.2rem;
      color: #7dd2d9;
      vertical-align: 0.25rem;
    }
  }
}
input[type="file"] {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border: 1px solid #000;
  opacity: 0;
}
</style>

后端图片存储处理

后端api对图片的处理,是必不可少的环节,需要将前端提交过来的base64字符串转换成图片格式,并存放至指定的文件夹,接口返回图片的Url路径。各项目后端对图片的处理逻辑都不一致,以下方案仅供参考(我们使用asp.net MVC 构建了独立的文件存储站点)。
其核心源码,如下:

/// <summary>
/// 图片文件base64上传
/// </summary>
/// <param name="folder">对应文件夹位置</param>
/// <param name="base64">图片文件base64字符串</param>
/// <returns></returns>
public ActionResult Base64FileUpload(string folder, string base64)
{
    var context = System.Web.HttpContext.Current;
    context.Response.ClearContent();
    // 因为前端调用时,需要做跨域处理
    context.Response.AddHeader("Access-Control-Allow-Origin", "*");
    context.Response.AddHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
    context.Response.AddHeader("Access-Control-Allow-Headers", "content-type");
    context.Response.AddHeader("Access-Control-Max-Age", "30");
    if (context.Request.HttpMethod.Equals("OPTIONS"))
    {
        return Content("");
    }

    var resultStr = base64.Substring(base64.IndexOf(",") + 1);//需要去掉头部信息,这很重要
    byte[] bytes = Convert.FromBase64String(resultStr);
    var fileName = Guid.NewGuid().ToString() + ".png";
    if (folder.IsEmpty()) folder = "folder";
    //本地上传
    string root = string.Format("/Resource/{0}/", folder);
    string virtualPath = root + fileName;
    string path = Server.MapPath("~" + virtualPath);
    //创建文件夹
    if (!Directory.Exists(Path.GetDirectoryName(path)))
    {
        Directory.CreateDirectory(Path.GetDirectoryName(path));
    }
    System.IO.MemoryStream ms = new System.IO.MemoryStream(bytes);//转换成无法调整大小的MemoryStream对象
    System.Drawing.Bitmap bitmap = new System.Drawing.Bitmap(ms);
    bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png);//保存到服务器路径
    ms.Close();//关闭当前流,并释放所有与之关联的资源
    return Content(Net.Url + virtualPath); //返回文件路径
}

结语

由于项目实际情况,上述的方案中还存在诸多未完善的点:
1、多张图片上传,还是采用的与单张图片相同的接口处理, 更为完善的方案是,前端的多图上传组件只绑定一个关联Id,即可通过实现上传和将图片列表查询展示(注:该功能在微信端未实现)。
2、后端图片上传的接口,未做严格的安全校验,更为完善的方案是,每个上传的场景,都应该限制文件类型,限制文件大小,以及文件数据来源校验(注: 如软件需要按二级等保标准测评,则后端接口会检测通不过)。
3、上传组件,未显示上传进度,体验性稍差。
正如前文所述,出于项目实际情况考虑,只是简单实现图片压缩上传功能,如要支持更多的场景,还得细细雕琢。

参考

1、移动端H5实现图片上传
2、安卓版微信 input onchange事件不生效

06-25 07:46