Antd Upload + React-Cropper 实现图片自定义区域剪裁并上传功能
一.产生背景
最近做项目遇到一个功能是需要将图片进行剪裁后在上传,并且裁剪功能支持缩放,旋转以及自定义剪裁框的大小,最开始想到的是使用Ant Design中的antd-img-crop 进行图片的裁剪,但是在使用过程中发现,使用此组件裁剪图片不能够拖动改变裁剪框的大小,只能够固定某一种比例进行对图片的裁剪。在查阅了一下antd-img-crop相关文档发现这个组件是基于react-easy-crop进行二次封装实现的,当去查阅react-easy-crop相关文档在Issues中发现此插件并不支持自定义拖动改变裁剪框的大小,
这不得不让我放弃了上面这种方式。然而在多次查寻关于图片裁剪相关资料时,发现了react-Cropper库完美符合我的功能需求,他不仅仅满足antd-img-crop的绝大多数功能,还能够支持上面说的自定义拖动裁剪框(简直是太香了),详细查阅相关文档后,让我毫不犹豫的选择了它。
二.react-Cropper
介绍
react-Cropper是基于cropper.js库进行封装,里面支持所有cropper.js相关API。cropperjs是一款非常强大却又简单的图片裁剪工具,它可以进行非常灵活的配置,支持手机端使用,支持包括IE9以上的现代浏览器。可以自己选择裁剪的交互方式,如大小、纵横比等 还可以预览裁剪区域,确认裁剪后可以生成一个包含裁剪图的canvas对象,借助canvas的toDataURL方法可以生成一张Base64格式的图片。还有另外一种不使用canvas的方式,利用该工具丰富的api可以拿到裁剪区域相对于原图的各项数据,使用这些数据进行css绝对定位即可展示裁剪后的图,该方式可以保证图片不失真和完整。具体可查看:react-cropper
使用样例
官方使用demo如下:
importReact, { useRef } from"react"; importCropperfrom"react-cropper"; import"cropperjs/dist/cropper.css"; constDemo: React.FC= () => { constcropperRef=useRef<HTMLImageElement>(null); constonCrop= () => { constimageElement: any=cropperRef?.current; constcropper: any=imageElement?.cropper; console.log(cropper.getCroppedCanvas().toDataURL()); }; return ( <Croppersrc="https://raw.githubusercontent.com/roadmanfong/react-cropper/master/example/img/child.jpg"style={{ height: 400, width: "100%" }} // Cropper.js optionsinitialAspectRatio={16/9} guides={false} crop={onCrop} ref={cropperRef} /> ); };
常用API介绍
由于是基于cropper.js进行封装,所以支持所有相关API的使用,具体可以参考cropper.js文档,以下只列出比较常用的API属性用法以及相关作用
options |
描述 |
取值 |
viewMode |
视图模式 |
0: 没有限制 1: 限制裁剪框不超过画布的大小。 2:限制最小画布大小以适合容器。如果画布和容器的比例不同,则最小画布将被维度之一的额外空间包围。 3: 限制最小画布尺寸以填充适合容器。如果画布和容器的比例不同,容器将无法在其中一个维度中容纳整个画布。 |
dragMode |
拖动模式 |
'crop': 创建一个新的裁剪框 'move': 移动画布 'none': 保持默认状态,不可拖动画布 |
src |
图片地址 |
img地址 |
preview |
是否启用预览 |
一个元素或一个元素数组或一个节点列表对象或一个有效的Document.querySelectorAll选择器 |
rotatable |
是够可以启用画布旋转 |
Boolean false or true |
initialAspectRatio |
定义裁剪框的初始纵横比。默认情况下,它与画布(图像包装器)的纵横比相同。 |
Number or NAN |
autoCropArea |
定义自动裁剪区域大小(百分比) |
Number 默认值0.8 |
onInitialized |
获取裁剪框实例 |
(ref)=>ref |
zoomTo |
将画布(图像包装器)缩放到绝对比例。 |
Numer |
minCropBoxWidth |
裁剪框的最小宽度 |
Number |
minCropBoxHeight |
裁剪框的最小高度 |
Number |
guides |
是够显示裁剪框网格 |
Boolean false or true |
三.实现思路
首先我们明确要使用antd 的Upload组件进行文件的上传以及处理上传前,中,后的一些事件以及图片的回显,所有我们必须要保证我们默认的上传流程不受影响的情况下加入图片剪裁逻辑。实现思路具体为,通过Upload上传组件获取到上传文件的文件信息,在其中的beforeUpload方法中能够拿到上传的文件信息,通过FileReader()方法将文件信息读取并且转换成URL,然后将生成的URL转交给图片剪切组件,此时在文件上传组件中需添加监听机制,监听裁剪图片是否完成,所以在beforeUpload方法中需要返回一个promise对象,里面启用一个事件循环去读取文件裁剪状态。当文件裁剪完成后,通过裁剪组件实例的getCroppedCanvas方法将数据转换成Blob对象 在通过File(),将Blob转换成file对象,再将获取到file对象转交给上传组件,完成后续的上传逻辑。实现方式总结下主要是对上传文件数据进行拦截,经过裁剪处理后再将数据放入主上传流程中。具体流程如下图所示:
四.具体实现
1.创建剪切组件
首先安装react-cropper依赖,根据官方文档进行安装
npminstall--savereact-cropper
根据官方实例创建cropper实例组件,由于我们是在上传文件时触发剪切,所有我们可以参照antd-img-crop的实现方式,将cropper组件放入Modal弹框中,在上传时触发弹框显示进行图片裁剪。
<><Upload {otherProps} beforeUpload={handleBeforeUpload} > {children} </Upload><Modalvisible={visible} onCancel={handleOnCancel} onOk={handleOk} title={'裁剪图片'} destroyOnClose><Cropperstyle={{ height: 400, width: '100%' }} zoomTo={0} // 默认缩放src={imgSrc} // 原始图片路径dragMode="move"// 拖拽模式minCropBoxHeight={100} // 剪切框最小高度minCropBoxWidth={100} // 剪切框最小宽度rotatable// 启用旋转autoCropArea={1} // 默认裁剪框与画布比例checkOrientation={false} // 是否启用检查方向ref={cropperRef} // 获取裁剪组件实例guides// 显示裁剪框网格信息 {cropperProps} /><divclassName={styles.slider}><buttontype="button"className={styles.btn1} onClick={() => { handleClick('add'); }}>↻</button><Slidervalue={rotate} min={-180} max={180} onChange={setRotate} /><buttontype="button"className={styles.btn2} onClick={() => { handleClick('duce'); }}>↺</button></div></Modal></>
2.创建剪切后的File对象
在Modal弹框中的handleOk事件中获取剪切后的数据
const { type } =fileRef.current; const { cropper } =cropperRef.current; awaitcropper.getCroppedCanvas({ imageSmoothingQuality: 'high', fillColor: 'transparent', }).toBlob((_blob) => { imageBlob.current=_blob; }, type);
其中getCroppedCanvas(options)可选参数有如下:
height:输出画布的目标高度。
minWidth: 输出画布的最小目标宽度,默认值为0。
minHeight: 输出画布的最小目标高度,默认值为0。
maxWidth: 输出画布的最大目标宽度,默认值为Infinity。
maxHeight: 输出画布的最大目标高度,默认值为Infinity。
fillColor:填充输出画布中任何 alpha 值的颜色,默认值为transparent.
imageSmoothingEnabled:设置为更改图像是否平滑(true,默认)或不平滑(false)
imageSmoothingQuality:设置图像平滑的质量“低”(默认)、“中”或“高”之一
3.接管beforeUpload方法,监听剪切结果
上面我们说过我们需要在 beforeUpload方法中启用监听,所以我们需要将传进来的beforeUpload方法进行重写,赋予监听的能力
consthandleBeforeUpload= (file, fileList) => { consttest=beforeUpload(file, fileList); if (test) { fileRef.current=file; constreader=newFileReader(); reader.readAsDataURL(file); reader.onload= (e) => { setImgSrc(e.target.result); setVisiable(true); }; returnnewPromise((resolve, reject) => { timer=setInterval(() => { // 监听剪切是否完成if (imageBlob.current) { window.clearInterval(timer); letcroppedFile=null; if (imageBlob.current!=='fail') { croppedFile=newFile([imageBlob.current], file.name, { type: file.type, lastModified: Date.now(), }); croppedFile.uid=file.uid; } imageBlob.current=null; fileRef.current=null; croppedFile?resolve(croppedFile) : reject(); } }, 100); }); } returnfalse; };
4.增加图片旋转功能
通过获取cropper实例,调用rotateTo方法实现图片的旋转。在Modal添加旋转滑动条,切换事件等。
const [rotate, setRotate] =useState(0); constsetCropperRotate= (_rotate) => { const { cropper } =cropperRef?.current|| {}; if (cropper) { cropper.rotateTo(_rotate); } }; consthandleClick= (type) => { if (type==='add') { const_rotate=rotate-10; setRotate(_rotate<-180?-180 : _rotate); } else { const_rotate=rotate+10; setRotate(_rotate>180?180 : _rotate); } }; <divclassName={styles.slider}><buttontype="button"className={styles.btn1} onClick={() => { handleClick('add'); }}>↻</button><Slidervalue={rotate} min={-180} max={180} onChange={setRotate} /><buttontype="button"className={styles.btn2} onClick={() => { handleClick('duce'); }}>↺</button></div>
5.组件的使用
<CropUpLoadname="avatar"showUploadList={false} accept=".png,.jpg,.jpeg"onChange={handleChange} customRequest={handleRequest} disabled={loading} beforeUpload={handleBeforeCrop} ><divclassName={styles.uploadBtn} style={{ background: 'rgb(244, 117, 85)', color: '#fff' }}><PictureFilledstyle={{ fontSize: 14, top: -1, position: 'relative' }} /></div></CropUpLoad>
6.效果截图
五.剖析antd-img-crop部分实现逻辑,源码,改造当前组件功能
1.antd-img-crop使用分析
首先我们看看官方给的例子用法
importImgCropfrom'antd-img-crop'; <ImgCroprotate><Uploadaction="https://www.mocky.io/v2/5cc8019d300000980a055e76"listType="picture-card"fileList={fileList} onChange={onChange} onPreview={onPreview} > {fileList.length<5&&'+ Upload'} </Upload></ImgCrop
很明显的差别是antd-img-crop是用一个组件包裹Upload实现的 而非我们实现的直接将Upload封装到组件内部,对外暴露出原始的upload组件api。这样虽然是满足需求 但是最为一个业务组件的封装不能将其他的组件耦合在我们的组件内部。可是它是如何接管upload上传文件数据的呢,我们开始分析它内部实现的原理
2.源码分析
constgetUpload=useCallback(() => { constupload=Array.isArray(children) ?children[0] : children; const { beforeUpload, accept, restUploadProps } =upload.props; beforeUploadRef.current=beforeUpload; return { upload, props: { restUploadProps, accept: accept||'image/*', beforeUpload: (file, fileList) => { returnnewPromise(async (resolve, reject) => { if (beforeCrop&&!(awaitbeforeCrop(file, fileList))) { reject(); return; } fileRef.current=file; resolveRef.current= (newFile) => { onModalOk?.(newFile); resolve(newFile); }; rejectRef.current= (uploadErr) => { onUploadFail?.(uploadErr); reject(uploadErr); }; constreader=newFileReader(); reader.addEventListener('load', () =>setImage(reader.result)); reader.readAsDataURL(file); }); }, }, }; }, [beforeCrop, children, onModalOk, onUploadFail]);
以上为antd-img-crop组件接管beforeUpload的部分源码,从源码上面分析,首先通过children获取到了传递到组件中的Upload组件实例,从实例中解析beforeUpload,accept方法,然后重新构造了这个Upload组件 并且将其中的beforeUpload,以及其中的resolve,reject状态通过ref的方式存储起来,这里与我们封装的组件进行对比可以发现,我们是通过一个计时器循环来判断剪切是否完成,进儿进行相应的resolve与reject,然而它是将此方法存储起来 但拿到剪切后的图片数据时,直接调用ref中的方法进行数据的传递。相比我实现的计时器的方式,这种方式大大减少了程序的不必要开销。我们也可以仿照它的实现逻辑进行对本组件的改造。
constonBlob=async (blob) => { letnewFile=newFile([blob], name, { type }); newFile.uid=uid; if (typeofbeforeUploadRef.current!=='function') { returnresolveRef.current(newFile); } constres=beforeUploadRef.current(newFile, [newFile]); if (typeofres!=='boolean'&&!res) { console.error('beforeUpload must return a boolean or Promise'); return; } if (res===true) returnresolveRef.current(newFile); if (res===false) returnrejectRef.current('not upload'); if (res&&typeofres.then==='function') { try { constpassedFile=awaitres; consttype=Object.prototype.toString.call(passedFile); if (type==='[object File]'||type==='[object Blob]') newFile=passedFile; resolveRef.current(newFile); } catch (err) { rejectRef.current(err); } } };
通过onBlob方法可以发现,antd-img-crop并没有对原有的beforeUpload方法进行覆盖重写,而是对外重新暴露了一个beforeCrop方法,此方法返回了当前选择的文件对象,我们可以通过此方法先对需要剪切的图片进行第一次规则筛选,在剪切完成时调用onBlob方法时对原来的beforeUpload方法进执行,将剪切后生成的文件对象最为入参进行上传组件的图片筛选,这样做到了两个组件的独立,功能互不影响。在我们封装的上传组件中,我们是先执行了Upload的beforeUpload方法后拿到图片后再进行上传。而且并没有考虑到在beforeUpload方法中返回promise对象的情况,这样使得我们的组件有十分大的局限性。
3.组件改造
此次改造,主要是仿照antd-img-crop部分实现逻辑,针对上述源发分析产生的问题进行对组件的更近一步封装
- 改变组件定时器的方式获取剪切结果,改为Ref存储方法的方式。(增加getUpload方法,获取上传组件,接管上传文件对象)
constgetUpload=useCallback(() => { constupload=Array.isArray(children) ?children[0] : children; const { beforeUpload, accept, restUploadProps } =upload.props; beforeUploadRef.current=beforeUpload; return { upload, props: { restUploadProps, accept: accept||'image/*', beforeUpload: (file, fileList) => { returnnewPromise(async (resolve, reject) => { if (beforeCrop&&!(awaitbeforeCrop(file, fileList))) { reject(); return; } fileRef.current=file; resolveRef.current= (newFile) => { resolve(newFile); }; rejectRef.current= (uploadErr) => { reject(uploadErr); }; constreader=newFileReader(); reader.readAsDataURL(file); reader.onload= (e) => { setImgSrc(e.target.result); setVisiable(true); }; }); }, }, }; }, [children]);
- 对外暴露beforeCrop方法,对其进行裁剪弹框的图片校验,增加beforeUpload方法的调用,使其Upload组件独立。(改造 handleOk 方法,将剪切完成的file对象转交给beforeUpload方法执行Upload组件逻辑)
consthandleOk=async() => { // TODO拿到截图的url,执行上传功能try { const { type, name, uid } =fileRef.current; const { cropper } =cropperRef.current; awaitcropper.getCroppedCanvas({ imageSmoothingQuality: 'high', fillColor: 'transparent', }).toBlob(async(_blob) => { letcroppedFile=newFile([_blob], name, { type, lastModified: Date.now(), }); croppedFile.uid=uid; if (typeofbeforeUploadRef.current!=='function') { resolveRef.current(croppedFile); } constres=beforeUploadRef.current(croppedFile, [croppedFile]); if (typeofres!=='boolean'&&!res) { return; } if (res===true) resolveRef.current(croppedFile); if (res===false) rejectRef.current(); if (res&&typeofres.then==='function') { try { constpassedFile=awaitres; const_type=Object.prototype.toString.call(passedFile); if (_type==='[object File]'||_type==='[object Blob]') croppedFile=passedFile; resolveRef.current(croppedFile); } catch (err) { rejectRef.current(err); } } resolveRef.current(croppedFile); }, type); } catch { message.error('裁剪失败'); rejectRef.current('裁剪失败'); } finally { setImgSrc(''); setVisiable(false); setRotate(0); } };
- 组件调用方式
<CropUpLoadbeforeCrop={() => { // TODO剪切图片选择校验returntrue; } ><Uploadname="avatar"showUploadList={false} accept=".png,.jpg,.jpeg"// eslint-disable-next-line no-undefonChange={handleChange} customRequest={handleRequest} disabled={loading} beforeUpload={handleBeforeCrop} ><divclassName={styles.uploadBtn} style={{ background: 'rgb(244, 117, 85)', color: '#fff' }}><PictureFilledstyle={{ fontSize: 14, top: -1, position: 'relative' }} /></div></Upload></CropUpLoad>
六.归纳总结
本次图片裁剪上传组件封装,虽然满足了当前业务的需求,但是相比antd Upload官方的裁剪组件还相差甚远,只是实现了基本的功能需求,优化的地方还有很多。但是也让自己也有所收获,至少懂得了其实现的基本原理,在后续需满足其他场景下相信也能很快的对其进行优化。其实对于前端组件封装我自己的理解来说,还是要多读一些源码,在源码中我们能够很快的找出别人实现方式与自己所实现的方式之间的差异,在这种差异中去思考哪一种方式更加完美,让组件更加低耦合,可扩展性更强,做一个复用性较强的组件。最后大家在开发过程中,可以相互借鉴,减少大家的时间,同时,也极大减少了维护的成本。
七.知识点
1.useRef的使用
返回一个可变的 ref 对象,该对象只有个 current 属性,初始值为传入的参数( initialValue )。
返回的 ref 对象在组件的整个生命周期内保持不变 当更新 current 值时并不会 re-render ,这是与 useState 不同的地方 更新 useRef 是 side effect (副作用),所以一般写在 useEffect 或 event handler 里.
可参考:React—useRef
2.useCallback,useMemo的使用
useCallback和useMemo的参数跟useEffect一致,他们之间最大的区别有是useEffect会用于处理副作用,而前两个hooks不能。useMemo和useCallback都会在组件第一次渲染的时候执行,之后会在其依赖的变量发生改变时再次执行;并且这两个hooks都返回缓存的值,useMemo返回缓存的变量,useCallback返回缓存的函数。
使用场景:
有一个父组件,其中包含子组件,子组件接收一个函数作为props;通常而言,如果父组件更新了,子组件也会执行更新;但是大多数场景下,更新是没有必要的,我们可以借助useCallback来返回函数,然后把这个函数作为props传递给子组件;这样,子组件就能避免不必要的更新。
useEffect、useMemo、useCallback都是自带闭包的。也就是说,每一次组件的渲染,其都会捕获当前组件函数上下文中的状态(state, props),所以每一次这三种hooks的执行,反映的也都是当前的状态,你无法使用它们来捕获上一次的状态。对于这种情况,我们应该使用ref来访问。
3.memo的作用
如果你的函数组件在给定相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。
React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹,且其实现中拥有 useState 或 useContext 的 Hook,当 context 发生变化时,它仍会重新渲染。
可参考:memo
4.forwardRef
引用传递(Ref forwading)是一种通过组件向子组件自动传递 引用ref 的技术。对于应用者的大多数组件来说没什么作用。但是对于有些重复使用的组件,可能有用。例如某些input组件,需要控制其focus,本来是可以使用ref来控制,但是因为该input已被包裹在组件中,这时就需要使用Ref forward来透过组件获得该input的引用。
例子:通过组件获得input的引用
importReact, { Component, createRef, forwardRef } from'react'; constFocusInput=forwardRef((props, ref) => ( <inputtype="text"ref={ref} />)); classForwardRefextendsComponent { constructor(props) { super(props); this.ref=createRef(); } componentDidMount() { const { current } =this.ref; current.focus(); } render() { return ( <div><p>forwardref</p><FocusInputref={this.ref} /></div> ); } } exportdefaultForwardRef;
5.Bolb对象
一直以来,JS都没有比较好的可以直接处理二进制的方法。而Blob的存在,允许我们可以通过JS直接操作二进制数据。一个Blob对象就是一个包含有只读原始数据的类文件对象。Blob对象中的数据并不一定得是JavaScript中的原生形式。File接口基于Blob,继承了Blob的功能,并且扩展支持了用户计算机上的本地文件。Blob对象可以看做是存放二进制数据的容器,此外还可以通过Blob设置二进制数据的MIME类型。
创建Blob对象有三种方式:
- 通过构造函数
varblob=newBlob(dataArr:Array<any>, opt:{type:string}); // dataArray:数组,包含了要添加到Blob对象中的数据,数据可以是任意多个ArrayBuffer,ArrayBufferView, Blob,//或者 DOMString对象。//opt:对象,用于设置Blob对象的属性(如:MIME类型)
- 通过Blob.slice()
Blob.slice(start:number, end:number, contentType:string) //此方法返回一个新的Blob对象,包含了原Blob对象中指定范围内的数据
- canvas.toBlob()
varcanvas=document.getElementById("canvas"); canvas.toBlob(function(blob){ console.log(blob); });
Blob对象作为一个装填二进制数据的基本对象,其作用也仅仅是一个容器,而真正的业务功能则需要通过FileReader、URL、Canvas等对象实现。