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

最新更新时间: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
	})
}

参考资料

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

打赏我吧^-^

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章