在react的移动端项目中实现手机拍摄图片、压缩、预览、裁剪、上传的实现方案

mac2024-05-15  27

最新更新时间:2019年10月31日15:33:32

《猛戳-查看我的博客地图-总有你意想不到的惊喜》

本文内容:在移动端实现图片拍摄、压缩、预览、裁剪、上传的五大功能,看起来是一套很复杂的业务逻辑组合,实际上每个模块可以单独开发,细分并拆分业务模块是常见复杂业务形态开发的基本方案。

概述

在移动端做开发永远越不过的两个障碍或技术瓶颈,兼容性和性能。

兼容性,某些HTML元素的默认样式在不同浏览器下显示效果不一;CSS样式的兼容性,移动端常见的是同一样式在不同OS和不同机型下显示效果不一;原生事件交互的兼容性,比如拍照和键盘输入场景下,Android和iOS系统表现的形式不一;性能,主要表现在硬件设备系统内存容量的受限,比如视频播放、图片连续拍摄都是高功耗的应用场景,处理不当容易造成内存泄漏导致浏览器crash。

本文中的技术方案,瓶颈在于连续拍摄照片有数量限制,实测过程中iPhone X等高性能手机连续拍摄几十张照片的时候,容易导致浏览器crash,这个问题经过长期探索和研究,手动实现了实时垃圾回收,以及图片压缩比例调整和压缩时机控制,性能有所提高和改善,从二十张左右的数量提升到了四十张左右的数量,但部分机型无限连续拍摄图片出现崩溃的场景终究没有解决方案,究其本质原因,受限于手机系统、可用内存容量等硬件。

本方案,对于大部分机型连续拍摄照片,并实施压缩预览裁剪上传功能,无数量限制。最终向服务器上传的是base64数据格式的图片数据。

技术方案的实现

DOM布局如下: import React from 'react' //引入Cropper图片裁剪组件 import Cropper from 'react-cropper'; import 'cropperjs/dist/cropper.css'; let styles = .contianer { .cropModal{ position: absolute; width: 100%; height: 100%; top: 0; left: 0; background: #000000; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 3; .crop{ } .btn{ display: flex; flex-direction: row; justify-content: space-between; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 285px; height: 60px; .cropperBtn{ width: 60px; height: 60px; line-height: 30px; color: #FFFFFF; font-size: 14px; text-align: center; img{ width: 23px; height: 22px; vertical-align: top; position: relative; top: 50%; transform: translateY(-50%); } } } .cropTips{ position: absolute; top: 22px; font-size: 11px; line-height: 15px; color: #B8B8B8; padding: 0 26px; letter-spacing: 0.5px; } } } export default class TakePhoto extends React.Component { constructor(props) { super(props); this.state = { displayLoading: false, cropperData:'', showCropModal: false }; this.fReader = new FileReader(); this.closureTime = 0; } render() { return <div id='testPage' className={styles.contianer}> {/*图片裁剪组件*/} { this.state.showCropModal ? <div className={styles.cropModal} id='cropModal'> <Cropper className={styles.crop} ref='cropper' src={this.state.cropperData} style={{maxHeight: '78%', width: '100%'}} //0-默认-没有任何限制 1-限制裁剪框不超过canvas画布边缘 2-如果是长图-限制图片不超过cropper的最高可视区域-同时裁剪框不超过canvas画布边缘 viewMode={2} dragMode='none' minCanvasWidth={285} //隐藏棋盘背景色 background={false} //裁剪框内部的横竖虚线可见 guides={true} //裁剪框内部的十字线可见 center={false} //可旋转原图 rotatable={true} //可缩放原图 scalable={true} //crop={(e)=>{this.crop(e)}} /> <div className={styles.btn}> <div className={styles.cropperBtn} onClick={this.cancelCrop}>取消</div> <div className={styles.cropperBtn} onClick={this.confirmCrop}>确认</div> <div className={styles.cropperBtn} onClick={this.rotateCrop}>旋转</div> </div> </div> : null } {this.state.displayLoading ? <Loading></Loading> : null} <input type="file" onChange={(e)=>{this.onChange(e)}} className={styles.getImg} title={this.state.title} id="fileinput" ref='onChange' accept="image/*" // capture="camera" /> </div> } } input元素onChange事件调起相机和相册的功能代码如下: /** * input onChange事件 * @param e * @return */ onChange(e){ //此处是崩溃点 相机调用的频率越高,崩溃越快 let _this = this; //弹出加载动画 this.openLoading() let file = e.currentTarget.files[0];//object-Blob //96K 的文件转换成 base64 是 130KB //用户取消操作 if(file == undefined){ return } this.fReader = new FileReader(); let tempTimer = setTimeout(function(){ _this.fReader.readAsDataURL(file); _this.fReader.onload=function(e) { this.zip(this.result);//压缩逻辑 } file = null; tempTimer = null; },500) } /** * 显示loading组件 * @param * @return */ openLoading(){ this.setState({ displayLoading: true }) } 图片压缩 /** * 图片压缩 * @param base64 * @return */ zip(base64){ let img = new Image(); let canvas = document.createElement("canvas"); let ctx = canvas.getContext("2d"); let compressionRatio = 0.5 //获取用户拍摄图片的旋转角度 let orientation = this.getOrientation(this.base64ToArrayBuffer(base64));//1 0° 3 180° 6 90° 8 -90° img.src = base64 img.onload = function () { let width = img.width, height = img.height; //图片旋转到 正向 if(orientation == 3){ canvas.width = width; canvas.height = height; ctx.rotate(Math.PI) ctx.drawImage(img, -width, -height, width, height) }else if(orientation == 6){ canvas.width = height; canvas.height = width; ctx.rotate(Math.PI / 2) ctx.drawImage(img, 0, -height, width, height) }else if(orientation == 8){ canvas.width = height; canvas.height = width; ctx.rotate(-Math.PI / 2) ctx.drawImage(img, -width, 0, width, height) }else{ //不旋转原图 canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); } //第一次粗压缩 // let base64 = canvas.toDataURL('image/jpeg', compressionRatio);//0.1-表示将原图10M变成1M 10-表示将原图1M变成10M //100保证图片容量 0.05保证不失真 //console.log('第一次粗压缩',base64.length/1024,'kb,压缩率',compressionRatio); //第二次细压缩 // while(base64.length/1024 > 500 && compressionRatio > 0.01){ //console.log('while') // compressionRatio -= 0.01; // base64 = canvas.toDataURL('image/jpeg', compressionRatio);//0.1-表示将原图10M变成1M 10-表示将原图1M变成10M //console.log('第二次细压缩',base64.length/1024,'kb,压缩率',compressionRatio) // } this.setCropperDate(canvas.toDataURL('image/jpeg', compressionRatio)); }; } /** * 拍照第一次压缩后为cropper组件赋值 * @param imgDataBase64 图片的base64 * @return */ setCropperDate = (imgDataBase64) => { let _this = this; this.state.cropperData = imgDataBase64; //定时器的作用,上面的imgDataBase64赋值,属于大数据赋值操作,消耗资源过大,加上定时器等待大数据赋值成功内存释放之后再渲染UI,不会出现白屏 let tempTimer = setTimeout(function(){ _this.setState({ displayLoading: false, showCropModal: true }) clearTimeout(tempTimer) },300) } 获取图片的旋转角度 /** * base64转ArrayBuffer对象 * @param base64 * @return buffer */ base64ToArrayBuffer(base64) { base64 = base64.replace(/^data\:([^\;]+)\;base64,/gmi, ''); var binary = atob(base64); var len = binary.length; var buffer = new ArrayBuffer(len); var view = new Uint8Array(buffer); for (var i = 0; i < len; i++) { view[i] = binary.charCodeAt(i); } return buffer; } /** * 获取jpg图片的exif的角度 * @param * @return */ getOrientation(arrayBuffer) { var dataView = new DataView(arrayBuffer); var length = dataView.byteLength; var orientation; var exifIDCode; var tiffOffset; var firstIFDOffset; var littleEndian; var endianness; var app1Start; var ifdStart; var offset; var i; // Only handle JPEG image (start by 0xFFD8) if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { offset = 2; while (offset < length) { if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) app1Start = offset; break; } offset++; } } if (app1Start) { exifIDCode = app1Start + 4; tiffOffset = app1Start + 10; if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { endianness = dataView.getUint16(tiffOffset); littleEndian = endianness === 0x4949; if (littleEndian || endianness === 0x4D4D /* bigEndian */) { if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); if (firstIFDOffset >= 0x00000008) { ifdStart = tiffOffset + firstIFDOffset; } } } } } if (ifdStart) { length = dataView.getUint16(ifdStart, littleEndian); for (i = 0; i < length; i++) { offset = ifdStart + i * 12 + 2; if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { // 8 is the offset of the current tag's value offset += 8; // Get the original orientation value orientation = dataView.getUint16(offset, littleEndian); // Override the orientation with its default value for Safari (#120) if (true) { dataView.setUint16(offset, 1, littleEndian); } break; } } } return orientation; } /** * Unicode码转字符串 ArrayBuffer对象 Unicode码转字符串 * @param * @return */ getStringFromCharCode(dataView, start, length) { var str = ''; var i; for (i = start, length += start; i < length; i++) { str += String.fromCharCode(dataView.getUint8(i)); } return str; } Cropper组件的取消、裁剪、旋转的三个方法: /** * 无线逆时针旋转图片 * @param * @return */ rotateCrop(){ this.refs.cropper.rotate(-90); } /** * 在裁剪组件中确认裁剪 * @param * @return */ confirmCrop(){ let _this = this; //节流 if(Date.now() - this.closureTime < 2000){ return } this.closureTime = Date.now() document.getElementById('cropModal').style.visibility = 'hidden'; this.setState({ displayLoading: true, }) let tempTimer = setTimeout(function(){ //获取裁剪后的图片base64 向服务器传递500KB以内的图片 let compressionRatio = 0.5; let cropperData = _this.refs.cropper.getCroppedCanvas().toDataURL("image/jpeg", compressionRatio) while(cropperData.length/1024 > 500 && compressionRatio > 0.1){ compressionRatio -= 0.1; cropperData = _this.refs.cropper.getCroppedCanvas().toDataURL("image/jpeg", compressionRatio) } _this.state.cropperData = null; _this.refs.cropper.clear();//去除裁剪框 //_this.refs.cropper.destroy();//需要修改npm包 _this.upload(cropperData);//向服务器提交base64图片数据 cropperData = null; //必须先拿到cropper数据 关闭裁剪框 显示加载框 _this.setState({showCropModal: false}) clearTimeout(tempTimer) },300) } /** * 在裁剪组件中取消裁剪 * @param * @return */ cancelCrop(){ this.state.cropperData = null; this.refs.cropper.clear() this.setState({ showCropModal: false }) }

参考资料

感谢阅读,欢迎评论^-^

打赏我吧^-^

最新回复(0)