前端是软件系统的门面,是用户接触系统的直接入口,用户对系统的感知,全部来自于前端,因此前端的重要性对软件系统来说不言而喻。手机签名组件作为前端的重要组成之一,本文讲述了精益求精开发签名组件的全过程,希望对读者有所启发。
保证能实现竖屏签名,也可以重签和取消;
对接给其他开发者使用时要足够简单、方便;
由于用户的书写习惯是从左到右的,所以要能支持横屏签名;
要支持Android浏览器、iOS浏览器、钉钉、微信等多个环境。
如果要满足上面所有的需求,那基本方案设计是怎样的?实现难点在哪里?因为要实现跨环境,那么只能用纯H5实现。在这个画图场景下,有Canvas API、WebGL API可以满足,调研之后,发现Canvas API足矣。整理基本思路如下:
使用H5的Touch Event监听手机上手指移动的轨迹,通过Mouse Event监听鼠标移动的轨迹;
焦点(手指或者鼠标)移动时,把轨迹的终点不断地画在画布(Canvas)上;
重签就清空画布;
Canvas需要导出File格式方便上传,导出Base64格式方便预览。
整理完思路之后,整理实现难点。
Touch Event和Mouse Event 最好抽象成一套,否则同样的轨迹逻辑要写两遍;
横屏签名有点复杂,由于手机默认是竖直屏幕的,并且很多用户是锁死了竖屏的,所以对屏幕被锁死成竖屏或者App只支持竖屏的情况,只能实现“假横屏”,也即是把竖直的Canvas顺时针旋转90度展示出来。这个时候就会Canvas 旋转了,绘图坐标就被旋转了,但是由于是“假横屏”,屏幕的响应事件坐标(即Touch Event、Mouse Event坐标并没有被旋转),所以这里存在一个坐标转换的问题;
不管竖屏、横屏、还是“假横屏”,用户看到的只有竖屏或者横屏,所以导出的图像必须和用户看到的一致;
用户有没有可能在签名时旋转屏幕的情况,旋转了,怎么处理,要清空画布吗?
手机触摸事件TouchEvent;
鼠标事件MouseEvent。
判断有手机触摸事件时则使用手机触摸事件,其他情况则使用鼠标事件,每一个步骤事件的反应都放在对应的handler中。
isSupportTouch: 'ontouchstart' in window,
events: 'ontouchstart' in window ?
['touchstart', 'touchmove', 'touchend', 'touchleave'] :
['mousedown', 'mousemove', 'mouseup', 'mouseleave']
this.$refs.canvas.addEventListener(this.events[0], this.startEventHandler, false);
this.$refs.canvas.addEventListener(this.events[1], this.moveEventHandler, false);
this.$refs.canvas.addEventListener(this.events[2], this.endEventHandler, false);
this.$refs.canvas.addEventListener(this.events[3], this.leaveEventHandler, false);
const evt = this.isSupportTouch ? event.touches[0] : event;
如果是手机触摸事件则取event.touches[0], 如果是鼠标事件则直接用event。从event中获取当前(触摸或者鼠标)焦点,然后在把线画到当前焦点即可。
moveEventHandler(event) {
event.preventDefault();
const evt = this.isSupportTouch ? event.touches[0] : event;
const coverPos = canvas.getBoundingClientRect();
const mouseX = evt.clientX - coverPos.left;
const mouseY = evt.clientY - coverPos.top;
const position = {x: mouseX, y: mouseY }// 获取当前event position
this.ctx.lineTo(position.x, position.y)
this.ctx.stroke();
},
导出图像的base64数据。
dataURL = canvas.toDataURL('image/png');
导出图像的File数据。
export function canvas2File(canvas: HTMLCanvasElement, fileName?: string, lastModified?: number, type = "image/jpeg", quality=0.8): Promise<File> {
return new Promise<File>((resolve, reject) => {
canvas.toBlob(blob => {
if (!blob) {
reject(new Error('canvas文件导出失败'));
} else {
const file = new File([blob], fileName, { lastModified, type });
resolve(file);
}
}, type, quality)
})
}
实现竖屏的签名效果。
4.2 攻克难点
实现原理:在页面不能横屏的情况下,绘图的canvas顺时针旋转90度。屏幕响应事件坐标系还是保持不变,通过代码转换事件坐标系和绘图坐标系的位置。
如下图,canvas从旧原点旋转到新原点:
const width = clientWidth - leftOffset;
const height = clientHeight - topOffset;
const longSide = Math.max(width, height);
const shortSide = Math.min(width, height);
resultStyle.transform = `rotate(90deg)`;
resultStyle.width = `${longSide}px`
resultStyle.height = `${shortSide}px`
resultStyle.transformOrigin = `${shortSide / 2}px center`
当竖屏时,必须模拟横屏,对事件的x和y做转换,否则保持x和y不变。
export const getForceLandscapeEventPosition = (evt: MouseEvent , canvas: HTMLCanvasElement, forceLandscape: boolean): EventPosition => {
const coverPos = canvas.getBoundingClientRect();
const mouseX = evt.clientX - coverPos.left;
const mouseY = evt.clientY - coverPos.top;
const isReverseXY = forceLandscape && (window.orientation == null || window.orientation === 180 || window.orientation === 0)
if (isReverseXY) {
const { clientWidth } = document.documentElement;
const leftOffset = 0;
const width = clientWidth - leftOffset;
return {'x': mouseY, 'y': (width - mouseX) };
} else {
return {'x': mouseX, 'y': mouseY};
}
}
const evt = this.isSupportTouch ? event.touches[0] : event;
const position = getForceLandscapeEventPosition(evt, this.$refs.canvas, !this.allowPortrait)
this.ctx.lineTo(position.x, position.y)
this.ctx.stroke();
由于屏幕旋转后,画图的画布尺寸改变了,旋转后若还保留上次绘制结果的,无论是拉伸还是截断都不合适,所以旋转后直接清空画布。由于组件可以在PC上展示,PC上的窗口大小可变,所以实际代码中使用resize事件而不是orientationchange事件来获取更好的兼容性。
window.addEventListener("resize", (e) => {
this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height);
this.ctx.closePath();
});
由于获取签名图像之后,大概率是要上传到后台的,file更适合上传。所以导出类型参数改为file和dataUrl(Base64)构成的数组,方便在需要file的时候既导出Base64图像又导出file;
默认在竖屏时开启了假横屏,并提供参数关闭此功能;
针对JPG图像格式不支持透明的情况做了明确说明,并设置了JPG的默认背景色。
在整理需求时提前想好实现方案,了解清楚实现难点,结果就既能按图索骥地提高效率,又能锻炼设计能力;
在克服“假横屏”的难点中,学习怎样对Canvas坐标系和Event坐标系进行转换;
在实现笔迹优化的过程中,接触到了贝塞尔曲线差值算法,开始让工作引入了些“技术含量”。
开发之前需要去打腹稿,做到胸有成竹,打腹稿能锻炼分析设计能力。只有不断锻炼设计能力,才能看懂技术实现背后的设计,才能看到相通。技术的表层实现是不通的,比如前端代码和后端不能直接放在一起编译使用。技术的深层设计上是相通的,比如事件循环,浏览器处理界面事件和后端服务器处理API事件都是用到了事件循环这个技术;
不畏难,要辩证的看待难点。有创新的点,有技术含量的点,都来自难点,不难的点,都已经有成熟或常见方案了。所以处理别人不愿处理或者忽略的难点,才能学到新的东西,有竞争力的东西。基本的搞懂了,难点也搞懂了,这样就看得更清一些,下次处理这个领域的问题,就能更快找到思路;
不断地提高要求。提出更高的要求,把重要的细节做的更好。西方有句话“God is in the detail”,中庸有云“君子致广大,而尽精微”。如果一个领域有细节问题至今没有解决,说明这是该领域的难点,要得到解决,往往要到另外一个领域或层面去找方案,穿过精微之处,才能走向下一个领域或层面,才能学的多、学的深;
学以致用。学习和工作是相辅相成的。学习要能落实到工作中去,因为只有结合工作、以服务工作为目的,才能持续获得资源,从而反过来去支撑学习,而且知识只有用出来,才能知道一个技术里的门道。否则学习只会浮于书本,无法验证其是否正确。所以优化用户体验要成为前端开发者技术提升的目的,这样才能让技术提升和工作相辅相成;
多总结,多写文章。思路的清晰不是一蹴而就的,是在不断梳理、揣摩、提炼后,逐渐清晰的。