之前一直都在用框架寫東西涝缝,也沒造過什么輪子,所以一直想用原生JS寫點什么東西榄笙,無奈自己水平又有限搭盾,因此只能上網(wǎng)找別人造好的輪子,然后自己研究痒钝。本項目并非原創(chuàng)秉颗,只是作為一個學(xué)習(xí)的案例。本篇文章用來記錄自己對該項目的學(xué)習(xí)總結(jié)送矩。
一.聲明:
- 本項目全都使用
es6/es7
語法進(jìn)行編寫蚕甥,并且使用了多頁面開發(fā)環(huán)境進(jìn)行打包編譯。因此可以作為前端進(jìn)階的一個項目栋荸,如果你是新手建議去這里 - 原項目文章寫的非常好菇怀,組件編寫的流程說的很清楚,詳情見原項目地址
- 自己改(zhao)造(chao)的項目地址 (帶有大量注釋)
- 原文涉及到的在這里就不再提了晌块,本文主要提及一些原文中沒有說到的東西
- 在看本篇文章之前爱沟,先看原項目
- 本文只是自己對項目源碼的理解,如有不對匆背,請及時指出
- 多頁面開發(fā)環(huán)境的使用方法見這里
- 演示地址 僅支持移動端
二.知識點
1. change事件
先看MDN上的介紹:
從上述介紹來看呼伸,change
事件可以冒泡,因此可以對表單元素使用事件代理钝尸,先看一段代碼:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<div id="handler">
<label>
<input type="radio" name="mode" value="check" id="checkmode">
驗證密碼
</label>
<label>
<input type="radio" name="mode" value="update" checked>
設(shè)置密碼
</label>
</div>
<script type="text/javascript">
var handler = document.querySelector("#handler");
var checkmode = document.querySelector("#checkmode")
handler.addEventListener("change",function (){
console.log("success");
})
setTimeout(function (){
checkmode.checked = 'checked'
},2000)
</script>
</body>
</html>
這段代碼的意思是:一開始讓設(shè)置密碼單選按鈕被選中括享,2s之后再讓驗證密碼按鈕選中,觸發(fā)change事件
珍促,這里change
事件是被代理的铃辖。經(jīng)過測試你會發(fā)現(xiàn):兩秒后驗證密碼單選按鈕被選中,但是change
事件回調(diào)沒有被觸發(fā)猪叙,WTF?娇斩。原來是這么回事仁卷,再看MDN上的一段描述:
事件觸發(fā)取決于表單元素的類型(type)和用戶對標(biāo)簽的操作:
1.<input type="radio">
和 <input type="checkbox">
的默認(rèn)選項被修改時(通過點擊或者鍵盤事件);
2.當(dāng)用戶完成提交動作時 (例如:點擊了<select>
中的一個選項,從 <input type="date">
標(biāo)簽選擇了一個日期犬第,通過 <input type="file">
標(biāo)簽上傳了一個文件五督,等 );
3.當(dāng)標(biāo)簽的值被修改并且失焦后,但并未進(jìn)行提交 (例如:對<textarea>
或者<input type="text">
的值進(jìn)行編輯后瓶殃。).
checkmode.checked = 'checked'
觸發(fā)了驗證密碼單選按鈕的change
事件充包,但是沒有發(fā)生冒泡,只有單選按鈕的鼠標(biāo)事件或者鍵盤事件被觸發(fā)時遥椿,change
事件才會冒泡基矮。因此解決辦法是使用click
方法:
click方法可以用來模擬鼠標(biāo)左鍵單擊一個元素。
當(dāng)在支持click方法的元素上使用該方法時會觸發(fā)該元素的 click 事件
checkmode.click()
2.實現(xiàn)一個高度隨寬度自適應(yīng)的正方形
使用margin或者padding
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<style type="text/css">
#container{
width: 400px;
}
#main{
width: 100%;
padding-bottom: 100%;
height: 0;
background: red;
}
</style>
</head>
<body>
<div id="container">
<div id="main"></div>
</div>
</body>
</html>
3.touch事件取消默認(rèn)行為
container.addEventListener('touchstart',(evt)=>{
evt.preventDefault();
},{passive:false})
passive
的默認(rèn)值是false
,當(dāng)passive=true
表示 listener
永遠(yuǎn)不會調(diào)用 preventDefault()
冠场。如果listener
仍然調(diào)用了這個函數(shù)家浇,客戶端將會忽略它并拋出一個控制臺警告。
三. 實現(xiàn)細(xì)節(jié):
1.狀態(tài)切換
先上一張圖:
三種狀態(tài)之間進(jìn)行切換:
- 驗證密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)或者與設(shè)置的不匹配則再次返回驗證密碼狀態(tài)
- 第一次設(shè)置密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)則再次返回第一次設(shè)置密碼狀態(tài)碴裙,否則進(jìn)行第二次重復(fù)密碼設(shè)置
- 第二次重復(fù)設(shè)置密碼狀態(tài):如果驗證的密碼不足四位(四個圓圈)或者與第一次設(shè)置的不匹配則再次返第一次設(shè)置密碼狀態(tài)钢悲,否則轉(zhuǎn)移到驗證密碼狀態(tài)
import Recorder from './recorder.js';
import {defaultFunctions} from './config.js'
export default class Locker extends Recorder{
static get ERR_NOT_MISMATCH(){
return "not mismath"
}
constructor(options){
options.check = Object.assign({},defaultFunctions.check,options.check);
options.update = Object.assign({},defaultFunctions.update,options.update);
/*
super關(guān)鍵字:
在子類的構(gòu)造函數(shù)中,只有調(diào)用super之后舔株,才可以使用this關(guān)鍵字莺琳,否則會報錯。
這是因為子類實例的構(gòu)建载慈,是基于對父類實例加工惭等,只有super方法才能返回父類實例
*/
super(options);
}
async check(password){
let checked = this.options.check.checked;
let res = await this.record();
if(!res.err&&password!==res.records){
res.err = Locker.ERR_NOT_MISMATCH
}
checked.call(this,res);
this.check(password);
}
async update(){
let beforeRepeat = this.options.update.beforeRepeat,
afterRepeat = this.options.update.afterRepeat;
let first = await this.record();
beforeRepeat.call(this,first);
if(first.err){
return this.update();
}
let second = await this.record();
if(!second.err&&second.records!==first.records){
second.err = Locker.ERR_NOT_MISMATCH
}
afterRepeat.call(this,second);
this.update();
}
}
2. 解讀Recoder
父類中的record
方法
record
方法中主要有touchstart
、touchmove
和touchend
三個事件的回調(diào)函數(shù).并且record
是一個異步的操作办铡,因此調(diào)用的時候要在async/await
中調(diào)用辞做。
-
handler
(touchstart和touchmove的事件回調(diào))主要用來畫固定線條、圓圈和移動線條寡具。詳情見下面的注釋
let handler = evt => {
let {clientX, clientY} = evt.changedTouches[0],
{bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);
/*
畫固定線條秤茅、圓圈、移動線條的步驟:
1.遍歷九個點童叠,與touchPoint求距離框喳,如果小于outerRaius,則該點就是手勢劃過的點拯钻,畫圓圈
2.判斷密碼記錄數(shù)組有沒有值帖努,如果有撰豺,取出數(shù)組中最后一個值最為畫固定線條的起點粪般,第一步中
遍歷到的點作為固定線條的終點
3.把第一步遍歷到的點從this.circles刪除并添加到records數(shù)組中(用于記錄密碼的數(shù)組)
4.判斷records數(shù)組長度,如果大于0污桦,數(shù)組中最后一個點作為移動線條的起點亩歹,手勢移動的點作為終點
并且在畫移動線條的時候要先清除畫布,再重繪
*/
for(let i = 0; i < this.circles.length; i++){
let point = this.circles[i],
x0 = point.x,
y0 = point.y;
if(distance(point, touchPoint) < outerRadius){
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, x1, y1, x0, y0);
}
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);
-
done
主要用來移出事件回調(diào)并且resolve
異步操作的結(jié)果,因為主要是touchend
決定了異步操作的結(jié)果小作,因此把done
方法封裝在了一個promise
中
let done;
// 異步操作的結(jié)束取決于什么時候touchend
let promise = new Promise((resolve, reject) => {
done = evt => {
moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
if(!records.length) return;//點擊空白處不執(zhí)行下面
circleCanvas.removeEventListener('touchstart', handler);
circleCanvas.removeEventListener('touchmove', handler);
circleCanvas.removeEventListener('touchend', done);
let err = records.length < options.minPoints ? Recorder.ERR_NOT_ENOUGH_POINTS : null;
//這里可以選擇一些復(fù)雜的編碼方式亭姥,本例子用最簡單的直接把坐標(biāo)轉(zhuǎn)成字符串
let res = {err, records: records.map(o => o.pos.join('')).join('')};
resolve(res);
};
circleCanvas.addEventListener('touchend', done);
});
3.難點解析
先來一張效果圖:
如圖所示:
一開始單選按鈕處于驗證密碼,我們沒有進(jìn)行任何繪制操作顾稀,當(dāng)點擊設(shè)置密碼進(jìn)行操作時會多出一條來自最開始點擊的圓圈的射線达罗。
這是因為,一開始處于驗證密碼狀態(tài)時静秆,調(diào)用了check
方法粮揉,而check
方法中調(diào)用了record
,而每record
一次就會給canvas
綁定事件回調(diào),這樣當(dāng)點擊設(shè)置密碼進(jìn)行繪制時,先調(diào)用了update
方法,update
又調(diào)用了一次record
抚笔,當(dāng)繪制的時候其實是執(zhí)行了兩次事件回調(diào)扶认,并且兩次事件回調(diào)用的是同一個circles
數(shù)組,所以其中一個回調(diào)在執(zhí)行的時候circles
數(shù)組中一直只有一項殊橙,這樣就造成了多出一條射線辐宾。
解決辦法:
在每次record
方法執(zhí)行前先移除上一次record
給canvas
綁定的事件回調(diào),但是怎么在本次record
方法中移出上一次record
方法中綁定的事件回調(diào)呢膨蛮?
那就是在record
方法的底部使用一個閉包,在閉包中使用removeEventListener
,這樣就可以把handler
和done
“閉起來”叠纹,并且把這個閉包賦值給一個實例屬性(是個對象),這樣當(dāng)record
的時候就可以移除上次record
添加的事件回調(diào)。
cancel(){
this.recordingTask&&this.recordingTask.cancel();
}
record(){
let {
circleCanvas,
moveCanvas,
circleCtx,
lineCtx,
moveCtx,
options
} = this;
let {
focusColor,
bgColor,
innerRadius,
outerRadius,
minPoint
} = options;
this.cancel();
circleCanvas.addEventListener("touchstart",(evt)=>{
this.clearPath();
});
let records = [];
const handler = (evt)=>{
let {clientX,clientY} = evt.touches[0],
touchPoint = getCanvasPoint(circleCanvas,clientX,clientY);
for(let i=0;i<this.circles.length;i++){
let point = this.circles[i];
let x0 = point.x,
y0 = point.y;
if(distancePoint(point,touchPoint)<outerRadius){
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,x1,y1,x0,y0);
}
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 done;
// 異步操作的結(jié)束取決于什么時候touchend
let promise = new Promise(resolve=>{
done = ()=>{
moveCtx.clearRect(0,0,moveCanvas.width,moveCanvas.height);
if(!records.length) return ;
circleCanvas.removeEventListener('touchstart', handler);
circleCanvas.removeEventListener('touchmove', handler);
circleCanvas.removeEventListener('touchend', done);
let err = records.length<minPoint?Recorder.ERR_NOT_ENOUGH_POINTS:null;
let res = {err,records:records.map(item=>item.pos.join('')).join('')};
resolve(res)
};
circleCanvas.addEventListener('touchend',done);
});
this.recordingTask = {};
this.recordingTask.cancel = ()=>{
circleCanvas.removeEventListener('touchstart', handler);
circleCanvas.removeEventListener('touchmove', handler);
circleCanvas.removeEventListener('touchend', done);
};
return promise
}
【注】:this.recordingTask = {};
的目的是避免第一次record
的時候cancel
方法不存在