本文首發於CSDN網站,下面的版本又經過進一步的修訂。
關於
- 我的部落格:louis blog
- SF專欄:路易斯前端深度課
- 原文連結:匠心打造canvas簽名元件
- CSDN連結:匠心打造canvas簽名元件 - 極客頭條
全文共4k+字,閱讀需5分鐘。
導讀
6月又是專案吃緊的時候,一大波需求襲來,猝不及防。
度過了漫長而煎熬的6月,是時候總結一波。最近移動端的一款產品原計劃是引入第三方的簽名外掛,該外掛依賴複雜,若干個js使用document.write
順序載入,外掛原始碼是ES5的,甚至說是ES3都不為過。為了能夠順利嵌入我們的VUE專案,我閱讀了兩天外掛的原始碼(demo及文件不全,囧),然後花了一天多點的時間使用ES6引用它。鑑於單頁應用中,任何非全域性資源都不該提前載入的指導性原則,為了做到動態載入,我甚至還專門寫了一個simple的vue元件iload.js去順序載入這些資源並執行回撥。一切看似很完美,結果發現demo引用的一個壓縮的js中居然寫死了外掛相關DOM節點的id和style,此刻我的內心幾乎是崩潰的。這樣的一個外掛我怕是無力引入了吧。
雖然嘴上這麼說,身體還是很誠實的,費盡千辛萬苦我還是把這個外掛用在了專案中。隨著專案推進,業務上經過多次溝通,我們砍掉了該簽名外掛的數字證書驗證部分。也就是說,這麼大的一個外掛,只剩下使用者簽名的功能,我完全可以自己做啊。於是我悄悄移除了這個外掛,為這幾天的調研和碼字過程劃上了一個完美的句號(深藏功與名)。
簽名是若干操作的集合,起於使用者手寫姓名,終於簽名圖片上傳,中間還包含圖片的處理,比如說減少鋸齒、旋轉、縮小、預覽等。canvas幾乎是最適合的解決方案。
手寫
從互動上看,使用者簽名的過程,只有開始的手寫部分是有互動的,後面是自動處理。為了完成手寫,需要監聽畫布的兩個事件:touchstart、touchmove(移動端touchend在touchmove之後不觸發)。前者定義起始點,後者不停地描線。
const canvas = document.getElementById('canvas');
const touchstart = (e) => {
/* TODO 定義起點 */
};
const touchmove = (e) => {
/* TODO 連點成線,並且填充顏色 */
};
canvas.addEventListener('touchstart', touchstart);
canvas.addEventListener('touchmove', touchmove);複製程式碼
注: 以下預設canvas和context物件已有。
可以先戳這裡體驗把後面將要提到的簽名元件 canvas-draw。
描線
既然要連點成線,自然需要一個變數來儲存這些點。
const point = {};複製程式碼
接下來就是畫線的部分。canvas畫線只需4行程式碼:
- 開始路徑(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用於旋轉當前的畫布。
語法: 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
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
本文連結: louiszhai.github.io/2017/07/07/…
參考文章: