匠心打造canvas簽名組件

本文爲原創投稿文章,未經允許,請勿轉載。
作者:翟燦東,網名路易斯,平安健康前端工程師。有四年前端架構及開發經驗。熟悉正則,堅持原著,深度思考,力求簡單通俗敘事。博客地址: http://louiszhai.github.io
責編:陳秋歌,尋求報道或者投稿請發郵件至chenqg#csdn.net,或加微信:Rachel_qg。

導讀

6月又是項目吃緊的時候,一大波需求襲來,猝不及防。

度過了漫長而煎熬的6月,是時候總結一波。最近移動端的一款產品原計劃是引入第三方的簽名插件,該插件依賴複雜,若干個JavaScript使用document.write順序加載,插件源碼是ES5的,甚至說是ES3都不爲過。爲了能夠順利嵌入我們的Vue項目,我閱讀了兩天插件的源碼(Demo及文檔不全,囧),然後花了一天多點的時間使用ES6引用它。鑑於單頁應用中,任何非全局資源都不該提前加載的指導性原則,爲了做到動態加載,我甚至還專門寫了一個簡單的Vue組件iload.js去順序加載這些資源並執行回調。一切看似很完美,結果發現Demo引用的一個壓縮的JavaScript中居然寫死了插件相關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°

此時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°

頁面順時針旋轉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。


本問就討論這麼多內容,大家有什麼問題或好的想法歡迎在下方參與留言和評論.

參考文章:

歡迎加入“CSDN前端開發者”羣,與更多專家、技術同行進行熱點、難點技術交流。請掃描以下二維碼申請入羣。
圖片描述


2017年7月8日(星期六),「“前端開發創新實踐”線上峯會」將在 CSDN 學院召開。本次峯會集結來自Smashing Magazine、美國Hulu、美團、廣發證券、去哪兒網、百度的多位國內外知名前端開發專家、資深架構師,主題涵蓋響應式佈局、Redux、Mobx、狀態管理、構建方案、代碼複用、個性化圖表定製度等前端開發重難點技術話題。技術解析加項目實戰,幫你開拓解決問題的思路,增強技術探索實踐能力。全天六場深度技術分享,現在僅需169元,限時優惠中,詳情點擊峯會官網

圖片描述

發佈了342 篇原創文章 · 獲贊 104 · 訪問量 46萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章