Vue+ts vue-cropper 插件上传头像

背景

  • 前端上传头像的组件要更新一下,之前是自动将图片进行变形,现在要重新改一下,改成前端显示一个剪切框,可以放大和缩小然后再上传头像。
  • 根据老板不造轮子的指示,我上网找了一下轮子,准备用一个叫做 vue-cropper 的轮子。
  • 找到轮子感觉事情就变得很简单了,然而坑比我想象的要多。

如何读入本地图片

  • 首先先看一下之前写的,如何读入本地的图片。
<div class="file is-centered">
	<label class="file-label" style="margin-top:5%">
        <input
            class="file-input"
            type="file"
            accept=".jpg, .png, .gif"
            @change="chooseAvatar($event)"
        />
        <span class="file-cta">
            <span class="file-icon">
                <i class="fa fa-upload"></i>
            </span>
            <span class="file-label">选择图片</span>
        </span>
	</label>
</div>
  • 这就是 bulma 上面 file input 的样式了,由于我之前试了几个小时,也没有让其居中显示,就用了这种粗糙的方式居中了。
  • 和普通的 html 里的 input 一样的参数,简单说,type 和 accept 限定了选择文件的类型。现在关键就是这个函数了,记住嗷,参数是 $event
  • 这个函数 @change 就是在内容变化的时候调用,简单地说,你点这个组件,浏览器会自动跳出来那个让你选择的弹窗,当你选择了一个文件以后,就会调用这个函数了,文件的信息就在 event 里面。
  • 接下来就是在 ts 函数里面把文件搞出来了,代码如下:
private chooseAvatar(event: any) {
    const that = this;
    // 创建一个对象 reader
    const reader = new FileReader();
    // 设置 reader 读取完成以后的操作
    reader.onload = function() {
        // this.result 即为图片内容,赋给 avatarUrl 即可在前端读出来
    	that.avatarUrl = this.result;
    	that.$nextTick(function() {
            if (
                event.target.files[0].type == "image/png" ||
                event.target.files[0].type == "image/jpeg"
            ) {
                that.ifUseCropper = true;
            } else {
                that.ifUseCropper = false;
             }
		})
    };
    // 说实话,我不知道这句话在干啥,但是没有这句话,会出现显示bug,先丢在这吧
    let imagSrc = window.URL.createObjectURL(event.target.files[0]);
    // event.target.files[0] 就是文件了,这一步就是读取这个文件
    // 读取完了以后就会执行上面 onload 里面的 function
    reader.readAsDataURL(event.target.files[0]);
    // iamgeFile 是我之后要发送给后台的,我们后端程序猿设计的发送 file 文件
    this.imageFile = event.target.files[0];
}
  • 由于第一版代码没存,这是用了 vue-cropper 的代码,后面那个 if 就是用来判断文件类型的,因为这个插件只能处理 png、jpg,我们的二次元后端又要上传一些 gif 动图,所以我就分开来处理,判断一下类型,就是 ifUseCropper 这个变量了。
  • 整个关于读取图片的步骤已经在上面标注了,其实非常简单。

关于 vue-cropper

  • 看一下这个组件的 github 上的 readme 就好了,但是我感觉那里面列出的传入参数默认值好像有的不太对,没有深究,发现不对手动赋值进去就好了。
  • 首先关于安装就有一个坑,npm install 了以后,由于 js 和 ts 的原因,import 不识别报错,上网查了一下,要在 src/index.d.ts 下面加上这句话:
declare module 'vue-cropper';
  • 然后是 html 上的使用方法了,和所以 vue 组件使用一样,import 然后再 component 里面声明,就可以使用了,使用代码如下:
<div class="vueCropper" v-else>
    <VueCropper
        ref="cropper"
        :img="avatarUrl"
        outputType="png"
        :autoCrop="true"
        :fixedNumber="[1, 1]"
        :fixed="true"
    ></VueCropper>
</div>
.vueCropper {
  width: 100%;
  height: 128px;
}
  • 省略了上面 v-if=“ifUseCropper” 部分的代码,我们直接看怎么用,首先,VueCropper 外面必须加一个有长和搞的 div,这个就是外面的剪切框,然后要给赋进去一些变量,官网有介绍
  • 特别注意的是要有一个 ref,之前写过的,这是为了后面调用这个组件里面的函数。官网上也有写,后面可以调用两种函数,分别得到剪切完成图片的 blob 或者 base64

blob、base64、file

  • 问题来了,我们传给后端的要是一个 file 啊,就是上面那个 event.target.files[0],结果只能获得 blob 或者 base64。
  • 用最简单的话解释一下这三个类型,base64 是字符串,可以和 url 一样被 img 渲染出来,blob 是一个特殊类型,file也是一个特殊类,就是html input type=“file” 得到的东西。
  • 上网搜了老半天,抓着后端程序猿一起调,才搞定,先给代码:
private confirmAvatar() {
    const that = this;
    const formateData = new FormData();
    if (this.ifUseCropper) {
      (this.$refs.cropper as any).getCropBlob(function(data: any) {
        let files = new File([data], "avatar.png", {type: "image/png"});
        that.imageFile = files;
        formateData.append("files", files);
        that.postToDatabase(formateData);
      });
    } else {
      formateData.append("files", this.imageFile);
      this.postToDatabase(formateData);
    }
}
  • 先说一个与坑无关的知识,后台经常有个术语叫表单提交,说的就是这个 FormData,用法很简单啦,就和字典差不多,往里面 append 就行了,一看就会。
  • 首先明确,我们要得到的是 blob,然后就是用 blob 创建一个 file,用到了构造函数。第一个参数必须要有中括号,第二个参数是这个文件的名称,有坑,名字必须加上后缀嗷,那个 .png 很致命,后面那个参数是文件的类型。
  • 然后还是报错,我找了后台程序猿看,结果他说改了读取方法,直接获取数据啥的,咱也不知道,反正就是后台也有坑,可以把锅甩给后台。
  • 然后就是老生常谈的异步的问题了,那个 post 要写在里面,不然还没等给 formteData 加数据,就已经 post 了。

总结

  • 至此,咱们终于可以剪图片了。上面代码都是摘出来的,为了突出重点。下面是整个组件的代码,是一个上传图片的弹窗,可以传要上传图片的 API,也可以当一个组件了,有需要可以直接嫖走(我们再 main 里面 import 了 bulma,所以这个没有引用)。
<template>
  <div class="modal is-active">
    <div class="modal-background"></div>
    <div class="modal-card">
      <header class="modal-card-head">
        <p class="modal-card-title">{{title}}</p>
      </header>
      <section class="modal-card-body">
        <center v-if="!ifUseCropper">
          <figure class="image is-128x128">
            <img class="is-rounded" :src="avatarUrl" style="width:128px;height:128px" />
          </figure>
        </center>
        <div class="vueCropper" v-else>
          <VueCropper
            ref="cropper"
            :img="avatarUrl"
            outputType="png"
            :autoCrop="true"
            :fixedNumber="[1, 1]"
            :fixed="true"
            autoCropWidth="128"
          ></VueCropper>
        </div>
        <div class="file is-centered">
          <label class="file-label" style="margin-top:5%">
            <input
              class="file-input"
              type="file"
              accept=".jpg, .png, .gif"
              @change="chooseAvatar($event)"
            />
            <span class="file-cta">
              <span class="file-icon">
                <i class="fa fa-upload"></i>
              </span>
              <span class="file-label">选择图片</span>
            </span>
          </label>
        </div>
      </section>
      <footer class="modal-card-foot">
        <button class="button is-success" @click="confirmAvatar()">保存修改</button>
        <button class="button" @click="cancelAvatar()">取消修改</button>
      </footer>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import Axios from "axios";
import { VueCropper } from "vue-cropper";

@Component({
  components: {
    VueCropper
  }
})
export default class UserChangeInformation extends Vue {
  @Prop() private avatarUrl!: any;
  @Prop() private src!: string;
  @Prop() private title!: string;
  @Prop() private projectId!: string;
  private imageFile: any = {};
  private ifUseCropper = false;

  private cancelAvatar() {
    this.$emit("closeAvatar");
  }
  private confirmAvatar() {
    const that = this;
    const formateData = new FormData();
    if (this.ifUseCropper) {
      (this.$refs.cropper as any).getCropBlob(function(data: any) {
        let files = new File([data], "avatar.png", {type: "image/png"});
        that.imageFile = files;
        formateData.append("files", files);
        that.postToDatabase(formateData);
      });
    } else {
      formateData.append("files", this.imageFile);
      this.postToDatabase(formateData);
    }
  }
  private chooseAvatar(event: any) {
    const that = this;
    const reader = new FileReader();
    reader.onload = function() {
      that.avatarUrl = this.result;
      that.$nextTick(function() {
        if (
          event.target.files[0].type == "image/png" ||
          event.target.files[0].type == "image/jpeg"
        ) {
          that.ifUseCropper = true;
        } else {
          that.ifUseCropper = false;
        }
      })
    };
    let imagSrc = window.URL.createObjectURL(event.target.files[0]);
    reader.readAsDataURL(event.target.files[0]);
    this.imageFile = event.target.files[0];
  }
  private postToDatabase(formateData: any) {
    const that = this;
    Axios.post(this.src, formateData).then(function(response) {
      that.$emit("closeAvatar");
    });
  }
}
</script>

<style lang="scss">
.vueCropper {
  width: 100%;
  height: 128px;
}
</style>