这是第三届 360 前端星计划的选拔作业题。600多名学生参与了解答,最后通过了60人。这60名同学完成的不错,思路、代码风格、功能完成度颇有可取之处,不过也有一些欠考虑的地方,比如发现很多同学能按照需求实现完整的功能,但是不知道应当如何设计开放的 API,或者说,如何分析和预判产品需求和未来的变化,从而决定什么应当开放,什么应当封装。这无关于答案正确与否,还是和经验有关。
在这里,我提供一个参考的版本,并不是说这一版就最好,而是说,通过这一版,分析当我们遇到这样的比较复杂的 UI 需求的时候,我们应该怎样思考和实现。
组件设计一般来说包括如下一些过程:
这些过程并不是每个组件设计的时候都会遇到,但是通常来说一个项目总会在其中一些过程里遇到问题需要解决。下面我们来简单分析一下。
作业本身只是说设计一个常见的手势密码的 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 展现的核心是九宫格和选中的小圆点,从技术上来讲,我们有三种可选方案: DOM/Canvas/SVG,三者都是可以实现主体 UI 的。
如果使用 DOM,最简单的方式是使用 flex 布局,这样能够做成响应式的。
DOM 实现绘制
使用 DOM 的优点是容易实现响应式,事件处理简单,布局也不复杂(但是和 Canvas 比起来略微复杂),但是斜线(demo 里没有画)的长度和斜率需要计算。
除了使用 DOM 外,使用 Canvas 绘制也很方便:
Canvas 实现绘制
用 Canvas 实现有两个小细节,第一是要实现响应式,可以用 DOM 构造一个正方形的容器:
#container {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 100%;
height: 0px;
background-color: white;
}
在这里我们使用 padding-top:100%
撑开容器高度使它等于容器宽度。
第二个细节是为了在 retina 屏上获得清晰的显示效果,我们将 Canvas 的宽高增加一倍,然后通过 transform: scale(0.5)
来缩小到匹配容器宽高。
#container canvas{
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.5);
}
由于 Canvas 的定位是 absolute,它本身的默认宽高并不等于容器的宽高,需要通过 JS 设置:
let width = 2 * container.getBoundingClientRect().width;
canvas.width = canvas.height = width;
这样我们就可以通过在 Canvas 上绘制实心圆和连线来实现 UI 了。具体的方法在后续的内容里有更详细的讲解。
最后我们来看一下用 SVG 绘制:
SVG 实现绘制
由于 SVG 原生操作的 API 不是很方便,这里使用了 Snap.svg 库,实现起来和使用 Canvas 大同小异,这里就不赘述了。
SVG 的问题是移动端兼容性不如 DOM 和 Canvas 好。
综合上面三者的情况,最终我选择使用 Canvas 来实现。
使用 Canvas 实现的话 DOM 结构就比较简单。为了响应式,我们需要实现一个自适应宽度的正方形容器,方法前面已经介绍过。接着在容器中创建 Canvas。这里需要注意的一点是,我们应当把 Canvas 分层。这是因为 Canvas 的渲染机制里,要更新画布的内容,需要刷新要更新的区域重新绘制。因为我们有必要把频繁变化的内容和基本不变的内容分层管理,这样能显著提升性能。
在这里我把 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('div');
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 工程。
以上就是今天要讲的全部内容,这里面有几个点我想再强调一下:
最后,如有任何问题,欢迎大家在下方评论区探讨。
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。