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 표시의 핵심은 9개의 정사각형 그리드와 선택된 작은 점입니다. 기술적으로 말하면 다음과 같은 세 가지 옵션이 있습니다. DOM/Canvas/SVG, 세 가지 모두 기본 UI를 구현할 수 있습니다.
DOM을 사용하는 경우 가장 쉬운 방법은 반응형으로 만들 수 있는 플렉스 레이아웃을 사용하는 것입니다.
DOM을 사용하는 것 외에도 Canvas를 사용하여 그리는 것도 매우 편리합니다.
Canvas는 그리기를 구현합니다. Canvas 사용에는 두 가지 작은 세부 사항이 있습니다. 반응성을 얻으려면 DOM을 사용하여 정사각형 컨테이너를 구성할 수 있습니다.
#container { position: relative; overflow: hidden; width: 100%; padding-top: 100%; height: 0px; background-color: white; }
여기에서는 padding-top:100%를 사용하여 컨테이너 높이를 컨테이너 너비와 동일하게 늘립니다. .
두 번째 세부 사항은 레티나 화면에서 선명한 표시 효과를 얻기 위해 캔버스의 너비와 높이를 두 배로 늘린 다음 변환을 통해 컨테이너의 너비와 높이에 맞게 줄입니다. 규모(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;
이런 식으로 캔버스에 실선과 연결된 선을 그려서 UI를 구현할 수 있습니다. 구체적인 방법은 이어지는 내용에서 더 자세히 설명하겠습니다.
마지막으로 SVG를 사용한 드로잉을 살펴보겠습니다.
드로잉의 SVG 구현
네이티브 SVG 작업을 위한 API는 그리 편리하지 않기 때문에 Snap.svg 라이브러리 여기서는 Canvas를 사용하는 것과 매우 유사하므로 여기서는 자세히 설명하지 않겠습니다.
SVG의 문제점은 DOM과 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 了。在这里,我们将组件功能分解一下,独立出一个单纯记录手势的 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 的时候思考真正的需求,判断什么该开放、什么该封装
做好技术调研和核心方案研究,选择合适的方案
优化和解决细节问题
위 내용은 Native JS는 제스처 잠금 해제 구성 요소 인스턴스 메서드를 구현합니다(그림).의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!