本文首發於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...
參考文章: