匠心打造canvas簽名元件

路易斯發表於2017-07-07

本文首發於CSDN網站,下面的版本又經過進一步的修訂。

關於

全文共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行程式碼:

  1. 開始路徑(beginPath)
  2. 定位起點(moveTo)
  3. 移動畫筆(lineTo)
  4. 繪製路徑(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°的頁面如下所示:

頁面順時針旋轉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°,都是可行的。如下:

頁面逆時針旋轉90°
頁面逆時針旋轉90°

頁面順時針旋轉180°
頁面順時針旋轉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/…

參考文章:

相關文章