これは、第 3 回 360 フロントエンド スター プロジェクトの選択の宿題の質問です。 600名以上の学生が解答に参加し、最終的に60名が合格しました。これら 60 人の学生は、アイデア、コーディング スタイル、および機能の完成度に非常に期待を寄せていますが、たとえば、多くの学生がニーズに応じて完全な機能を実装できることもわかりました。 、しかし、彼らはそれを行う方法を知りませんオープンデザインAPI、つまり、何をオープンにし、何をカプセル化するかを決定するために、製品のニーズと将来の変更を分析および予測する方法。答えが正しいかどうかではなく、経験が重要です。
ここでは、このバージョンが最適であるという意味ではありませんが、このバージョンを通じて、このような複雑な UI 要件や実装に遭遇したときにどのように考えるべきかを分析できるようにするためです。
コンポーネント設計には通常、次のプロセスが含まれます:
要件の理解
技術的な選択
構造(UI)設計
と API 設計
プロセス設計
互換性と詳細の最適化
ツールとエンジニアリング
これらのプロセスは、すべてのコンポーネントを設計するときに発生するわけではありませんが、一般的に言えば、プロジェクトの実行中に解決する必要がある問題が発生します。プロセスの一部。以下で簡単に分析してみましょう。
割り当て自体は、共通の ジェスチャーパスワード UI インタラクションを設計することだけであり、パスワードの確認とパスワードの設定を選択することで 2 つの 状態 を切り替えることができます。したがって、ほとんどの学生は、ニーズに応じてコンポーネント全体の状態切り替えとプロセスをカプセル化します。一部の学生は、特定の 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 表示の中核は、9 正方形のグリッドと選択された小さなドットです。技術的に言えば、DOM/Canvas/SVG の 3 つすべてがメインです。 UIも実装可能です。
DOM を使用する場合、最も簡単な方法は、応答性に優れたフレックス レイアウトを使用することです。
DOMで描画を実現
DOMを使うメリットは、レスポンシブ実装が簡単、イベント処理がシンプル、レイアウトが複雑ではない(ただしCanvasより若干複雑)ですが、長さや傾きがスラッシュの部分(デモでは描かれていません) 計算が必要です。
DOM の使用に加えて、Canvas を使用して描画することも非常に便利です。
Canvas は描画を実装します
Canvas の使用には 2 つの細かい点があります。 1 つ目は、DOM を使用して正方形を構築することができます。コンテナ:
#container { position: relative; overflow: hidden; width: 100%; padding-top: 100%; height: 0px; background-color: white; }
ここでは、padding-top:100% を使用して、コンテナの幅と等しくなるようにコンテナの高さを拡張します。
2 番目の詳細は、Retina スクリーン上で明確な表示効果を得るために、Canvas の幅と高さを 2 倍にしてから、transform:scale(0.5) によってコンテナの幅と高さに一致するように縮小します。 。
#container canvas{ position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.5); }
Canvas の位置は絶対的なため、そのデフォルトの幅と高さはコンテナの幅と高さとは等しくありません:
let width = 2 * container.getBoundingClientRect().width; canvas.width = canvas.height = width;
このようにして、描画することができます。 Canvas上に実線と実線を配置したUIを実装してみましょう。具体的な方法については、後のコンテンツで詳しく説明します。
最後に、SVG を使用した描画を見てみましょう:
描画の SVG 実装
SVG ネイティブ操作の API はあまり便利ではないため、ここでは Snap.svg ライブラリを使用します。実装は Canvas を使用するのと似ています。したがって、ここでは詳細には触れません。
SVG の問題は、モバイル互換性が DOM や Canvas ほど良くないことです。
上記 3 つの状況を踏まえ、最終的に Canvas を使用して実装することにしました。
Canvas を使用して実装した場合、DOM 構造は比較的単純になります。応答性を高めるには、適応幅を備えた正方形のコンテナを実装する必要があります。このメソッドは以前に紹介しました。次に、コンテナ内に Canvas を作成します。ここで注意すべき点は、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 了。在这里,我们将组件功能分解一下,独立出一个单纯记录手势的 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('p'); 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 的时候思考真正的需求,判断什么该开放、什么该封装
做好技术调研和核心方案研究,选择合适的方案
优化和解决细节问题
以上がネイティブ JS はジェスチャ ロック解除コンポーネント インスタンス メソッドを実装します (写真)の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。