摘要:原文匠心打造簽名組件導(dǎo)讀月又是項(xiàng)目吃緊的時(shí)候,一大波需求襲來(lái),猝不及防。可以先戳這里體驗(yàn)把后面將要提到的簽名組件。剩下的也是綁定事件中關(guān)鍵的一步。設(shè)置完成了上述功能,一個(gè)簽名插件就已經(jīng)成型了。
本文首發(fā)于CSDN網(wǎng)站,下面的版本又經(jīng)過進(jìn)一步的修訂。
原文:匠心打造canvas簽名組件
6月又是項(xiàng)目吃緊的時(shí)候,一大波需求襲來(lái),猝不及防。
度過了漫長(zhǎng)而煎熬的6月,是時(shí)候總結(jié)一波。最近移動(dòng)端的一款產(chǎn)品原計(jì)劃是引入第三方的簽名插件,該插件依賴復(fù)雜,若干個(gè)js使用document.write順序加載,插件源碼是ES5的,甚至說是ES3都不為過。為了能夠順利嵌入我們的VUE項(xiàng)目,我閱讀了兩天插件的源碼(demo及文檔不全,囧),然后花了一天多點(diǎn)的時(shí)間使用ES6引用它。鑒于單頁(yè)應(yīng)用中,任何非全局資源都不該提前加載的指導(dǎo)性原則,為了做到動(dòng)態(tài)加載,我甚至還專門寫了一個(gè)simple的vue組件iload.js去順序加載這些資源并執(zhí)行回調(diào)。一切看似很完美,結(jié)果發(fā)現(xiàn)demo引用的一個(gè)壓縮的js中居然寫死了插件相關(guān)DOM節(jié)點(diǎn)的id和style,此刻我的內(nèi)心幾乎是崩潰的。這樣的一個(gè)插件我怕是無(wú)力引入了吧。
雖然嘴上這么說,身體還是很誠(chéng)實(shí)的,費(fèi)盡千辛萬(wàn)苦我還是把這個(gè)插件用在了項(xiàng)目中。隨著項(xiàng)目推進(jìn),業(yè)務(wù)上經(jīng)過多次溝通,我們砍掉了該簽名插件的數(shù)字證書驗(yàn)證部分。也就是說,這么大的一個(gè)插件,只剩下用戶簽名的功能,我完全可以自己做啊。于是我悄悄移除了這個(gè)插件,為這幾天的調(diào)研和碼字過程劃上了一個(gè)完美的句號(hào)(深藏功與名)。
簽名是若干操作的集合,起于用戶手寫姓名,終于簽名圖片上傳,中間還包含圖片的處理,比如說減少鋸齒、旋轉(zhuǎn)、縮小、預(yù)覽等。canvas幾乎是最適合的解決方案。
手寫從交互上看,用戶簽名的過程,只有開始的手寫部分是有交互的,后面是自動(dòng)處理。為了完成手寫,需要監(jiān)聽畫布的兩個(gè)事件:touchstart、touchmove(移動(dòng)端touchend在touchmove之后不觸發(fā))。前者定義起始點(diǎn),后者不停地描線。
const canvas = document.getElementById("canvas"); const touchstart = (e) => { /* TODO 定義起點(diǎn) */ }; const touchmove = (e) => { /* TODO 連點(diǎn)成線,并且填充顏色 */ }; canvas.addEventListener("touchstart", touchstart); canvas.addEventListener("touchmove", touchmove);
注: 以下默認(rèn)canvas和context對(duì)象已有。
可以先戳這里體驗(yàn)把后面將要提到的簽名組件 canvas-draw。
描線既然要連點(diǎn)成線,自然需要一個(gè)變量來(lái)存儲(chǔ)這些點(diǎn)。
const point = {};
接下來(lái)就是畫線的部分。canvas畫線只需4行代碼:
開始路徑(beginPath)
定位起點(diǎn)(moveTo)
移動(dòng)畫筆(lineTo)
繪制路徑(stroke)
考慮到start和move兩個(gè)動(dòng)作,那么一個(gè)描線的方法就呼之欲出了,如下:
const paint = (signal) => { switch (signal) { case 1: // 開始路徑 context.beginPath(); context.moveTo(point.x, point.y); case 2: // 前面之所以沒有break語(yǔ)句,是為了點(diǎn)擊時(shí)就能描畫出一個(gè)點(diǎn) context.lineTo(point.x, point.y); context.stroke(); break; } };綁定事件
為了兼容PC端的類似需求,我們有必要區(qū)分下平臺(tái)。移動(dòng)端,使用手指操作,需要綁定的是touchstart和touchmove;PC端,使用鼠標(biāo)操作,需要綁定的是mousedown和mousemove。如下一行代碼可用于判斷是否移動(dòng)端:
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);
描線的方法準(zhǔn)備妥當(dāng)后,剩下的就是在適當(dāng)?shù)臅r(shí)候,記錄當(dāng)前劃過的點(diǎn),并且調(diào)用paint方法進(jìn)行繪制。這里可以抽象出一個(gè)事件生成器:
let pressed = false; // 標(biāo)示是否發(fā)生鼠標(biāo)按下或者手指按下事件 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,整數(shù)坐標(biāo)處繪制直線,直線寬度將會(huì)多1px(不理解的不妨谷歌下) point.y = e.clientY - top + 0.5; paint(signal); } };
以上代碼中的left和top并非內(nèi)置變量,它們分別表示著畫布距屏幕左邊和頂部的像素距離,主要用于將屏幕坐標(biāo)點(diǎn)轉(zhuǎn)換為畫布坐標(biāo)點(diǎn)。以下是一種獲取方法:
const { left, top } = canvas.getBoundingClientRect();
很明顯,上述的事件生成器是一個(gè)高階函數(shù),用于固化signal參數(shù)并返回一個(gè)新的Function。基于此,start和move回調(diào)便呈現(xiàn)了。
const start = create(1); const move = create(2);
為了避免UI過度繪制,讓move操作執(zhí)行得更加流暢,requestAnimationFrame優(yōu)化自然是少不了的。
const requestAnimationFrame = window.requestAnimationFrame; const optimizedMove = requestAnimationFrame ? (e) => { requestAnimationFrame(() => { move(e); }); } : move;
剩下的也是綁定事件中關(guān)鍵的一步。PC端中,mousedown和mousemove沒有先后順序,不是每一次畫布之上的鼠標(biāo)移動(dòng)都是有效的操作,因此我們使用pressed變量來(lái)保證mousemove事件回調(diào)只在mousedown事件之后執(zhí)行。實(shí)際上,設(shè)置后的pressed變量總需要還原,還原的契機(jī)就是mouseup和mouseleave回調(diào),由于mouseup事件并不總能觸發(fā)(比如說鼠標(biāo)移動(dòng)到別的節(jié)點(diǎn)上才彈起,此時(shí)觸發(fā)的是其他節(jié)點(diǎn)的mouseup事件),mouseleave便是鼠標(biāo)移出畫布時(shí)的兜底邏輯。而移動(dòng)端的touch事件,其天然的連續(xù)性,保證了touchmove只會(huì)在touchstart之后觸發(fā),因此無(wú)須設(shè)置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; }); }); }旋轉(zhuǎn)
想要在移動(dòng)端簽名,往往面臨著屏幕寬度不夠的尷尬。豎屏下寫不了幾個(gè)漢字,甚至三個(gè)都?jí)騿堋H绻鸻pp webview或?yàn)g覽器不支持橫屏展示,此時(shí)并不是意味著沒有了辦法,起碼我們可以將整個(gè)網(wǎng)頁(yè)旋轉(zhuǎn)90°。
方案一:起初我的想法是將畫布也一同旋轉(zhuǎn)90°,后來(lái)發(fā)現(xiàn)難以處理旋轉(zhuǎn)后的坐標(biāo)系和屏幕坐標(biāo)系的對(duì)應(yīng)關(guān)系,因此我采取了旋轉(zhuǎn)90°繪制頁(yè)面,但是正常布局畫布的方案,從而保證坐標(biāo)系的一致性(這樣就不用重新糾正canvas畫布的坐標(biāo)系了,關(guān)于糾正坐標(biāo)系后續(xù)還有方案二,請(qǐng)耐心閱讀)。
由于用戶是橫屏操作畫布的,完成簽名后,圖片需要逆時(shí)針旋轉(zhuǎn)90°才能保上傳到服務(wù)器。因此還差一個(gè)旋轉(zhuǎn)的方法。實(shí)際上,rotate方法可以旋轉(zhuǎn)畫布,drawImage方法可以在新的畫布中繪制一張圖片或老的畫布,這種繪制的定制化程度很高。
rotaterotate用于旋轉(zhuǎn)當(dāng)前的畫布。
語(yǔ)法: rotate(angle),angle表示旋轉(zhuǎn)的弧度,這里需要將角度轉(zhuǎn)換為弧度計(jì)算,比如順時(shí)針旋轉(zhuǎn)90°,angle的值就等于-90 * Math.PI / 180。ratate旋轉(zhuǎn)時(shí)默認(rèn)以畫布左上角為中心,如果需要以畫布中心位置為中心,需要在rotate方法執(zhí)行前將畫布的坐標(biāo)原點(diǎn)移至中心位置,旋轉(zhuǎn)完成后,再移動(dòng)回來(lái)。如下:
const { width, height } = canvas; context.translate(width / 2, height / 2); // 坐標(biāo)原點(diǎn)移至畫布中心 context.rotate(90 * Math.PI / 180); // 順時(shí)針旋轉(zhuǎn)90° context.translate(-width / 2, -height / 2); // 坐標(biāo)原點(diǎn)還原到起始位置
實(shí)際上,這種變換處理,使用transform(Math.cos(90 * Math.PI / 180), 1, -1, Math.cos(90 * Math.PI / 180), 0, 0)同樣可以順時(shí)針旋轉(zhuǎn)90°。
drawImagedrawImage用于繪制圖片、畫布或者視頻,可自定義寬高、位置、甚至局部裁剪。它有三種形態(tài)的api:
drawImage(img,x,y),x,y為畫布中的坐標(biāo),img可以是圖片、畫布或視頻資源,表示在畫布的指定坐標(biāo)處繪制。
drawImage(img,x,y,width,height),width,height表示指定圖片繪制后的寬高(可以任意縮放或調(diào)整寬高比例)。
context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height),sx,sy表示從指定的坐標(biāo)位置裁剪原始圖片,并且裁剪swidth的寬度和sheight的高度。
通常情況下,我們可能需要旋轉(zhuǎn)一張圖片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) { // 逆時(shí)針旋轉(zhuǎn)90° case -90: canvas.width = height; canvas.height = width; context.rotate(angle); context.drawImage(image, -width, 0); break; // 順時(shí)針旋轉(zhuǎn)90° case 90: canvas.width = height; canvas.height = width; context.rotate(angle); context.drawImage(image, 0, -height); break; // 順時(shí)針旋轉(zhuǎn)180° case 180: canvas.width = width; canvas.height = height; context.rotate(angle); context.drawImage(image, -width, -height); break; } image = canvas; } return image; };縮放
旋轉(zhuǎn)后的畫布,通常需要進(jìn)一步格式化其寬高才能上傳。此處還是利用drawImage去改變畫布寬高,以達(dá)到縮小和放大的目的。如下:
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; };上傳
我們做了這么多的操作和轉(zhuǎn)換,最終的目的還是上傳圖片。
首先,獲取畫布中的圖片:
const getPNGImage = () => { return canvas.toDataURL("image/png"); };
getPNGImage方法返回的是dataURL,需要轉(zhuǎn)換為Blob對(duì)象才能上傳。如下:
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請(qǐng)求(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); };設(shè)置
完成了上述功能,一個(gè)簽名插件就已經(jīng)成型了。除非你迫不及待想要發(fā)布,否則,這樣的代碼我是不建議拿出去的。一些必要的設(shè)置通常是不能忽略的。
通常畫布中的直線是1px大小,這么細(xì)的線,是不能模擬筆觸的,可如果你要放大至10px,便會(huì)發(fā)現(xiàn),繪制的直線其實(shí)是矩形。這在簽名過程中也是不合適的,我們期望的是圓滑的筆觸,因此需要盡量模擬手寫。實(shí)際上,lineCap就可指定直線首尾圓滑,lineJoin可以指定線條交匯時(shí)的邊角圓滑。如下是一個(gè)simple的設(shè)置:
context.lineWidth = 10; // 直線寬度 context.strokeStyle = "black"; // 路徑的顏色 context.lineCap = "round"; // 直線首尾端圓滑 context.lineJoin = "round"; // 當(dāng)兩條線條交匯時(shí),創(chuàng)建圓形邊角 context.shadowBlur = 1; // 邊緣模糊,防止直線邊緣出現(xiàn)鋸齒 context.shadowColor = "black"; // 邊緣顏色優(yōu)化
一切看似很完美,直到遇到了retina屏幕。retina屏是用4個(gè)物理像素繪制一個(gè)虛擬像素,屏幕寬度相同的畫布,其每個(gè)像素點(diǎn)都會(huì)由4倍物理像素去繪制,畫布中點(diǎn)與點(diǎn)之間的距離增加,會(huì)產(chǎn)生較為明顯的鋸齒,可通過放大畫布然后壓縮展示來(lái)解決這個(gè)問題。
let { width, height } = window.getComputedStyle(canvas, null); width = width.replace("px", ""); height = height.replace("px", ""); // 根據(jù)設(shè)備像素比優(yōu)化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); // 畫布內(nèi)容放大相同的倍數(shù) } else { canvas.width = width; canvas.height = height; }重置坐標(biāo)系
由于采取了方案一,簽名的工作流變成了:『頁(yè)面順時(shí)針旋轉(zhuǎn)90°繪制、畫布正常豎屏繪制』—>『手寫簽名』—>『逆時(shí)針旋轉(zhuǎn)畫布90°』—> 『合理縮放畫布至屏幕寬度』—> 『導(dǎo)出圖片并上傳』。由此可見方案一流程復(fù)雜,處理起來(lái)也比較麻煩。
換個(gè)角度想想,既然畫布是可以旋轉(zhuǎn)的,我剛好可以利用這種坐標(biāo)系的反向旋轉(zhuǎn)去抵消頁(yè)面的正向旋轉(zhuǎn),這樣頁(yè)面上點(diǎn)的坐標(biāo)就可以映射到畫布本身的坐標(biāo)上。于是有了方案二。
方案二:頁(yè)面順時(shí)針旋轉(zhuǎn)90°,畫布跟隨著一起旋轉(zhuǎn)(畫布的坐標(biāo)系也跟著旋轉(zhuǎn)90°);然后再逆向旋轉(zhuǎn)畫布90°,重置畫布的坐標(biāo)系,使之與頁(yè)面坐標(biāo)系映射起來(lái)。
順時(shí)針旋轉(zhuǎn)90°的頁(yè)面如下所示:
此時(shí)canvas畫布也隨著頁(yè)面順時(shí)針旋轉(zhuǎn)90°,想要重置畫布坐標(biāo)系,可借由rotate逆向旋轉(zhuǎn)90°,然后由translate平移坐標(biāo)系。以下代碼包含了順逆時(shí)針旋轉(zhuǎn)90°、180° 的處理(為了便于描述,假設(shè)畫布充滿屏幕):
context.rotate((degree * Math.PI) / 180); switch (degree) { // 頁(yè)面順時(shí)針旋轉(zhuǎn)90°后,畫布左上角的原點(diǎn)位置落到了屏幕的右上角(此時(shí)寬高互換),圍繞原點(diǎn)逆時(shí)針旋轉(zhuǎn)90°后,畫布與原位置垂直,居于屏幕右側(cè),需要向左平移畫布當(dāng)前高度相同的距離。 case -90: context.translate(-height, 0); break; // 頁(yè)面逆時(shí)針旋轉(zhuǎn)90°后,畫布左上角的原點(diǎn)位置落到了屏幕的左下角(此時(shí)寬高互換),圍繞原點(diǎn)順時(shí)針旋轉(zhuǎn)90°后,畫布與原位置垂直,居于屏幕下側(cè),需要向上平移畫布當(dāng)前寬度相同的距離。 case 90: context.translate(0, -width); break; // 頁(yè)面順逆時(shí)針旋轉(zhuǎn)180°回到了同一個(gè)位置(即頁(yè)面倒立),畫布左上角的原點(diǎn)位置落到了屏幕的右下角(此時(shí)寬高不變),圍繞原點(diǎn)反方向旋轉(zhuǎn)180°后,畫布與原位置平行,居于屏幕右側(cè)的下側(cè),需要向左平移畫布寬度相同的距離,向右平移畫布高度的距離。 case -180: case 180: context.translate(-width, -height); }
擁有了對(duì)畫布坐標(biāo)系重置的能力,我們能夠?qū)嫴寄鏁r(shí)針旋轉(zhuǎn)90°、甚至180°,都是可行的。如下:
當(dāng)然重置畫布坐標(biāo)系后,需要注意清屏?xí)r,清屏的范圍也有可能發(fā)生變化,需要稍作如下處理。
const clear = () => { let width; let height; switch (this.degree) { // this.degree是畫布坐標(biāo)系旋轉(zhuǎn)的度數(shù) case -90: case 90: width = this.height; // 畫布旋轉(zhuǎn)之前的高度 height = this.width; // 畫布選擇之前的寬度 break; default: width = this.width; height = this.height; } this.context.clearRect(0, 0, width, height); };
方案一簡(jiǎn)單粗暴,布局上,canvas畫布雖然不需要旋轉(zhuǎn),但需要多帶帶絕對(duì)定位布局,給頁(yè)面視覺展示帶來(lái)不便,同時(shí),上傳圖片之前需要對(duì)圖片做旋轉(zhuǎn)、縮放等處理,流程復(fù)雜。
方案二用糾正畫布坐標(biāo)系的方式,省去了布局和圖片上的特殊處理,一步到位,因此方案二更佳。
以上,涉及的代碼可以在這里找到:canvas-draw,這是一個(gè)借助vue cli 搭建起來(lái)的殼,主要是為了方便調(diào)試,核心代碼見 canvas-draw/draw.js,喜歡的同學(xué)不妨輕點(diǎn)star。
本問就討論這么多內(nèi)容,大家有什么問題或好的想法歡迎在下方參與留言和評(píng)論.
本文作者:louis
本文鏈接: http://louiszhai.github.io/20...
參考文章:
HTML5 canvas transform與矩陣
Canvas之平移translate、旋轉(zhuǎn)rotate、縮放scale
文章版權(quán)歸作者所有,未經(jīng)允許請(qǐng)勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請(qǐng)注明本文地址:http://specialneedsforspecialkids.com/yun/83953.html
摘要:前端日?qǐng)?bào)精選精讀與提案知乎專欄第期認(rèn)識(shí)引擎記錄一次利用工具進(jìn)行性能優(yōu)化的真實(shí)案例簡(jiǎn)書中的使用規(guī)則教程繼承的實(shí)現(xiàn)方法個(gè)人文章中文譯組件渲染性能探索個(gè)人文章周刊第期表單性能的改進(jìn)實(shí)踐知乎專欄簡(jiǎn)單可重用的圖表庫(kù)知乎專欄 2017-07-08 前端日?qǐng)?bào) 精選 精讀 TC39 與 ECMAScript 提案 - 知乎專欄【第989期】認(rèn)識(shí) V8 引擎記錄一次利用 Timeline/Perform...
摘要:本文介紹一個(gè)簡(jiǎn)單的類似的布局組件的實(shí)現(xiàn),基于。介紹的內(nèi)容已經(jīng)制作成組件。即當(dāng)不可以拖出抽屜時(shí),應(yīng)觸發(fā)默認(rèn)事件,比如垂直方向的滾動(dòng)等等。這種優(yōu)化可以將一部分復(fù)雜的計(jì)算工作提前準(zhǔn)備好,使頁(yè)面的反應(yīng)更為快速靈敏。 本文介紹一個(gè)簡(jiǎn)單的DrawerLayout(類似Android的DrawerLayout)布局組件的實(shí)現(xiàn),基于Vue.js。介紹的內(nèi)容已經(jīng)制作成 vue-drawer-layout...
摘要:本文介紹一個(gè)簡(jiǎn)單的類似的布局組件的實(shí)現(xiàn),基于。介紹的內(nèi)容已經(jīng)制作成組件。即當(dāng)不可以拖出抽屜時(shí),應(yīng)觸發(fā)默認(rèn)事件,比如垂直方向的滾動(dòng)等等。這種優(yōu)化可以將一部分復(fù)雜的計(jì)算工作提前準(zhǔn)備好,使頁(yè)面的反應(yīng)更為快速靈敏。 本文介紹一個(gè)簡(jiǎn)單的DrawerLayout(類似Android的DrawerLayout)布局組件的實(shí)現(xiàn),基于Vue.js。介紹的內(nèi)容已經(jīng)制作成 vue-drawer-layout...
摘要:剛剛踏入編程世界大門的你,是不是對(duì)程序員生活充滿了是不是幻想前方是一條令人熱血沸騰的殺怪之路亦或是默默坐在電腦前做孤獨(dú)英雄一輩子充滿好奇,不如來(lái)看看最具匠心的工程師王鐵手的感悟。 剛剛踏入編程世界大門的你,是不是對(duì)程序員生活充滿了 YY?是不是幻想前方是一條令人熱血沸騰的殺怪之路?亦或是默默坐在電腦前做孤獨(dú)英雄一輩子?充滿好奇,不如來(lái)看看 SegmentFault 最具匠心的工程師——...
閱讀 3530·2021-11-23 10:10
閱讀 3292·2019-08-30 14:03
閱讀 2066·2019-08-30 13:09
閱讀 3392·2019-08-29 15:29
閱讀 1540·2019-08-29 11:23
閱讀 2002·2019-08-28 18:28
閱讀 2840·2019-08-26 13:34
閱讀 2168·2019-08-26 11:32