在前端开发中,当遇到图片或头像上传等功能时,有尺寸分辨率限制的话,就需要用到图片的裁剪功能。想了解图片基础知识的,可见前文图片基础知识介绍。
而canvas的使用,对于我们直接在web端实现图片裁剪功能成为可能。本文将使用前端技术实现一个图片的裁剪功能。
一、图片文件的上传和读取
使用文件上传控件,实现图片上传,获取到图片文件(File对象)后,可以通过 FileReader 或 URL.createObjectURL 两个API完成对文件数据的转换。前文有描述深入理解前端二进制API知识。
	- FileReader:一般使用readAsDataURL方法将File读取为图片文件的Base64数据,可以直接作为图片数据加载。Base64知识可见前文深入理解Base64字符串编码知识。
 
	- URL.createObjectURL:则生成一个伪协议的Blob-Url链接,用在这里是一个图片的URL链接,可以加载图片资源。
 
如下,即响应文件上传事件,以Base64字符串数据加载图片:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			 | 
			
			 // 上传控件事件响应,加载图片文件 
			document.getElementById('input-file').onchange = (e) => { 
			  const file = e.target.files[0] 
			  const reader = new FileReader() 
			  reader.onload = async (event) => { 
			    initImageCut(event.target.result) 
			  } 
			  reader.readAsDataURL(file) 
			} 
			 | 
		
	
这样读取的数据就是图片Base64字符串数据,可当做图片资源被 Image 对象加载了。
二、图片展示和蒙层处理
获取到图片文件的数据以后,加载图片获取像素宽高:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			 | 
			
			 const img = new Image() 
			img.src = dataUrl 
			img.onload = function () { 
			  resolve(img) 
			} 
			 | 
		
	
一般通过 Image 对象,生成一个img实例,加载图片数据,img实例里包含有图片宽高。
图片的宽高是比重重要的数据,如计算图片展示区的缩放比例,后续裁剪框的拖放和缩放也都需要用到。
如下代码,计算缩放比例(zoom):
	
		
			| 
			 1 
			2 
			 | 
			
			 zoom = Math.min(WIDTH / img.width, HEIGHT / img.height) 
			zoom = zoom > 1 ? 1 : zoom 
			 | 
		
	
其中,WIDTH 和 HEIGHT 是设定一个固定区域,用来展示图片和裁剪框,值的大小可以随意设置,在显示器可视区域内最好;
zoom的作用,可以方便我们后面获取图片的相对大小。
接下来就可以在页面上展示图片,并设置蒙层处理。
图片的展示,我们这里直接使用html的 <img> 标签:
	
		
			| 
			 1 
			2 
			 | 
			
			 <img class="image" id="bgMaskImg"/> 
			<img class="image" id="cutBoxImg"/> 
			 | 
		
	
这里使用了两个 <img> 标签元素,两个元素加载同样的图片资源,区别在于:
	- 其中 bgMaskImg 作为底图,设置透明度(如0.5),模拟蒙层效果;
 
	- cutBoxImg 作为裁剪框区域的图片展示,即非蒙层的清晰图片。
 
这里图片展示需要达到的效果,如下:

上图的展示中,看上去有蒙层效果的就是第一个img标签;
而中间区域清晰的图片块则是第二个img标签的效果,这里是借助CSS中的 clip-path 属性来完成的。
然后,需要给两个img标签元素加载图片,并设置各元素的样式:
	
		
			| 
			 1 
			2 
			3 
			 | 
			
			 bgMaskImgElm.src = cutBoxImgElm.src = imgUrl 
			  
			setStyle() 
			 | 
		
	
CSS clip-path
clip-path 是一个CSS属性,能够只展示元素的一块部分区域,而其他区域隐藏起来。
这个特性正好可以作为裁剪功能使用,这里使用在图片上,就能模拟出来裁剪蒙层的效果。
CSS之前有个 clip 属性,但是已经废弃,虽然部分浏览器还支持,但建议使用 clip-path。
clip-path属性有很多取值,我们使用它的多边形值 polygon,模拟方形的裁剪区域:
	
		
			| 
			 1 
			2 
			3 
			 | 
			
			 img { 
			  clip-path: polygon(0 0, 100px 0, 100px 100px, 0 100px); 
			} 
			 | 
		
	
如上代码,使用像素值,定位方形的四个顶点的坐标(左上、右上、右下、左下),展示出图片的一个方形裁剪区域,这时候其他区域不可见,就形成了上面图片展示中的清晰区域。
因为这块裁剪区域会随着裁剪框移动或者缩放而进行改变,所以需要通过JS来改变:
	
		
			| 
			 1 
			2 
			 | 
			
			 const clipPath = `polygon(...)` 
			cutBoxImgElm.style.clipPath = clipPath 
			 | 
		
	
在移动或拖动后,重新计算裁剪区域的坐标点,再进行 clip-path 属性的更新。
三、裁剪框展示
上面实现了图片的加载展示、蒙层和裁剪区域的处理后,接下来,就是对裁剪框的实现。
裁剪框使用div的方式就可以了,定义一个裁剪框:
	
		
			| 
			 1 
			2 
			3 
			 | 
			
			 <div id="cutBox" class="cut-box" style="display: none;"> 
			... 
			</div> 
			 | 
		
	
这里需要注意的是通过JS来改变这个裁剪div的位置和大小,并且也要同步更改上文提到的 clip-path 属性:
	
		
			| 
			 1 
			2 
			3 
			4 
			 | 
			
			 cutBoxElm.style.width = cutBoxWidth * zoom  - 2 + 'px' 
			cutBoxElm.style.height = cutBoxHeight * zoom  - 2 + 'px' 
			cutBoxElm.style.left = cutBoxLeft + 'px' 
			cutBoxElm.style.top = cutBoxTop + 'px' 
			 | 
		
	
裁剪框的缩放点
裁剪框的展示,一般会设计八个缩放点,如下图所示:

需要注意,图上标注了缩放点大致的名称,下文会涉及到对应的点的事件处理,可以清楚是哪个操作。
这样的代码用div也较好实现,使用小图标或者CSS画出粗线即可,放入 cutBox 的裁剪框div下,跟随移动。
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			 | 
			
			 <div class="box-corner topleft" style="cursor:nw-resize;"></div> 
			<div class="box-corner topright" style="cursor:ne-resize;"></div> 
			<div class="box-corner bottomright" style="cursor:se-resize;"></div> 
			<div class="box-corner bottomleft" style="cursor:sw-resize;"></div> 
			<div class="box-middle topmiddle" style="cursor:n-resize;"></div> 
			<div class="box-middle bottommiddle" style="cursor:s-resize;"></div> 
			<div class="box-middle leftmiddle" style="cursor:w-resize;"></div> 
			<div class="box-middle rightmiddle" style="cursor:e-resize;"></div> 
			 | 
		
	
上面html代码就是定义的八个点,配以对应的css样式,就达到所需要的效果了。
需要注意的是,鼠标样式的变更,这里的缩放点,当鼠标hover上去的时候,需要展示不同的鼠标样式。
cursor 鼠标样式
cursor属性主要设置光标的类型,在浏览器上使用鼠标操作时,会显示鼠标的不同样式图标。
如 pointer 悬浮的手指样式,grab 抓手样式等。
裁剪框里使用的是八个缩放相关的鼠标指针样式:
	
		
			| 值 | 
			描述 | 
		
		
			| nw-resize、se-resize | 
			左斜双箭头 | 
		
		
			| ne-resize、sw-resize | 
			右斜双箭头 | 
		
		
			| n-resize、s-resize | 
			垂直双箭头 | 
		
		
			| w-resize、e-resize | 
			水平双箭头 | 
		
	
四、裁剪框移动事件
处理好图片、蒙层效果、裁剪框的结构和展示以后,下面就需要增加一些操作事件。
首先要处理的是裁剪框的移动事件,让裁剪框可以在图片区域内任意移动,使用基本的鼠标事件:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			19 
			 | 
			
			 isMoveDown = false 
			cutBoxElm.addEventListener('mousedown', (event) => { 
			  isMoveDown = true 
			  const { offsetLeft, offsetTop } = cutBoxElm 
			  const disX = event.clientX - offsetLeft 
			  const disY = event.clientY - offsetTop 
			  
			  document.onmousemove = (docEvent) => { 
			    const left = docEvent.clientX - disX 
			    const top = docEvent.clientY - disY 
			    if (isMoveDown) { 
			      //... 
			    } 
			    docEvent.preventDefault() 
			  } 
			  document.onmouseup = () => { 
			    isMoveDown = false 
			  } 
			}) 
			 | 
		
	
移动裁剪框,不牵涉到缩放,只是对位置信息跟随鼠标同步更新,所以重点是计算鼠标事件和裁剪框的偏移位置数据。
在计算位置定位数据后,还需要做的一件事,是不能让裁剪框脱离图片区域,即不能移动到图片外面去,这样是无效的。
	
		
			| 
			 1 
			2 
			3 
			4 
			 | 
			
			 // 裁剪框 left 数据 
			cutBoxLeft = Math.max(0, Math.min(left, curImageWidth - curCutBoxWidth)) 
			// 裁剪框 top 数据 
			cutBoxTop = Math.max(0, Math.min(top, curImageHeight - curCutBoxHeight)) 
			 | 
		
	
以上代码,通过对图片宽高和裁剪框宽高的处理,获取裁剪框位置的限制点。
五、裁剪框缩放操作
裁剪框的缩放事件,也需要进行绑定,本文示例,通过对八个缩放点进行各自的事件绑定,仍然是通过与移动裁剪框一样的鼠标事件:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			 | 
			
			 // 是左上角的缩放点 
			document.querySelector('.topleft').addEventListener('mousedown', (event) => { 
			  reSizeDown('topleft', event) 
			}) 
			// ... 
			// 其他点各自绑定 
			 | 
		
	
reSizeDown 函数中仍然是对 mousemove 事件的处理:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			10 
			11 
			12 
			13 
			14 
			15 
			16 
			17 
			18 
			19 
			20 
			21 
			22 
			23 
			24 
			25 
			26 
			27 
			28 
			29 
			30 
			 | 
			
			 let isResizeDown = false 
			function reSizeDown (type, event) { 
			  isResizeDown = true 
			  document.onmousemove = (docEvent) => { 
			    const disX = docEvent.clientX - event.clientX 
			    const disY = docEvent.clientY - event.clientY 
			    if (isResizeDown) { 
			      let cutW = currentCutBoxWidth 
			      let cutH = currentCutBoxHeight 
			      switch (type) { 
			        case 'topleft': 
			          cutBoxLeft = Math.min(currentBoxLeft + (currentCutBoxWidth * zoom ) - 16, Math.max(0, currentBoxLeft + disX)) 
			          cutBoxTop = Math.min(currentBoxTop + (currentCutBoxHeight * zoom ) - 16, Math.max(0, currentBoxTop + disY)) 
			  
			          const nwWidth = currentCutBoxWidth - (disX / zoom) 
			          const nwHeight = currentCutBoxHeight - (disY / zoom) 
			          cutW = +(cutBoxLeft > 0 ? nwWidth : (currentCutBoxWidth + currentBoxLeft / zoom)).toFixed(0) 
			          cutH = +(cutBoxTop > 0 ? nwHeight : (currentCutBoxHeight + currentBoxTop / zoom)).toFixed(0) 
			          break 
			        // case 'topright': 
			        // ... 
			        // 对每个缩放点进行处理 
			      } 
			      // ... 
			    } 
			  } 
			  document.onmouseup = () => { 
			    isResizeDown = false 
			  } 
			} 
			 | 
		
	
以上代码,以左上角的缩放为例,拖动左上角缩放时,裁剪框的位置和宽高尺寸都会发生变化,所以需要计算这四个值(left, top, width, height)。
裁剪框四个角的位置都需要类似这样的处理,但是其他四个直线方向上的缩放,则要相对简单一点:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			 | 
			
			 case 'leftmiddle': 
			  cutBoxLeft = Math.min(currentBoxLeft + (currentCutBoxWidth * zoom ) - 16, Math.max(0, currentBoxLeft + disX)) 
			  const wWidth = currentCutBoxWidth - (disX / zoom) 
			  cutW = +(cutBoxLeft > 0 ? wWidth : (currentCutBoxWidth + currentBoxLeft / zoom)).toFixed(0) 
			  break 
			 | 
		
	
以上代码,只需要计算 left 偏移和裁剪框宽度即可。
有了位置和宽高数据以后,实时改变裁剪框的样式属性和蒙层图片的 clip-path 属性,同步变化,裁剪框的事件处理就基本完成了。
六、完成裁剪功能
事件绑定后,裁剪框的基本功能就已经完成了,剩下的,就是进行最后的图片裁剪操作。
裁剪框进行移动或者缩放以后,我们需要获取到当前裁剪框的数据:位置和宽高数据。
根据位置和宽高,就可以使用canvas裁剪出图片:
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			6 
			7 
			8 
			9 
			 | 
			
			 const left = cutBoxLeft / zoom 
			const top = cutBoxTop / zoom 
			  
			const myCanvas = document.createElement('canvas') 
			const ctx = myCanvas.getContext('2d') 
			myCanvas.width = cutBoxWidth 
			myCanvas.height = cutBoxHeight 
			  
			ctx.drawImage(imgObj, left, top, cutBoxWidth, cutBoxHeight, 0, 0, cutBoxWidth, cutBoxHeight) 
			 | 
		
	
如上代码,
位置信息之前计算的是相对位置,还原到原始图片上,就需要通过缩放比例进行还原处理;裁剪框宽高因之前已进行还原,这里直接取值即可。
画布 myCanvas 裁剪绘制成功后,就得到了所需要的图像,可以直接将画布展示在页面上,也可以将画布导出为图像Base64数据或者Blob-Url后再加载。
	
		
			| 
			 1 
			2 
			3 
			4 
			5 
			 | 
			
			 myCanvas.toBlob((blob) => { 
			  const url = URL.createObjectURL(blob) 
			}, 'image/jpeg') 
			// 或 
			myCanvas.toDataURL() 
			 | 
		
	
drawImage
drawImage 是canvas中一个的API,用来处理各种图像操作的,它的语法有三种,我们取可以做裁剪的:
	
		
			| 
			 1 
			 | 
			
			 context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) 
			 | 
		
	
其中,
	- 参数 sx, sy, sWidth, sHeight,就可以理解为对原始图片按该位置和宽高尺寸进行裁剪,就可以得到一张裁好的新图;
 
	- 参数 dx, dy, dWidth, dHeight,就是将上面裁好的新图,按照该位置和宽高,绘制到canvas画布上,这个时候,就等到了裁剪后新图片在canvas里的展示。
 
下图就是示例里裁剪功能完整的界面展示效果:

后记
经过以上步骤,一个基于前端技术的基础的图片裁剪功能就完成了。 图片裁剪还可以有多种方法进行处理,示例使用的都是原始标签和API,方便理解。 后续也可以进行各种组件化的封装,不论vue、react、webcomponent等,都可以快速的引入进行封装。