前言 & 背景
首先背景需求是小弟目前正在做毕设,头像上传中有一个部分是对上传的图片进行自定义的裁剪,然后再上传到服务端,这样就能保证图片的尺寸比例,并且能有效降低图片的大小(毕竟不需要上传一大块图片了)。
网上轮子也有不少,虽然说也不必刻意去造轮子。不过这种插件,我是先了解了整个思路,发现好像还有点意思,所以就自己动手实现了一个,抽离成了NPM包,官方说法就是提升代码复(zhuang)用(bi)率嘛。
演示
URL
(建议移动端打开)
动图
功能规划
首先考虑到有以下这些基本功能:
- 自定义的裁剪区大小
- 平移图片
- 图片缩放
- 图片压缩
- 输出
考虑到缩放图片需要用到pinch手势,所以就添加了AlloyFinger作为手势库。除此之外就没有其他依赖了。
基本原理
Web上图片裁剪的基本原理都是通过众所周知的Canvas去完成的,本次实现也不例外。
整个裁剪的流程是这样的:
- 展示Canvas加载完整的图片资源,根据页面尺寸对图片进行缩放
- 裁剪Canvas通过起始坐标、裁剪区域加载展示Canvas中的部分内容
- 平移、pinch等手势触发后不断的重复步骤1、2
- 点击裁剪后通过裁剪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.width和canvas.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加载的图片需要显示设置crossOrigin为Anonymous才能使用,否则会报错说明画布受“污染”。
// 加载图像
_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;
},
判断的方式也是很简单,知道起始坐标、宽度和高度,判断某个点是否在面积内即可。
然后相加到sX和sY,调用画图函数即可。
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)
所以,这里通过currentWidth和currentHeight保存每一次完整操作后的图片尺寸,而resizedWidth和resizedHeight则保存本次缩放操作中某一次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的触发表示一次完整的缩放操作完成,保存当前尺寸到currentWidth和currentHeight。
输出
输出通过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 地址
如果觉得不错,希望能给个Star吧!