Dies ist die Auswahl-Hausaufgabenfrage für das dritte 360-Front-End-Star-Projekt. Mehr als 600 Studierende beteiligten sich an der Beantwortung der Fragen und 60 Studierende bestanden schließlich. Diese 60 Studenten haben gute Arbeit geleistet und ihre Ideen, ihr Codierungsstil und ihre Funktionsvervollständigung sind recht vielversprechend. Es wurde jedoch auch festgestellt, dass viele Studenten vollständige Funktionen implementieren können Sie kennen die Anforderungen, wissen aber nicht, wie es geht. Entwerfen Sie eine offene API, oder mit anderen Worten, wie Sie Produktanforderungen und zukünftige Änderungen analysieren und vorhersagen können, um zu entscheiden, was sein soll offen und was gekapselt werden soll. Es geht nicht darum, ob die Antwort richtig ist oder nicht, sondern um Erfahrung.
Hier stelle ich eine Referenzversion zur Verfügung. Dies bedeutet nicht, dass diese Version die beste ist, aber wir können anhand dieser Version analysieren, wie wir denken und umsetzen sollten.
Komponentendesign umfasst im Allgemeinen die folgenden Prozesse:
Anforderungen verstehen
Technische Auswahl
Strukturelles (UI) Design
Daten- und API-Design
Prozessdesign
Kompatibilität und Detailoptimierung
Tools & Engineering
Diese Prozesse treten nicht beim Entwerfen jeder Komponente auf, aber im Allgemeinen wird ein Projekt immer auf Probleme stoßen, die während einiger dieser Prozesse gelöst werden müssen. Lassen Sie es uns im Folgenden kurz analysieren.
Bei der Aufgabe selbst geht es nur darum, eine gemeinsame GestePasswort-UI-Interaktion zu entwerfen. Sie können zwischen den beiden Zuständen wechseln, indem Sie das Bestätigungspasswort auswählen und Festlegen des Passworts , jeder Status hat seinen eigenen Prozess. Daher kapseln die meisten Schüler die Statusumschaltung und den Prozess der gesamten Komponente entsprechend den Anforderungen. Einige Studenten stellen bestimmte Konfigurationsfunktionen im UI-Stil bereit, aber im Grunde kann kein Schüler die Knoten im Prozess und Zustandswechselprozess öffnen. Wenn diese Komponente von Benutzern verwendet werden soll, muss sie natürlich den Prozessknoten öffnen. Mit anderen Worten, der Benutzer muss entscheiden, welche Vorgänge beim Festlegen des Kennworts, beim Überprüfen des Kennworts usw. ausgeführt werden sollen. und welche Vorgänge nach erfolgreicher Passwortüberprüfung ausgeführt werden sollen. Diese Entscheidung können Komponentenentwickler nicht im Namen der Benutzer treffen.
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);
Der Kern der UI-Anzeige dieses Problems ist das Neun-Quadrat-Raster und der ausgewählte kleine Punkt. Technisch gesehen haben wir drei Optionen Lösung: DOM/Canvas/SVG, alle drei können die Haupt-UI implementieren.
Wenn Sie DOM verwenden, ist die Verwendung eines Flex-Layouts am einfachsten, das responsiv gestaltet werden kann.
DOM implementiert das Zeichnen
Der Vorteil der Verwendung von DOM besteht darin, dass die Reaktionsfähigkeit einfach zu implementieren ist, die Ereignisverarbeitung einfach ist und das Layout nicht kompliziert ist (aber es ist). etwas komplizierter als Canvas), aber die Länge und Neigung des Schrägstrichs (in der Demo nicht gezeichnet) müssen berechnet werden.
Zusätzlich zur Verwendung von DOM ist es auch sehr praktisch, Canvas zum Zeichnen zu verwenden:
Canvas implementiert das Zeichnen
Es gibt zwei kleine Details bei der Verwendung von Canvas. Das erste ist Um Reaktionsfähigkeit zu erreichen, können Sie DOM verwenden, um einen quadratischen Container zu konstruieren:
#container { position: relative; overflow: hidden; width: 100%; padding-top: 100%; height: 0px; background-color: white; }
Hier verwenden wir padding-top:100%, um die Containerhöhe so zu erweitern, dass sie der entspricht Behälterbreite.
Das zweite Detail besteht darin, dass wir, um einen klaren Anzeigeeffekt auf dem Retina-Bildschirm zu erzielen, die Breite und Höhe der Leinwand verdoppeln und sie dann durch Transformation verkleinern, um sie an die Breite und Höhe des Containers anzupassen: Maßstab (0,5).
#container canvas{ position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.5); }
Da die Positionierung von Canvas absolut ist, stimmen seine Standardbreite und -höhe nicht mit der Breite und Höhe des Containers überein und müssen über JS festgelegt werden:
let width = 2 * container.getBoundingClientRect().width; canvas.width = canvas.height = width;
Auf diese Weise können wir die Benutzeroberfläche implementieren, indem wir durchgezogene Kreise und verbundene Linien auf Canvas zeichnen. Die konkrete Methode wird im weiteren Inhalt näher erläutert.
Schließlich werfen wir einen Blick auf das Zeichnen mit SVG:
SVG-Implementierung des Zeichnens
Da die API für native SVG-Operationen nicht sehr praktisch ist, wird die Snap.svg-Bibliothek verwendet wird hier verwendet, um es zu implementieren. Es ist der Verwendung von Canvas sehr ähnlich, daher werde ich hier nicht auf Details eingehen.
Das Problem bei SVG ist, dass die Kompatibilität mit Mobilgeräten nicht so gut ist wie bei DOM und Canvas.
Aufgrund der oben genannten drei Situationen habe ich mich schließlich für die Verwendung von Canvas zur Implementierung entschieden.
Die DOM-Struktur ist relativ einfach, wenn sie mit Canvas implementiert wird. Um reaktionsfähig zu sein, müssen wir einen quadratischen Container mit adaptiver Breite implementieren. Die Methode wurde bereits eingeführt. Erstellen Sie dann eine Leinwand im Container. Hier ist zu beachten, dass wir Canvas schichten sollten. Dies liegt daran, dass im Rendering-Mechanismus von Canvas der zu aktualisierende Bereich aktualisiert und neu gezeichnet werden muss, um den Inhalt der Leinwand zu aktualisieren. Da wir häufig wechselnde Inhalte und grundsätzlich unveränderte Inhalte in Ebenen verwalten müssen, kann dies die Leistung erheblich verbessern.
In 3 Schichten unterteilt
在这里我把 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 的时候思考真正的需求,判断什么该开放、什么该封装
做好技术调研和核心方案研究,选择合适的方案
优化和解决细节问题
Das obige ist der detaillierte Inhalt vonNative JS implementiert die Komponenteninstanzmethode zum Entsperren von Gesten (Bild). Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!