如何用原生 JS 實現手勢解鎖組件
這是 第三屆 360 前端星計劃 的選拔作業題。600多名學生參與了解答,最后通過了60人。這60名同學完成的不錯,思路、代碼風格、功能完成度頗有可取之處,不過也有一些欠考慮的地方,比如發現很多同學能按照需求實現完整的功能,但是不知道應當如何 設計開放的 API ,或者說,如何分析和預判產品需求和未來的變化,從而決定什么應當開放,什么應當封裝。這無關于答案正確與否,還是和經驗有關。
在這里,我提供一個 參考的版本 ,并不是說這一版就最好,而是說,通過這一版,分析當我們遇到這樣的比較復雜的 UI 需求的時候,我們應該怎樣思考和實現。
組件設計的一般步驟
組件設計一般來說包括如下一些過程:
- 理解需求
- 技術選型
- 結構(UI)設計
- 數據和API設計
- 流程設計
- 兼容性和細節優化
- 工具 & 工程化
這些過程并不是每個組件設計的時候都會遇到,但是通常來說一個項目總會在其中一些過程里遇到問題需要解決。下面我們來簡單分析一下。
理解需求
作業本身只是說設計一個常見的手勢密碼的 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 工程 。
總結
以上就是今天要講的全部內容,這里面有幾個點我想再強調一下:
- 在設計 API 的時候思考真正的需求,判斷什么該開放、什么該封裝
- 做好技術調研和核心方案研究,選擇合適的方案
- 優化和解決細節問題
最后,如有任何問題,歡迎大家在下方評論區探討。
來自:https://www.h5jun.com/post/handlock-comp.html