本文首发于CSDN网站,下面的版本又通过进一步的修订。
原文:匠心打造canvas签名组件html
6月又是项目吃紧的时候,一大波需求袭来,猝不及防。vue
度过了漫长而煎熬的6月,是时候总结一波。最近移动端的一款产品原计划是引入第三方的签名插件,该插件依赖复杂,若干个js使用document.write
顺序加载,插件源码是ES5的,甚至说是ES3都不为过。为了可以顺利嵌入咱们的VUE项目,我阅读了两天插件的源码(demo及文档不全,囧),而后花了一天多点的时间使用ES6引用它。鉴于单页应用中,任何非全局资源都不应提早加载的指导性原则,为了作到动态加载,我甚至还专门写了一个simple的vue组件iload.js去顺序加载这些资源并执行回调。一切看似很完美,结果发现demo引用的一个压缩的js中竟然写死了插件相关DOM节点的id和style,此刻个人心里几乎是崩溃的。这样的一个插件我怕是无力引入了吧。ios
虽然嘴上这么说,身体仍是很诚实的,费尽千辛万苦我仍是把这个插件用在了项目中。随着项目推动,业务上通过屡次沟通,咱们砍掉了该签名插件的数字证书验证部分。也就是说,这么大的一个插件,只剩下用户签名的功能,我彻底能够本身作啊。因而我悄悄移除了这个插件,为这几天的调研和码字过程划上了一个完美的句号(深藏功与名)。git
签名是若干操做的集合,起于用户手写姓名,终于签名图片上传,中间还包含图片的处理,好比说减小锯齿、旋转、缩小、预览等。canvas几乎是最适合的解决方案。github
从交互上看,用户签名的过程,只有开始的手写部分是有交互的,后面是自动处理。为了完成手写,须要监听画布的两个事件:touchstart、touchmove(移动端touchend在touchmove以后不触发)。前者定义起始点,后者不停地描线。web
const canvas = document.getElementById('canvas'); const touchstart = (e) => { /* TODO 定义起点 */ }; const touchmove = (e) => { /* TODO 连点成线,而且填充颜色 */ }; canvas.addEventListener('touchstart', touchstart); canvas.addEventListener('touchmove', touchmove);
注: 如下默认canvas和context对象已有。ajax
能够先戳这里体验把后面将要提到的签名组件 canvas-draw。canvas
既然要连点成线,天然须要一个变量来存储这些点。axios
const point = {};
接下来就是画线的部分。canvas画线只需4行代码:api
开始路径(beginPath)
定位起点(moveTo)
移动画笔(lineTo)
绘制路径(stroke)
考虑到start和move两个动做,那么一个描线的方法就呼之欲出了,以下:
const paint = (signal) => { switch (signal) { case 1: // 开始路径 context.beginPath(); context.moveTo(point.x, point.y); case 2: // 前面之因此没有break语句,是为了点击时就能描画出一个点 context.lineTo(point.x, point.y); context.stroke(); break; } };
为了兼容PC端的相似需求,咱们有必要区分下平台。移动端,使用手指操做,须要绑定的是touchstart和touchmove;PC端,使用鼠标操做,须要绑定的是mousedown和mousemove。以下一行代码可用于判断是否移动端:
const isMobile = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i.test(navigator.userAgent);
描线的方法准备稳当后,剩下的就是在适当的时候,记录当前划过的点,而且调用paint方法进行绘制。这里能够抽象出一个事件生成器:
let pressed = false; // 标示是否发生鼠标按下或者手指按下事件 const create = signal => (e) => { if (signal === 1) { pressed = true; } if (signal === 1 || pressed) { e = isMobile ? e.touches[0] : e; point.x = e.clientX - left + 0.5; // 不加0.5,整数坐标处绘制直线,直线宽度将会多1px(不理解的不妨谷歌下) point.y = e.clientY - top + 0.5; paint(signal); } };
以上代码中的left和top并不是内置变量,它们分别表示着画布距屏幕左边和顶部的像素距离,主要用于将屏幕坐标点转换为画布坐标点。如下是一种获取方法:
const { left, top } = canvas.getBoundingClientRect();
很明显,上述的事件生成器是一个高阶函数,用于固化signal参数并返回一个新的Function。基于此,start和move回调便呈现了。
const start = create(1); const move = create(2);
为了不UI过分绘制,让move操做执行得更加流畅,requestAnimationFrame优化天然是少不了的。
const requestAnimationFrame = window.requestAnimationFrame; const optimizedMove = requestAnimationFrame ? (e) => { requestAnimationFrame(() => { move(e); }); } : move;
剩下的也是绑定事件中关键的一步。PC端中,mousedown和mousemove没有前后顺序,不是每一次画布之上的鼠标移动都是有效的操做,所以咱们使用pressed变量来保证mousemove事件回调只在mousedown事件以后执行。实际上,设置后的pressed变量总须要还原,还原的契机就是mouseup和mouseleave回调,因为mouseup事件并不总能触发(好比说鼠标移动到别的节点上才弹起,此时触发的是其余节点的mouseup事件),mouseleave即是鼠标移出画布时的兜底逻辑。而移动端的touch事件,其自然的连续性,保证了touchmove只会在touchstart以后触发,所以无须设置pressed变量,也不须要还原它。代码以下:
if (isMobile) { canvas.addEventListener('touchstart', start); canvas.addEventListener('touchmove', optimizedMove); } else { canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', optimizedMove); ['mouseup', 'mouseleave'].forEach((event) => { canvas.addEventListener(event, () => { pressed = false; }); }); }
想要在移动端签名,每每面临着屏幕宽度不够的尴尬。竖屏下写不了几个汉字,甚至三个都够呛。若是app webview或浏览器不支持横屏展现,此时并非意味着没有了办法,起码咱们能够将整个网页旋转90°。
方案一:起初个人想法是将画布也一同旋转90°,后来发现难以处理旋转后的坐标系和屏幕坐标系的对应关系,所以我采起了旋转90°绘制页面,可是正常布局画布的方案,从而保证坐标系的一致性(这样就不用从新纠正canvas画布的坐标系了,关于纠正坐标系后续还有方案二,请耐心阅读)。
因为用户是横屏操做画布的,完成签名后,图片须要逆时针旋转90°才能保上传到服务器。所以还差一个旋转的方法。实际上,rotate方法能够旋转画布,drawImage方法能够在新的画布中绘制一张图片或老的画布,这种绘制的定制化程度很高。
rotate用于旋转当前的画布。
语法: rotate(angle)
,angle表示旋转的弧度,这里须要将角度转换为弧度计算,好比顺时针旋转90°,angle的值就等于-90 * Math.PI / 180
。ratate旋转时默认以画布左上角为中心,若是须要以画布中心位置为中心,须要在rotate方法执行前将画布的坐标原点移至中心位置,旋转完成后,再移动回来。以下:
const { width, height } = canvas; context.translate(width / 2, height / 2); // 坐标原点移至画布中心 context.rotate(90 * Math.PI / 180); // 顺时针旋转90° context.translate(-width / 2, -height / 2); // 坐标原点还原到起始位置
实际上,这种变换处理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)
一样能够顺时针旋转90°。
drawImage用于绘制图片、画布或者视频,可自定义宽高、位置、甚至局部裁剪。它有三种形态的api:
drawImage(img,x,y)
,x,y为画布中的坐标,img能够是图片、画布或视频资源,表示在画布的指定坐标处绘制。
drawImage(img,x,y,width,height)
,width,height表示指定图片绘制后的宽高(能够任意缩放或调整宽高比例)。
context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
,sx,sy表示从指定的坐标位置裁剪原始图片,而且裁剪swidth的宽度和sheight的高度。
一般状况下,咱们可能须要旋转一张图片90°、180°或者-90°。代码以下:
const rotate = (degree, image) => { degree = ~~degree; if (degree !== 0) { const maxDegree = 180; const minDegree = -90; if (degree > maxDegree) { degree = maxDegree; } else if (degree < minDegree) { degree = minDegree; } const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); const height = image.height; const width = image.width; const angle = (degree * Math.PI) / 180; switch (degree) { // 逆时针旋转90° case -90: canvas.width = height; canvas.height = width; context.rotate(angle); context.drawImage(image, -width, 0); break; // 顺时针旋转90° case 90: canvas.width = height; canvas.height = width; context.rotate(angle); context.drawImage(image, 0, -height); break; // 顺时针旋转180° case 180: canvas.width = width; canvas.height = height; context.rotate(angle); context.drawImage(image, -width, -height); break; } image = canvas; } return image; };
旋转后的画布,一般须要进一步格式化其宽高才能上传。此处仍是利用drawImage去改变画布宽高,以达到缩小和放大的目的。以下:
const scale = (width, height) => { const w = canvas.width; const h = canvas.height; width = width || w; height = height || h; if (width !== w || height !== h) { const tmpCanvas = document.createElement('canvas'); const tmpContext = tmpCanvas.getContext('2d'); tmpCanvas.width = width; tmpCanvas.height = height; tmpContext.drawImage(canvas, 0, 0, w, h, 0, 0, width, height); canvas = tmpCanvas; } return canvas; };
咱们作了这么多的操做和转换,最终的目的仍是上传图片。
首先,获取画布中的图片:
const getPNGImage = () => { return canvas.toDataURL('image/png'); };
getPNGImage方法返回的是dataURL,须要转换为Blob对象才能上传。以下:
const dataURLtoBlob = (dataURL) => { const arr = dataURL.split(','); const mime = arr[0].match(/:(.*?);/)[1]; const bStr = atob(arr[1]); let n = bStr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bStr.charCodeAt(n); } return new Blob([u8arr], { type: mime }); };
完成了上面这些,才能一波ajax请求(xhr、fetch、axios均可)带走签名图片。
const upload = (blob, url, callback) => { const formData = new FormData(); const xhr = new XMLHttpRequest(); xhr.withCredentials = true; formData.append('image', blob, 'sign'); xhr.open('POST', url, true); xhr.onload = () => { if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) { callback(xhr.responseText); } }; xhr.onerror = (e) => { console.log(`upload img error: ${e}`); }; xhr.send(formData); };
完成了上述功能,一个签名插件就已经成型了。除非你火烧眉毛想要发布,不然,这样的代码我是不建议拿出去的。一些必要的设置一般是不能忽略的。
一般画布中的直线是1px大小,这么细的线,是不能模拟笔触的,可若是你要放大至10px,便会发现,绘制的直线实际上是矩形。这在签名过程当中也是不合适的,咱们指望的是圆滑的笔触,所以须要尽可能模拟手写。实际上,lineCap就可指定直线首尾圆滑,lineJoin能够指定线条交汇时的边角圆滑。以下是一个simple的设置:
context.lineWidth = 10; // 直线宽度 context.strokeStyle = 'black'; // 路径的颜色 context.lineCap = 'round'; // 直线首尾端圆滑 context.lineJoin = 'round'; // 当两条线条交汇时,建立圆形边角 context.shadowBlur = 1; // 边缘模糊,防止直线边缘出现锯齿 context.shadowColor = 'black'; // 边缘颜色
一切看似很完美,直到遇到了retina屏幕。retina屏是用4个物理像素绘制一个虚拟像素,屏幕宽度相同的画布,其每一个像素点都会由4倍物理像素去绘制,画布中点与点之间的距离增长,会产生较为明显的锯齿,可经过放大画布而后压缩展现来解决这个问题。
let { width, height } = window.getComputedStyle(canvas, null); width = width.replace('px', ''); height = height.replace('px', ''); // 根据设备像素比优化canvas绘图 const devicePixelRatio = window.devicePixelRatio; if (devicePixelRatio) { canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; canvas.height = height * devicePixelRatio; // 画布宽高放大 canvas.width = width * devicePixelRatio; context.scale(devicePixelRatio, devicePixelRatio); // 画布内容放大相同的倍数 } else { canvas.width = width; canvas.height = height; }
因为采起了方案一,签名的工做流变成了:『页面顺时针旋转90°绘制、画布正常竖屏绘制』—>『手写签名』—>『逆时针旋转画布90°』—> 『合理缩放画布至屏幕宽度』—> 『导出图片并上传』。因而可知方案一流程复杂,处理起来也比较麻烦。
换个角度想一想,既然画布是能够旋转的,我恰好能够利用这种坐标系的反向旋转去抵消页面的正向旋转,这样页面上点的坐标就能够映射到画布自己的坐标上。因而有了方案二。
方案二:页面顺时针旋转90°,画布跟随着一块儿旋转(画布的坐标系也跟着旋转90°);而后再逆向旋转画布90°,重置画布的坐标系,使之与页面坐标系映射起来。
顺时针旋转90°的页面以下所示:
此时canvas画布也随着页面顺时针旋转90°,想要重置画布坐标系,可借由rotate逆向旋转90°,而后由translate平移坐标系。如下代码包含了顺逆时针旋转90°、180° 的处理(为了便于描述,假设画布充满屏幕):
context.rotate((degree * Math.PI) / 180); switch (degree) { // 页面顺时针旋转90°后,画布左上角的原点位置落到了屏幕的右上角(此时宽高互换),围绕原点逆时针旋转90°后,画布与原位置垂直,居于屏幕右侧,须要向左平移画布当前高度相同的距离。 case -90: context.translate(-height, 0); break; // 页面逆时针旋转90°后,画布左上角的原点位置落到了屏幕的左下角(此时宽高互换),围绕原点顺时针旋转90°后,画布与原位置垂直,居于屏幕下侧,须要向上平移画布当前宽度相同的距离。 case 90: context.translate(0, -width); break; // 页面顺逆时针旋转180°回到了同一个位置(即页面倒立),画布左上角的原点位置落到了屏幕的右下角(此时宽高不变),围绕原点反方向旋转180°后,画布与原位置平行,居于屏幕右侧的下侧,须要向左平移画布宽度相同的距离,向右平移画布高度的距离。 case -180: case 180: context.translate(-width, -height); }
拥有了对画布坐标系重置的能力,咱们可以将画布逆时针旋转90°、甚至180°,都是可行的。以下:
固然重置画布坐标系后,须要注意清屏时,清屏的范围也有可能发生变化,须要稍做以下处理。
const clear = () => { let width; let height; switch (this.degree) { // this.degree是画布坐标系旋转的度数 case -90: case 90: width = this.height; // 画布旋转以前的高度 height = this.width; // 画布选择以前的宽度 break; default: width = this.width; height = this.height; } this.context.clearRect(0, 0, width, height); };
方案一简单粗暴,布局上,canvas画布虽然不须要旋转,但须要单独绝对定位布局,给页面视觉展现带来不便,同时,上传图片以前须要对图片作旋转、缩放等处理,流程复杂。
方案二用纠正画布坐标系的方式,省去了布局和图片上的特殊处理,一步到位,所以方案二更佳。
以上,涉及的代码能够在这里找到:canvas-draw,这是一个借助vue cli 搭建起来的壳,主要是为了方便调试,核心代码见 canvas-draw/draw.js,喜欢的同窗不妨轻点star。
本问就讨论这么多内容,你们有什么问题或好的想法欢迎在下方参与留言和评论.
本文做者:louis
本文连接: http://louiszhai.github.io/20...
参考文章: