最近參加 360 暑假的前端星計劃,有一個線上作業,截止日期是 3 月 30 號,讓手動實現一個 H5 手勢解鎖,具體的效果就像原生手機的九宮格解鎖那樣。
實現的最終效果就像下面這張圖這樣:
基本要求是這樣的:將密碼儲存到 localStorage
裡,開始的時候會從本地讀取密碼,如果沒有就讓使用者設定密碼,密碼最少為五位數,少於五位要提示錯誤。需要對第一次輸入的密碼進行驗證,兩次一樣才能保持,然後是驗證密碼,能夠對使用者輸入的密碼進行驗證。
H5 手勢解鎖
掃碼線上檢視:
或者點選檢視手機版。
專案 GitHub 地址,H5HandLock。
首先,我要說明一下,對於這個專案,我是參考別人的,H5lock。
我覺得一個比較合理的解法應該是利用 canvas 來實現,不知道有沒有大神用 css 來實現。如果純用 css 的話,可以將連線先設定 display: none
,當手指劃過的時候,顯示出來。光設定這些應該就非常麻煩吧。
之前瞭解過 canvas,但沒有真正的寫過,下面就來介紹我這幾天學習 canvas 並實現 H5 手勢解鎖的過程。
準備及佈局設定
我這裡用了一個比較常規的做法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
(function(w){ var handLock = function(option){} handLock.prototype = { init : function(){}, ... } w.handLock = handLock; })(window) // 使用 new handLock({ el: document.getElementById('id'), ... }).init(); |
常規方法,比較易懂和操作,弊端就是,可以被隨意的修改。
傳入的引數中要包含一個 dom 物件,會在這個 dom 物件內建立一個 canvas。當然還有一些其他的 dom 引數,比如 message,info 等。
關於 css 的話,懶得去新建檔案了,就直接內聯了。
canvas
1. 學習 canvas 並搞定畫圓
MDN 上面有個簡易的教程,大致瀏覽了一下,感覺還行。Canvas教程。
先建立一個 canvas
,然後設定其大小,並通過 getContext
方法獲得繪畫的上下文:
1 2 3 4 5 |
var canvas = document.createElement('canvas'); canvas.width = canvas.height = width; this.el.appendChild(canvas); this.ctx = canvas.getContext('2d'); |
然後呢,先畫 n*n
個圓出來:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
createCircles: function(){ var ctx = this.ctx, drawCircle = this.drawCircle, n = this.n; this.r = ctx.canvas.width / (2 + 4 * n) // 這裡是參考的,感覺這種畫圓的方式挺合理的,方方圓圓 r = this.r; this.circles = []; // 用來儲存圓心的位置 for(var i = 0; i < n; i++){ for(var j = 0; j < n; j++){ var p = { x: j * 4 * r + 3 * r, y: i * 4 * r + 3 * r, id: i * 3 + j } this.circles.push(p); } } ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); // 為了防止重複畫 this.circles.forEach(function(v){ drawCircle(ctx, v.x, v.y); // 畫每個圓 }) }, drawCircle: function(ctx, x, y){ // 畫圓函式 ctx.strokeStyle = '#FFFFFF'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, this.r, 0, Math.PI * 2, true); ctx.closePath(); ctx.stroke(); } |
畫圓函式,需要注意:如何確定圓的半徑和每個圓的圓心座標(這個我是參考的),如果以圓心為中點,每個圓上下左右各擴充套件一個半徑的距離,同時為了防止四邊太擠,四周在填充一個半徑的距離。那麼得到的半徑就是 width / ( 4 * n + 2)
,對應也可以算出每個圓所在的圓心座標,也有一套公式,GET。
2. 畫線
畫線需要藉助 touch event 來完成,也就是,當我們 touchstart
的時候,傳入開始時的相對座標,作為線的一端,當我們 touchmove
的時候,獲得座標,作為線的另一端,當我們 touchend
的時候,開始畫線。
這只是一個測試畫線功能,具體的後面再進行修改。
有兩個函式,獲得當前 touch 的相對座標:
1 2 3 4 5 6 7 8 |
getTouchPos: function(e){ // 獲得觸控點的相對位置 var rect = e.target.getBoundingClientRect(); var p = { // 相對座標 x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top }; return p; } |
畫線:
1 2 3 4 5 6 7 8 |
drawLine: function(p1, p2){ // 畫線 this.ctx.beginPath(); this.ctx.lineWidth = 3; this.ctx.moveTo(p1.x, p2.y); this.ctx.lineTo(p.x, p.y); this.ctx.stroke(); this.ctx.closePath(); }, |
然後就是監聽 canvas 的 touchstart
、touchmove
、和 touchend
事件了。
3. 畫折線
所謂的畫折線,就是,將已經觸控到的點連起來,可以把它看作是畫折線。
首先,要用兩個陣列,一個陣列用於已經 touch 過的點,另一個陣列用於儲存未 touch 的點,然後在 move 監聽時候,對 touch 的相對位置進行判斷,如果觸到點,就把該點從未 touch 移到 touch 中,然後,畫折線,思路也很簡單。
1 2 3 4 5 6 7 8 9 10 11 |
drawLine: function(p){ // 畫折線 this.ctx.beginPath(); this.ctx.lineWidth = 3; this.ctx.moveTo(this.touchCircles[0].x, this.touchCircles[0].y); for (var i = 1 ; i < this.touchCircles.length ; i++) { this.ctx.lineTo(this.touchCircles[i].x, this.touchCircles[i].y); } this.ctx.lineTo(p.x, p.y); this.ctx.stroke(); this.ctx.closePath(); }, |
1 2 3 4 5 6 7 8 9 10 11 |
judgePos: function(p){ // 判斷 觸點 是否在 circle 內 for(var i = 0; i < this.restCircles.length; i++){ temp = this.restCircles[i]; if(Math.abs(p.x - temp.x) < r && Math.abs(p.y - temp.y) < r){ this.touchCircles.push(temp); this.restCircles.splice(i, 1); this.touchFlag = true; break; } } } |
4. 標記已畫
前面已經說了,我們把已經 touch 的點(圓)放到陣列中,這個時候需要將這些已經 touch 的點給標記一下,在圓心處畫一個小實心圓:
1 2 3 4 5 6 7 8 9 |
drawPoints: function(){ for (var i = 0 ; i < this.touchCircles.length ; i++) { this.ctx.fillStyle = '#FFFFFF'; this.ctx.beginPath(); this.ctx.arc(this.touchCircles[i].x, this.touchCircles[i].y, this.r / 2, 0, Math.PI * 2, true); this.ctx.closePath(); this.ctx.fill(); } } |
同時新增一個 reset 函式,當 touchend 的時候呼叫,400ms 呼叫 reset 重置 canvas。
到現在為止,一個 H5 手勢解鎖的簡易版已經基本完成。
password
為了要實現記住和重置密碼的功能,把 password 儲存在 localStorage 中,但首先要新增必要的 html 和樣式。
1. 新增 message 和 單選框
為了儘可能的使介面簡潔(越醜越好),直接在 body 後面新增了:
1 2 3 4 5 6 7 |
<div id="select"> <div class="message">請輸入手勢密碼</div> <div class="radio"> <label><input type="radio" name="pass">設定手勢密碼</label> <label><input type="radio" name="pass">驗證手勢密碼</label> </div> </div> |
將新增到 dom 已 option 的形式傳給 handLock:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
var el = document.getElementById('handlock'), info = el.getElementsByClassName('info')[0], select = document.getElementById('select'), message = select.getElementsByClassName('message')[0], radio = select.getElementsByClassName('radio')[0], setPass = radio.children[0].children[0], checkPass = radio.children[1].children[0]; new handLock({ el: el, info: info, message: message, setPass: setPass, checkPass: checkPass, n: 3 }).init(); |
2. info 資訊顯示
關於 info 資訊顯示,自己寫了一個懸浮窗,然後預設為 display: none
,然後寫了一個 showInfo
函式用來顯示提示資訊,直接呼叫:
1 2 3 4 5 6 7 8 |
showInfo: function(message, timer){ // 專門用來顯示 info var info = this.dom.info; info.innerHTML = message; info.style.display = 'block'; setTimeout(function(){ info.style.display = ''; }, 1000) } |
關於 info 的樣式,在 html 中呢。
3. 關於密碼
先不考慮從 localStorage 讀取到情況,新加一個 lsPass 物件,專門用於儲存密碼,由於密碼情況比較多,比如設定密碼,二次確認密碼,驗證密碼,為了方便管理,暫時設定了密碼的三種模式,分別是:
model:1 驗證密碼模式
model:2 設定密碼模式
model:3 設定密碼二次驗證
具體看下面這個圖:
這三種 model ,只要處理好它們之間如何跳轉就 ok 了,即狀態的改變。
所以就有了 initPass:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
initPass: function(){ // 將密碼初始化 this.lsPass = w.localStorage.getItem('HandLockPass') ? { model: 1, pass: w.localStorage.getItem('HandLockPass').split('-') } : { model: 2 }; this.updateMessage(); }, updateMessage: function(){ // 根據當前模式,更新 dom if(this.lsPass.model == 2){ this.dom.setPass.checked = true; this.dom.message.innerHTML = '請設定手勢密碼'; }else if(this.lsPass.model == 1){ this.dom.checkPass.checked = true; this.dom.message.innerHTML = '請驗證手勢密碼'; }else if(this.lsPass.model = 3){ this.dom.setPass.checked = true; this.dom.message.innerHTML = '請再次輸入密碼'; } }, |
有必要再來介紹一下 lsPass 的格式:
1 2 3 4 |
this.lsPass = { model:1, // 表示當前的模式 pass: [0, 1, 2, 4, 5] // 表示當前的密碼,可能不存在 } |
因為之前已經有了一個基本的實現框架,現在只需要在 touchend 之後,寫一個函式,功能就是先對當前的 model 進行判斷,實現對應的功能,這裡要用到 touchCircles 陣列,表示密碼的順序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
checkPass: function(){ var succ, model = this.lsPass.model; //succ 以後會用到 if(model == 2){ // 設定密碼 if(this.touchCircles.length < 5){ // 驗證密碼長度 succ = false; this.showInfo('密碼長度至少為 5!', 1000); }else{ succ = true; this.lsPass.temp = []; // 將密碼放到臨時區儲存 for(var i = 0; i < this.touchCircles.length; i++){ this.lsPass.temp.push(this.touchCircles[i].id); } this.lsPass.model = 3; this.showInfo('請再次輸入密碼', 1000); this.updateMessage(); } }else if(model == 3){// 確認密碼 var flag = true; // 先要驗證密碼是否正確 if(this.touchCircles.length == this.lsPass.temp.length){ var tc = this.touchCircles, lt = this.lsPass.temp; for(var i = 0; i < tc.length; i++){ if(tc[i].id != lt[i]){ flag = false; } } }else{ flag = false; } if(!flag){ succ = false; this.showInfo('兩次密碼不一致,請重新輸入', 1000); this.lsPass.model = 2; // 由於密碼不正確,重新回到 model 2 this.updateMessage(); }else{ succ = true; // 密碼正確,localStorage 儲存,並設定狀態為 model 1 w.localStorage.setItem('HandLockPass', this.lsPass.temp.join('-')); // 儲存字串 this.lsPass.model = 1; this.lsPass.pass = this.lsPass.temp; this.updateMessage(); } delete this.lsPass.temp; // 很重要,一定要刪掉,bug }else if(model == 1){ // 驗證密碼 var tc = this.touchCircles, lp = this.lsPass.pass, flag = true; if(tc.length == lp.length){ for(var i = 0; i < tc.length; i++){ if(tc[i].id != lp[i]){ flag = false; } } }else{ flag = false; } if(!flag){ succ = false; this.showInfo('很遺憾,密碼錯誤', 1000); }else{ succ = true; this.showInfo('恭喜你,驗證通過', 1000); } } }, |
密碼的設定要參考前面那張圖,要時刻警惕狀態的改變。
4. 手動重置密碼
思路也很簡單,就是新增點選事件,點選之後,改變 model 即可,點選事件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
this.dom.setPass.addEventListener('click', function(e){ self.lsPass.model = 2; // 改變 model 為設定密碼 self.updateMessage(); // 更新 message self.showInfo('請設定密碼', 1000); }) this.dom.checkPass.addEventListener('click', function(e){ if(self.lsPass.pass){ self.lsPass.model = 1; self.updateMessage(); self.showInfo('請驗證密碼', 1000) }else{ self.showInfo('請先設定密碼', 1000); self.updateMessage(); } }) |
ps:這裡面還有幾個小的 bug,因為 model 只有 3 個,所以設定的時候,當點選重置密碼的時候,沒有設定密碼成功,又切成驗證密碼狀態,此時無法提升沿用舊密碼,原因是 model 只有三個。
5. 新增 touchend 顏色變化
實現這個基本上就大功告成了,這個功能最主要的是給使用者一個提醒,若使用者劃出的密碼符合規範,顯示綠色,若不符合規範或錯誤,顯示紅色警告。
因為之前已經設定了一個 succ 變數,專門用於重繪。
1 2 3 4 5 6 7 8 9 10 11 12 |
drawEndCircles: function(color){ // end 時重繪已經 touch 的圓 for(var i = 0; i < this.touchCircles.length; i++){ this.drawCircle(this.touchCircles[i].x, this.touchCircles[i].y, color); } }, // 呼叫 if(succ){ this.drawEndCircles('#2CFF26'); // 綠色 }else{ this.drawEndCircles('red'); // 紅色 } |
那麼,一個可以演示的版本就生成了,儘管還存在一些 bug,隨後會來解決。(詳情分支 password)
一些 bugs
有些 bugs 在做的時候就發現了,一些 bug 後來用手機測試的時候才發現,比如,我用 chrome 的時候,沒有察覺這個 bug,當我用 android 手機 chrome 瀏覽器測試的時候,發現當我 touchmove 向下的時候,會觸發瀏覽器的下拉重新整理,解決辦法:加了一個 preventDefault
,沒想到居然成功了。
1 2 3 4 5 6 7 8 9 |
this.canvas.addEventListener('touchmove', function(e){ e.preventDefault ? e.preventDefault() : null; var p = self.getTouchPos(e); if(self.touchFlag){ self.update(p); }else{ self.judgePos(p); } }, false) |
關於 showInfo
由於showInfo 中有 setTimeout 函式,可以看到函式裡的演出為 1s,導致如果我們操作的速度比較快,在 1s 內連續 show 了很多個 info,後面的 info 會被第一個 info 的 setTimeout 弄亂,顯示的時間小於 1s,或更短。比如,當重複點選設定手勢密碼和驗證手勢密碼,會產生這個 bug。
解決辦法有兩個,一個是增加一個專門用於顯示的陣列,每次從陣列中取值然後顯示。另一種解題思路和防抖動的思路很像,就是當有一個新的 show 到來時,把之前的那個 setTimeout 清除掉。
這裡採用第二種思路:
1 2 3 4 5 6 7 8 9 |
showInfo: function(message, timer){ // 專門用來顯示 info clearTimeout(this.showInfo.timer); var info = this.dom.info; info.innerHTML = message; info.style.display = 'block'; this.showInfo.timer = setTimeout(function(){ info.style.display = ''; }, timer || 1000) }, |
解決小尾巴
所謂的小尾巴,如下:
解決辦法也很簡單,在 touchend 的時候,先進行 clearRect
就 ok 了。
關於優化
效能優化一直都是一個大問題,不要以為前端不需要考慮記憶體,就可以隨便寫程式碼。
之前在設計自己網頁的時候,用到了滾動,滑鼠滑輪輕輕一碰,滾動函式就執行了幾十多則幾百次,之前也考慮過解決辦法。
優化 canvas 部分
對於 touchmove 函式,原理都是一樣的,手指一劃,就執行了 n 多次,這個問題後面在解決,先來看另一個問題。
touchmove 是一個高頻函式,看到這裡,如果你並沒有仔細看我的程式碼,那你對我採用的 canvas 畫圖方式可能不太瞭解,下面這個是 touchmove 函式幹了哪些事:
- 先判斷,如果當前處於未選中一個密碼狀態,則繼續監視當前的位置,直到選中第一個密碼,進入第二步;
- 進入 update 函式,update 函式主要幹四件事,重繪圓(密碼)、判斷當前位置、重繪點、重繪線;
第二步是一個很揪心的動作,為什麼每次都要重繪圓,點和線呢?
上面這個圖可以很好的說明問題,因為在設定或驗證密碼的過程中,我們需要用一條線來連線觸點到當前的最後一個密碼,並且當 touchmove 的時候,能看到它們在變化。這個功能很棒,可以勾勒出 touchmove 的軌跡。
但是,這就必須要時刻重新整理 canvas,效能大大地降低,重新整理的那可是整個 canvas。
因為 canvas 只有一個,既要畫背景圓(密碼),又要畫已選密碼的點,和折線。這其中好多步驟,自始至終只需要一次就好了,比如背景圓,只需在啟動的時候畫一次,已選密碼,只要當 touchCircles 新加元素的時候才會用一次,還不用重繪,只要畫就可以了。折線分成兩部分,一部分是已選密碼之間的連線,還有就是最後一個密碼點到當前觸點之間的連線。
如果有兩個 canvas 就好了,一個儲存靜態的,一個專門用於重繪。
為什麼不可以有呢!
我的解決思路是,現在有兩個 canvas,一個在底層,作為描繪靜態的圓、點和折線,另一個在上層,一方面監聽 touchmove 事件,另一方面不停地重繪最後一個密碼點的圓心到當前觸點之間的線。如果這樣可以的話,touchmove 函式執行一次的效率大大提高。
插入第二個 canvas:
1 2 3 4 5 6 |
var canvas2 = canvas.cloneNode(canvas, true); canvas2.style.position = 'absolute';//讓上層 canvas 覆蓋底層 canvas canvas2.style.top = '0'; canvas2.style.left = '0'; this.el.appendChild(canvas2); this.ctx2 = canvas2.getContext('2d'); |
要改換對第二個 ctx2 進行 touch 監聽,並設定一個 this.reDraw
引數,表示有新的密碼新增進來,需要對點和折線新增新內容, update 函式要改成這樣:
1 2 3 4 5 6 7 8 9 |
update: function(p){ // 更新 touchmove this.judgePos(p); // 每次都要判斷 this.drawLine2TouchPos(p); // 新加函式,用於繪最後一個密碼點點圓心到觸點之間的線 if(this.reDraw){ // 有新的密碼加進來 this.reDraw = false; this.drawPoints(); // 新增新點 this.drawLine();// 新增新線 } }, |
1 2 3 4 5 6 7 8 9 10 11 12 |
drawLine2TouchPos: function(p){ var len = this.touchCircles.length; if(len >= 1){ this.ctx2.clearRect(0, 0, this.width, this.width); // 先清空 this.ctx2.beginPath(); this.ctx2.lineWidth = 3; this.ctx2.moveTo(this.touchCircles[len - 1].x, this.touchCircles[len - 1].y); this.ctx2.lineTo(p.x, p.y); this.ctx2.stroke(); this.ctx2.closePath(); } }, |
相應的 drawPoints 和 drawLine 函式也要對應修改,由原理畫所有的,到現在只需要畫新加的。
效果怎麼樣:
move 函式執行多次,而其他函式只有當新密碼加進來的時候才執行一次。
加入節流函式
之前也已經說過了,這個 touchmove 函式執行的次數比較多,儘管我們已經用兩個 canvas 對重繪做了很大的優化,但 touchmove 還是有點大開銷。
這個時候我想到了防抖動和節流,首先防抖動肯定是不行的,萬一我一直處於 touch 狀態,重繪會延遲死的,這個時候節流會好一些。防抖和節流。
先寫一個節流函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
throttle: function(func, delay, mustRun){ var timer, startTime = new Date(), self = this; return function(){ var curTime = new Date(), args = arguments; clearTimeout(timer); if(curTime - startTime >= mustRun){ startTime = curTime; func.apply(self, args); }else{ timer = setTimeout(function(){ func.apply(self, args); }, delay) } } } |
節流函式的意思:在延遲為 delay 的時間內,如果函式再次觸發,則重新計時,這個功能和防抖動是一樣的,第三個引數 mustRun 是一個時間間隔,表示在時間間隔大於 mustRun 後的一個函式可以立即直接執行。
然後對 touchmove 的回撥函式進行改造:
1 2 3 4 5 6 7 8 9 10 11 |
var t = this.throttle(function(e){ e.preventDefault ? e.preventDefault() : null; e.stopPropagation ? e.stopPropagation() : null; var p = this.getTouchPos(e); if(this.touchFlag){ this.update(p); }else{ this.judgePos(p); } }, 16, 16) this.canvas2.addEventListener('touchmove', t, false) |
關於 delay 和 mustRun 的時間間隔問題,web 效能裡有一個 16ms 的概念,就是說如果要達到每秒 60 幀,間隔為 1000/60 大約為 16 ms。如果間隔大於 16ms 則 fps 會比 60 低。
鑑於此,我們這裡將 delay 和 mustRun 都設為 16,在極端的情況下,也就是最壞的情況下,或許需要 15 + 15 = 30ms 才會執行一次,這個時候要設定兩個 8 才合理,不過考慮到手指活動是一個連續的過程,怎麼可能會每 15 秒執行一次,經過線上測試,發現設定成 16 效果還不錯。
效能真的能優化嗎,我們來看兩個圖片,do 和 wantdo 表示真實執行和放到節流函式中排隊準備執行。
當 touchmove 速度一般或很快的時候:
當 touchmove 速度很慢的時候:
可以看出來,滑動過程中,速度一般和快速,平均優化了一半,慢速效果也優化了 20 到 30% 之間,平時手勢鎖解鎖時候,肯定速度很快。可見,節流的優化還是很明顯的。
關鍵是,優化之後的流程性,沒有受到任何影響。
這個節流函式最終還是出現了一個 bug:由於是延遲執行的,導致 e.preventDefault
失效,在手機瀏覽器向下滑會出現重新整理的情況,這也算事件延遲的一個危害吧。
解決辦法:在節流函式提前取消預設事件:
1 2 3 4 5 6 7 8 9 10 |
throttle: function(func, delay, mustRun){ var timer, startTime = new Date(), self = this; return function(e){ if(e){ e.preventDefault ? e.preventDefault() : null; //提前取消預設事件,不要等到 setTimeout e.stopPropagation ? e.stopPropagation() : null; } ... } } |
總結
大概花了三天左右的時間,將這個 H5 的手勢解鎖給完成,自己還是比較滿意的,雖然可能達不到評委老師的認可,不過自己在做的過程中,學習到了很多新知識。