从零开始实现一个Vue移动端图片裁剪插件

1,993 阅读4分钟

前言 & 背景

首先背景需求是小弟目前正在做毕设,头像上传中有一个部分是对上传的图片进行自定义的裁剪,然后再上传到服务端,这样就能保证图片的尺寸比例,并且能有效降低图片的大小(毕竟不需要上传一大块图片了)。

网上轮子也有不少,虽然说也不必刻意去造轮子。不过这种插件,我是先了解了整个思路,发现好像还有点意思,所以就自己动手实现了一个,抽离成了NPM包,官方说法就是提升代码复(zhuang)用(bi)率嘛。

演示

URL

logcas.github.io/vue-mocropp…

(建议移动端打开)

动图

功能规划

首先考虑到有以下这些基本功能:

  1. 自定义的裁剪区大小
  2. 平移图片
  3. 图片缩放
  4. 图片压缩
  5. 输出

考虑到缩放图片需要用到pinch手势,所以就添加了AlloyFinger作为手势库。除此之外就没有其他依赖了。

基本原理

Web上图片裁剪的基本原理都是通过众所周知的Canvas去完成的,本次实现也不例外。

整个裁剪的流程是这样的:

  1. 展示Canvas加载完整的图片资源,根据页面尺寸对图片进行缩放
  2. 裁剪Canvas通过起始坐标、裁剪区域加载展示Canvas中的部分内容
  3. 平移、pinch等手势触发后不断的重复步骤1、2
  4. 点击裁剪后通过裁剪Canvas输出内容

首先需要实现这样的一个布局:

可以看到,需要一个展示完整图片的层级,在这个层级之上有一个遮罩层。然后再遮罩层之上,需要有一个裁剪区突出裁剪的区域内容。最后,为了手势操作的方便,再最上层加盖一个手势操作层。

所以基本布局是这样的:

  <div class="cutout" v-show="visible">
    <!--预览Canvas-->
    <canvas class="previewer" ref="pCanvas"></canvas>

    <!--遮罩层-->
    <div class="mask" v-if="showMask"></div>

    <!-- 裁剪Canvas -->
    <canvas class="cropper" ref="cCanvas" :style="{
      width: cropWidth + 'px',
      height: cropHeight + 'px'
    }"></canvas>
    
    <!-- 手势接收层 -->
    <div class="gesture-layer" ref="gesture" @touchmove.prevent="void 0"></div>
  </div>

高清屏下Canvas模糊问题

在高清屏下,dpr>1时,Canvas会出现模糊。具体原因可以从这里中看到,这里就不再赘述。

解决办法是通过window.devicePixelRatio获取DRP的值,然后通过canvas.widthcanvas.height放大画布,最后通过CSS把画布展示区域缩小。

const getPixelRatio = function (context) {
  const backingStore = context.backingStorePixelRatio ||
    context.webkitBackingStorePixelRatio ||
    context.mozBackingStorePixelRatio ||
    context.msBackingStorePixelRatio ||
    context.oBackingStorePixelRatio ||
    context.backingStorePixelRatio || 1;
  return (window.devicePixelRatio || 1) / backingStore;
};

// this.ratio 存储 dpr 值,在后面画图时会用到
this.ratio = getPixelRatio(this.cCtx);
this.$refs.pCanvas.width = window.innerWidth * this.ratio;
this.$refs.pCanvas.height = window.innerHeight * this.ratio;
this.$refs.cCanvas.width = this.cropWidth * this.ratio;
this.$refs.cCanvas.height = this.cropHeight * this.ratio;

加载图片

由于跨域的原因,Canvas加载的图片需要显示设置crossOriginAnonymous才能使用,否则会报错说明画布受“污染”。

    // 加载图像
    _loadImage() {
      const image = new Image();
      const onLoad = () => {
        this.image = image;
        this.initWidth = image.width;
        this.initHeight = image.height;
        this._adjustImageSize();
        this._drawCanvas();
      };
      const onError = () => {
        if (process.env.NODE_ENV === 'development') {
          console.error('Unable to load image, Please check whether the image URL is correct.');
        }
        this.$emit('load-failed');
      }
      image.setAttribute("crossOrigin",'Anonymous');
      image.src = this.src;
      image.onload = onLoad;
      image.onerror = onError;
    },

调整图片初始大小

如果图片很大,那么需要缩放图片至最大为屏幕的高度或宽度:

    // 调整图像大小
    _adjustImageSize() {
      // 如果图像宽度大于屏幕宽度,缩小图像宽度至最大为屏幕宽度
      let zoom = 1;
      if (this.initWidth > window.innerWidth) {
        zoom = window.innerWidth / this.initWidth;
      }
      // 如果图像高度大于页面高度,缩小图像高度至最高为页面高度
      if (this.initHeight > window.innerHeight) {
        zoom = Math.min(zoom, window.innerHeight / this.initHeight);
      }
      this.zoom = zoom;
      this.currentWidth = this.zoom * this.initWidth;
      this.currentHeight = this.zoom * this.initHeight;
      this.resizedWidth = this.currentWidth;
      this.resizedHeight = this.currentHeight;
    },

画图

在Canvas上画图,需要知道起始坐标、宽度、高度。

计算起始坐标

在初始化时,规定了图片需要垂直水平居中摆放,那么这时候就需要通过推导计算出图片的起始坐标(也就是左上角的位置):

    // 计算起点(主要用于初始化)
    _getStartPoint() {
      const sX = (window.innerWidth - this.currentWidth) / 2;
      const sY = (window.innerHeight - this.currentHeight) / 2;
      this.sX = sX;
      this.sY = sY;
    },

在展示Canvas上画图

    _drawCanvas(resizedWidth, resizedHeight) {
      const pCtx = this.pCtx;
      const ratio = this.ratio;
      const pCanvas = this.$refs.pCanvas;

      const isNull = o => typeof o === 'object' && o === null;

      if (isNull(this.sX) || isNull(this.sY)) {
        this._getStartPoint();
      }

      resizedWidth = resizedWidth || this.currentWidth;
      resizedHeight = resizedHeight || this.currentHeight;

      pCtx.fillStyle = '#000';
      pCtx.fillRect(0, 0, pCanvas.width, pCanvas.height);
      pCtx.drawImage(this.image, 0, 0, this.initWidth, this.initHeight, this.sX * ratio, this.sY * ratio, resizedWidth * ratio, resizedHeight * ratio);
    },

再初次画图前,会判断是否存在起始坐标,如果没有,则自动计算(使图像垂直水平居中)。然后就是通过CanvasAPI进行画图。

需要注意的是,展示Canvas的画布是根据Dpr放大的,因此,画图时坐标也需要根据Dpr进行放大统一,这里就是乘ratio(ratio是之间保存的dpr值)

在裁剪Canvas上画图

裁剪Canvas展示的是裁剪区域,也是展示Canvas上的某一部分。我们只要知道裁剪区域相对于展示Canvas的位置,就能获取到起始坐标,就很容易获得需要裁剪的部分了。

     const { top: cSy, left: cSx } = this.$refs.cCanvas.getBoundingClientRect();
      cCtx.drawImage(this.$refs.pCanvas, cSx * ratio, cSy * ratio, this.cropWidth * ratio, this.cropHeight * ratio, 0, 0, this.cropWidth * ratio, this.cropHeight * ratio);

完整的画图代码

    _drawCanvas(resizedWidth, resizedHeight) {
      const cCtx = this.cCtx;
      const pCtx = this.pCtx;
      const ratio = this.ratio;
      const pCanvas = this.$refs.pCanvas;

      const isNull = o => typeof o === 'object' && o === null;

      if (isNull(this.sX) || isNull(this.sY)) {
        this._getStartPoint();
      }

      resizedWidth = resizedWidth || this.currentWidth;
      resizedHeight = resizedHeight || this.currentHeight;

      pCtx.fillStyle = '#000';
      pCtx.fillRect(0, 0, pCanvas.width, pCanvas.height);
      pCtx.drawImage(this.image, 0, 0, this.initWidth, this.initHeight, this.sX * ratio, this.sY * ratio, resizedWidth * ratio, resizedHeight * ratio);
      
      const { top: cSy, left: cSx } = this.$refs.cCanvas.getBoundingClientRect();
      cCtx.drawImage(this.$refs.pCanvas, cSx * ratio, cSy * ratio, this.cropWidth * ratio, this.cropHeight * ratio, 0, 0, this.cropWidth * ratio, this.cropHeight * ratio);
    },

结合手势

手势这部分使用了AlloyFinger作为手势库。(好像这样就不是从零开始实现了?)

平移

平移比较简单,通过AlloyFinger中的pressMove可以获取平移的距离。

在平移前,需要判断手势的触发是否在展示Canvas的图片区域内,如果是,才触发平移。

    // 检测手势是否在合法区域
    isInLegalArea(x, y) {
      if (x >= this.sX 
        && x < this.sX + this.currentWidth 
        && y >= this.sY 
        && y < this.sY + this.currentHeight
      ) {
        return true;
      }
      return false;
    },

判断的方式也是很简单,知道起始坐标、宽度和高度,判断某个点是否在面积内即可。

然后相加到sXsY,调用画图函数即可。

      pressMove: evt => {
        const { clientX, clientY } = evt.touches[0];
        if (!this.isInLegalArea(clientX, clientY)) {
          return;
        }
        this.sX += evt.deltaX;
        this.sY += evt.deltaY;
        this._drawCanvas();
      },

缩放

pinch手势的缩放比较复杂一点点。

这里我把这样一个过程称为一个完整的缩放操作

touchstart -> pinch -> pinch -> pinch -> touchend

在一个完整的缩放操作的过程中,缩放因子都是基于上一次完整的缩放操作时图片的尺寸的。

假设某一时刻图片的尺寸为:500x500

整个缩放过程是这样的:

touchstart -> pinch -> pinch -> pinch -> touchend

缩放因子分别为:

0.8 -> 0.5 -> 0.3

那么,根据计算此时图片的大小应该是:

0.8 * (500x500) -> 0.5 * (500x500) -> 0.3 * (500x500)

所以,这里通过currentWidthcurrentHeight保存每一次完整操作后的图片尺寸,而resizedWidthresizedHeight则保存本次缩放操作中某一次pinch的实时尺寸。

      pinch: evt => {
        const resizedWidth = evt.zoom * this.currentWidth;
        const resizedHeight = evt.zoom * this.currentHeight;
        if (resizedWidth > this.initWidth * 2 || resizedHeight > this.initHeight * 2) {
          return;
        }
        this.sX += (this.resizedWidth - resizedWidth) / 2;
        this.sY += (this.resizedHeight - resizedHeight) / 2;
        this.resizedWidth = resizedWidth;
        this.resizedHeight = resizedHeight;
        this._drawCanvas(this.resizedWidth, this.resizedHeight);
      },
      touchEnd: evt => {
        this.currentWidth = this.resizedWidth;
        this.currentHeight = this.resizedHeight;
      }

touchend的触发表示一次完整的缩放操作完成,保存当前尺寸到currentWidthcurrentHeight

输出

输出通过HTMLCanvasElement.toDataURL()接口,可以指定图片的Mime类型以及输出的质量(在指定图片格式为 image/jpeg 或 image/webp的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。其他参数会被忽略。)

由于高分屏下把Canvas实际的画布尺寸放大了dpr倍数,因此,需要把图像缩小为1/dpr之后再输出,以保证输出的尺寸与设定的尺寸(cropWidth和cropHeight)一致。

    cropImage() {
      function dataURLtoBlob(dataurl) {
        let arr = dataurl.split(',');
        let mime = arr[0].match(/:(.*?);/)[1];
        let bstr = atob(arr[1]);
        let n = bstr.length;
        let u8arr = new Uint8Array(n);
        while (n--) {
          u8arr[n] = bstr.charCodeAt(n);
        }
        return new Blob([u8arr], { type: mime });
      }

      const cloneCanvas = (cloned) => {
        const clone = document.createElement('canvas');
        clone.width = cloned.width / this.ratio;
        clone.height = cloned.height / this.ratio;
        const cloneCtx = clone.getContext('2d');
        cloneCtx.drawImage(cloned, 0, 0, cloned.width, cloned.height, 0, 0, clone.width, clone.height);
        return clone;
      }

      try {
        const clone = cloneCanvas(this.$refs.cCanvas);
        let data = clone.toDataURL(this.outputMime, this.outputQuality);
        if (this.outputType === 'blob') {
          data = dataURLtoBlob(data);
        }
        this.$emit('crop-done', data);
      } catch (e) {
        this.$emit('crop-failed', e);
      }
    },

最后

最后,发现自己真的不太会描述整个过程,愈发觉得大佬们写的文章通俗易懂的厉害之处,还需要很长的路要走咯。

如果你发现有什么不正确的地方,希望能及时告诉我,谢谢。

GitHub 地址

github.com/logcas/vue-…

如果觉得不错,希望能给个Star吧!