原生 JavaScript 實現手勢解鎖元件

十年蹤跡的部落格發表於2017-04-25

這是第三屆 360 前端星計劃的選拔作業題。600多名學生參與瞭解答,最後通過了60人。這60名同學完成的不錯,思路、程式碼風格、功能完成度頗有可取之處,不過也有一些欠考慮的地方,比如發現很多同學能按照需求實現完整的功能,但是不知道應當如何設計開放的 API,或者說,如何分析和預判產品需求和未來的變化,從而決定什麼應當開放,什麼應當封裝。這無關於答案正確與否,還是和經驗有關。

在這裡,我提供一個參考的版本,並不是說這一版就最好,而是說,通過這一版,分析當我們遇到這樣的比較複雜的 UI 需求的時候,我們應該怎樣思考和實現。

元件設計的一般步驟

元件設計一般來說包括如下一些過程:

  1. 理解需求
  2. 技術選型
  3. 結構(UI)設計
  4. 資料和API設計
  5. 流程設計
  6. 相容性和細節優化
  7. 工具 & 工程化

這些過程並不是每個元件設計的時候都會遇到,但是通常來說一個專案總會在其中一些過程裡遇到問題需要解決。下面我們來簡單分析一下。

理解需求

作業本身只是說設計一個常見的手勢密碼的 UI 互動,可以通過選擇驗證密碼和設定密碼來切換兩種狀態,每種狀態有自己的流程。因此大部分同學就照著需求把整個元件的狀態切換和流程封裝了起來,有的同學提供了一定的 UI 樣式配置能力,但是基本上沒有同學能將流程和狀態切換過程中的節點給開放出來。實際上這個元件如果要給使用者使用,顯然需要將過程節點開放出來,也就是說,需要由使用者決定設定密碼的過程裡執行什麼操作、驗證密碼的過程和密碼驗證成功後執行什麼操作,這些是元件開發者無法代替使用者來決定的。

var password = '11121323';

var locker = new HandLock.Locker({
  container: document.querySelector('#handlock'),
  check: {
    checked: function(res){
      if(res.err){
        console.error(res.err); //密碼錯誤或長度太短
        [執行操作...]
      }else{
        console.log(`正確,密碼是:${res.records}`);
        [執行操作...]
      }
    },
  },
  update:{
    beforeRepeat: function(res){
      if(res.err){
        console.error(res.err); //密碼長度太短
        [執行操作...]
      }else{
        console.log(`密碼初次輸入完成,等待重複輸入`);
        [執行操作...]
      }
    },
    afterRepeat: function(res){
      if(res.err){
        console.error(res.err); //密碼長度太短或者兩次密碼輸入不一致
        [執行操作...]
      }else{
        console.log(`密碼更新完成,新密碼是:${res.records}`);
        [執行操作...]
      }
    },
  }
});

locker.check(password);

技術選型

這個問題的 UI 展現的核心是九宮格和選中的小圓點,從技術上來講,我們有三種可選方案: DOM/Canvas/SVG,三者都是可以實現主體 UI 的。

如果使用 DOM,最簡單的方式是使用 flex 佈局,這樣能夠做成響應式的。

使用 DOM 的優點是容易實現響應式,事件處理簡單,佈局也不復雜(但是和 Canvas 比起來略微複雜),但是斜線(demo 裡沒有畫)的長度和斜率需要計算。 除了使用 DOM 外,使用 Canvas 繪製也很方便:

用 Canvas 實現有兩個小細節,第一是要實現響應式,可以用 DOM 構造一個正方形的容器:

#container {
  position: relative;
  overflow: hidden;
  width: 100%;
  padding-top: 100%;
  height: 0px;
  background-color: white;
}

在這裡我們使用 padding-top:100% 撐開容器高度使它等於容器寬度。

第二個細節是為了在 retina 屏上獲得清晰的顯示效果,我們將 Canvas 的寬高增加一倍,然後通過 transform: scale(0.5) 來縮小到匹配容器寬高。

#container canvas{
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(0.5);
}

由於 Canvas 的定位是 absolute,它本身的預設寬高並不等於容器的寬高,需要通過 JS 設定:

let width = 2 * container.getBoundingClientRect().width;
canvas.width = canvas.height = width;

這樣我們就可以通過在 Canvas 上繪製實心圓和連線來實現 UI 了。具體的方法在後續的內容裡有更詳細的講解。

最後我們來看一下用 SVG 繪製:

由於 SVG 原生操作的 API 不是很方便,這裡使用了 Snap.svg 庫,實現起來和使用 Canvas 大同小異,這裡就不贅述了。

SVG 的問題是移動端相容性不如 DOM 和 Canvas 好。

綜合上面三者的情況,最終我選擇使用 Canvas 來實現。

結構設計

使用 Canvas 實現的話 DOM 結構就比較簡單。為了響應式,我們需要實現一個自適應寬度的正方形容器,方法前面已經介紹過。接著在容器中建立 Canvas。這裡需要注意的一點是,我們應當把 Canvas 分層。這是因為 Canvas 的渲染機制裡,要更新畫布的內容,需要重新整理要更新的區域重新繪製。因為我們有必要把頻繁變化的內容和基本不變的內容分層管理,這樣能顯著提升效能。

分成 3 個圖層

在這裡我把 UI 分別繪製在 3 個圖層裡,對應 3 個 Canvas。最上層只有隨著手指頭移動的那個線段,中間是九個點,最下層是已經繪製好的線。之所以這樣分,是因為隨手指頭移動的那條線需要不斷重新整理,底下兩層都不用頻繁更新,但是把連好的線放在最底層是因為我要做出圓點把線的一部分遮擋住的效果。

確定圓點的位置

圓點的位置有兩種定位法,第一種是九個九宮格,圓點在小九宮格的中心位置。如果認真的同學,已經發現在前面 DOM 方案裡,我們就是採用這樣的方式,圓點的直徑為 11.1%。第二種方式是用橫豎三條線把寬高四等分,圓點在這些線的交點處。

在 Canvas 裡我們採用第二種方法來確定圓點(程式碼裡的 n = 3)。

let range = Math.round(width / (n + 1));

let circles = [];

//drawCircleCenters
for(let i = 1; i <= n; i++){
  for(let j = 1; j <= n; j++){
    let y = range * i, x = range * j;
    drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
    let circlePoint = {x, y};
    circlePoint.pos = [i, j];
    circles.push(circlePoint);
  }
}

最後一點,嚴格說不屬於結構設計,但是因為我們的 UI 是通過觸屏操作,我們需要考慮 Touch 事件處理和座標的轉換。

function getCanvasPoint(canvas, x, y){
  let rect = canvas.getBoundingClientRect();
  return {
    x: 2 * (x - rect.left), 
    y: 2 * (y - rect.top),
  };
}

我們將 Touch 相對於螢幕的座標轉換為 Canvas 相對於畫布的座標。程式碼裡的 2 倍是因為我們前面說了要讓 retina 屏下清晰,我們將 Canvas 放大為原來的 2 倍。

API 設計

接下來我們需要設計給使用者使用的 API 了。在這裡,我們將元件功能分解一下,獨立出一個單純記錄手勢的 Recorder。將元件功能分解為更加底層的元件,是一種簡化元件設計的常用模式。

我們抽取出底層的 Recorder,讓 Locker 繼承 Recorder,Recorder 負責記錄,Locker 管理實際的設定和驗證密碼的過程。

我們的 Recorder 只負責記錄使用者行為,由於使用者操作是非同步操作,我們將它設計為 Promise 規範的 API,它可以以如下方式使用:

var recorder = new HandLock.Recorder({
  container: document.querySelector('#main')
});

function recorded(res){
  if(res.err){
    console.error(res.err);
    recorder.clearPath();
    if(res.err.message !== HandLock.Recorder.ERR_USER_CANCELED){
      recorder.record().then(recorded);
    }
  }else{
    console.log(res.records);
    recorder.record().then(recorded);
  }      
}

recorder.record().then(recorded);

對於輸出結果,我們簡單用選中圓點的行列座標拼接起來得到一個唯一的序列。例如 “11121323″ 就是如下選擇圖形:

為了讓 UI 顯示具有靈活性,我們還可以將外觀配置抽取出來。

const defaultOptions = {
  container: null, //建立canvas的容器,如果不填,自動在 body 上建立覆蓋全屏的層
  focusColor: '#e06555',  //當前選中的圓的顏色
  fgColor: '#d6dae5',     //未選中的圓的顏色
  bgColor: '#fff',        //canvas背景顏色
  n: 3, //圓點的數量: n x n
  innerRadius: 20,  //圓點的內半徑
  outerRadius: 50,  //圓點的外半徑,focus 的時候顯示
  touchRadius: 70,  //判定touch事件的圓半徑
  render: true,     //自動渲染
  customStyle: false, //自定義樣式
  minPoints: 4,     //最小允許的點數
};

這樣我們實現完整的 Recorder 物件,核心程式碼如下:

[...] //定義一些私有方法

const defaultOptions = {
  container: null, //建立canvas的容器,如果不填,自動在 body 上建立覆蓋全屏的層
  focusColor: '#e06555',  //當前選中的圓的顏色
  fgColor: '#d6dae5',     //未選中的圓的顏色
  bgColor: '#fff',        //canvas背景顏色
  n: 3, //圓點的數量: n x n
  innerRadius: 20,  //圓點的內半徑
  outerRadius: 50,  //圓點的外半徑,focus 的時候顯示
  touchRadius: 70,  //判定touch事件的圓半徑
  render: true,     //自動渲染
  customStyle: false, //自定義樣式
  minPoints: 4,     //最小允許的點數
};

export default class Recorder{
  static get ERR_NOT_ENOUGH_POINTS(){
    return 'not enough points';
  }
  static get ERR_USER_CANCELED(){
    return 'user canceled';
  }
  static get ERR_NO_TASK(){
    return 'no task';
  }
  constructor(options){
    options = Object.assign({}, defaultOptions, options);

    this.options = options;
    this.path = [];

    if(options.render){
      this.render();
    }
  }
  render(){
    if(this.circleCanvas) return false;

    let options = this.options;
    let container = options.container || document.createElement('div');

    if(!options.container && !options.customStyle){
      Object.assign(container.style, {
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        lineHeight: '100%',
        overflow: 'hidden',
        backgroundColor: options.bgColor
      });
      document.body.appendChild(container); 
    }
    this.container = container;

    let {width, height} = container.getBoundingClientRect();

    //畫圓的 canvas,也是最外層監聽事件的 canvas
    let circleCanvas = document.createElement('canvas'); 

    //2 倍大小,為了支援 retina 屏
    circleCanvas.width = circleCanvas.height = 2 * Math.min(width, height);
    if(!options.customStyle){
      Object.assign(circleCanvas.style, {
        position: 'absolute',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%) scale(0.5)', 
      });
    }

    //畫固定線條的 canvas
    let lineCanvas = circleCanvas.cloneNode(true);

    //畫不固定線條的 canvas
    let moveCanvas = circleCanvas.cloneNode(true);

    container.appendChild(lineCanvas);
    container.appendChild(moveCanvas);
    container.appendChild(circleCanvas);

    this.lineCanvas = lineCanvas;
    this.moveCanvas = moveCanvas;
    this.circleCanvas = circleCanvas;

    this.container.addEventListener('touchmove', 
      evt => evt.preventDefault(), {passive: false});

    this.clearPath();
    return true;
  }
  clearPath(){
    if(!this.circleCanvas) this.render();

    let {circleCanvas, lineCanvas, moveCanvas} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d'),
        width = circleCanvas.width,
        {n, fgColor, innerRadius} = this.options;

    circleCtx.clearRect(0, 0, width, width);
    lineCtx.clearRect(0, 0, width, width);
    moveCtx.clearRect(0, 0, width, width);

    let range = Math.round(width / (n + 1));

    let circles = [];

    //drawCircleCenters
    for(let i = 1; i <= n; i++){
      for(let j = 1; j <= n; j++){
        let y = range * i, x = range * j;
        drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
        let circlePoint = {x, y};
        circlePoint.pos = [i, j];
        circles.push(circlePoint);
      }
    }

    this.circles = circles;
  }
  async cancel(){
    if(this.recordingTask){
      return this.recordingTask.cancel();
    }
    return Promise.resolve({err: new Error(Recorder.ERR_NO_TASK)});
  }
  async record(){
    if(this.recordingTask) return this.recordingTask.promise;

    let {circleCanvas, lineCanvas, moveCanvas, options} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d');

    circleCanvas.addEventListener('touchstart', ()=>{
      this.clearPath();
    });

    let records = [];

    let handler = evt => {
      let {clientX, clientY} = evt.changedTouches[0],
          {bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
          touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);

      for(let i = 0; i < this.circles.length; i++){
        let point = this.circles[i],
            x0 = point.x,
            y0 = point.y;

        if(distance(point, touchPoint) < touchRadius){
          drawSolidCircle(circleCtx, bgColor, x0, y0, outerRadius);
          drawSolidCircle(circleCtx, focusColor, x0, y0, innerRadius);
          drawHollowCircle(circleCtx, focusColor, x0, y0, outerRadius);

          if(records.length){
            let p2 = records[records.length - 1],
                x1 = p2.x,
                y1 = p2.y;

            drawLine(lineCtx, focusColor, x0, y0, x1, y1);
          }

          let circle = this.circles.splice(i, 1);
          records.push(circle[0]);
          break;
        }
      }

      if(records.length){
        let point = records[records.length - 1],
            x0 = point.x,
            y0 = point.y,
            x1 = touchPoint.x,
            y1 = touchPoint.y;

        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        drawLine(moveCtx, focusColor, x0, y0, x1, y1);        
      }
    };

    circleCanvas.addEventListener('touchstart', handler);
    circleCanvas.addEventListener('touchmove', handler);

    let recordingTask = {};
    let promise = new Promise((resolve, reject) => {
      recordingTask.cancel = (res = {}) => {
        let promise = this.recordingTask.promise;

        res.err = res.err || new Error(Recorder.ERR_USER_CANCELED);
        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);
        resolve(res);
        this.recordingTask = null;

        return promise;
      }

      let done = evt => {
        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        if(!records.length) return;

        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);

        let err = null;

        if(records.length < options.minPoints){
          err = new Error(Recorder.ERR_NOT_ENOUGH_POINTS);
        }

        //這裡可以選擇一些複雜的編碼方式,本例子用最簡單的直接把座標轉成字串
        let res = {err, records: records.map(o => o.pos.join('')).join('')};

        resolve(res);
        this.recordingTask = null;
      };
      document.addEventListener('touchend', done);
    });

    recordingTask.promise = promise;

    this.recordingTask = recordingTask;

    return promise;
  }
}

它的幾個公開的方法,recorder 負責記錄繪製結果, clearPath 負責在畫布上清除上一次記錄的結果,cancel 負責終止記錄過程,這是為後續流程準備的。

流程設計

接下來我們基於 Recorder 來設計設定和驗證密碼的流程:

驗證密碼

設定密碼

有了前面非同步 Promise API 的 Recorder,我們不難實現上面的兩個流程。

驗證密碼的內部流程

async check(password){
  if(this.mode !== Locker.MODE_CHECK){
    await this.cancel();
    this.mode = Locker.MODE_CHECK;
  }  

  let checked = this.options.check.checked;

  let res = await this.record();

  if(res.err && res.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(res);
  }

  if(!res.err && password !== res.records){
    res.err = new Error(Locker.ERR_PASSWORD_MISMATCH)
  }

  checked.call(this, res);
  this.check(password);
  return Promise.resolve(res);
}

設定密碼的內部流程

async update(){
  if(this.mode !== Locker.MODE_UPDATE){
    await this.cancel();
    this.mode = Locker.MODE_UPDATE;
  }

  let beforeRepeat = this.options.update.beforeRepeat, 
      afterRepeat = this.options.update.afterRepeat;

  let first = await this.record();

  if(first.err && first.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(first);
  }

  if(first.err){
    this.update();
    beforeRepeat.call(this, first);
    return Promise.resolve(first);   
  }

  beforeRepeat.call(this, first);

  let second = await this.record();      

  if(second.err && second.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(second);
  }

  if(!second.err && first.records !== second.records){
    second.err = new Error(Locker.ERR_PASSWORD_MISMATCH);
  }

  this.update();
  afterRepeat.call(this, second);
  return Promise.resolve(second);
}

可以看到,有了 Recorder 之後,Locker 的驗證和設定密碼基本上就是順著流程用 async/await 寫下來就行了。

細節問題

實際手機觸屏時,如果上下拖動,瀏覽器有預設行為,會導致頁面上下移動,需要阻止 touchmove 的預設事件。

this.container.addEventListener('touchmove', 
      evt => evt.preventDefault(), {passive: false});

這裡仍然需要注意的一點是, touchmove 事件在 chrome 下預設是一個 Passive Event,因此 addEventListener 的時候需要傳參 {passive: false},否則的話不能 preventDefault。

工具 & 工程化

因為我們的程式碼使用了 ES6+,所以需要引入 babel 編譯,我們的元件也使用 webpack 進行打包,以便於使用者在瀏覽器中直接引入。

這方面的內容,在之前的部落格裡有介紹,這裡就不再一一說明。

最後,具體的程式碼可以直接檢視 GitHub 工程

總結

以上就是今天要講的全部內容,這裡面有幾個點我想再強調一下:

  1. 在設計 API 的時候思考真正的需求,判斷什麼該開放、什麼該封裝
  2. 做好技術調研和核心方案研究,選擇合適的方案
  3. 優化和解決細節問題

最後,如有任何問題,歡迎大家在下方評論區探討。

相關文章