Springhead

Je voudrais mourir pendant mon sommeil.

0%

可配置抽奖转盘遇到的一些坑和处理方法

前阵子因为需求改动,把之前写过的可配置抽奖转盘的抽奖判断逻辑重写了一遍,还解决了一个兼容性问题,简单记录下。

目录
一、setTimeout制作动画在Safari中掉帧
二、用状态机(伪)管理不同抽奖逻辑


一、setTimeout制作动画在Safari中掉帧

代码的实现逻辑是使用canvas根据奖品数量动态绘制转盘角度生成静止的一帧,然后使用定时器修改绘制的起始角度,每隔一段时间重绘一张转盘实现动画效果。

问题出在最开始是使用setTimeout实现这个定时绘制的功能,但是转动效果在苹果手机上非常糟糕,掉帧极其严重。在微信开发者工具上也复现不出问题,就猜测是Safari的兼容性问题,发现setTimeout和setInterval在Safari上的兼容性不大好。在同事的提示下换成了requestAnimationFrame,动画就流畅了很多。

至于原因懒得再总结了,我觉得这个人讲的很清楚。requestAnimationFram和setTimeout执行的先后

  • requestAnimationFrame 执行步伐跟着系统的绘制频率走,就是说屏幕分辨率 和 屏幕尺寸会影响requestAnimationFrame的回调函数执行时间。
  • setTimeout 执行只是在内存中通过设置一个间隔时间来运行代码,HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。同时setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,所以 setTimeout 的实际执行时机一般要比其设定的时间晚一些。

两者执行的快慢影响因素:

  • requestAnimationFrame受系统的绘制频率影响,即屏幕分辨率 和 屏幕尺寸
  • setTimeout 受任务队列和页面渲染有关

比较麻烦的点在于动画逻辑需要修改,大概修改如下。

使用setTimeout的大概写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const interval = 10;
const result = "一等奖";
for (let i = 1; i < condition; i += 1) {
const timeId = setTimeout(() => {

// 一些需要循环的操作
setStart(interval * i);

// 满足停止条件时结束,没错这动画我居然包了层promise写
if (i === condition) {
resolve(result);
clearTimeout(timeId);
}

}, 100);
}

使用requestAnimationFrame的大概写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const interval = 10;
const result = "一等奖";

let i = 1;
// 回调函数
function animloop() {
i += 1;
// 一些需要循环的操作
setStart(interval * i);

// 在没有满足条件前递归
if (i < condition) {
window.requestAnimationFrame(animloop);
}

// 满足停止条件时结束
if (i === condition) {
resolve(result);
}
}

// 第一帧渲染
window.requestAnimationFrame(animloop);

二、用状态机(伪)管理不同抽奖逻辑

因为这是个可配置的抽奖活动,导致灵活度非常高。点击抽奖按钮之后需要做的条件判断非常之多,而且因为可配置的原因也非常复杂.再加上需求的变化,原本分散在不同组件中的判断逻辑需要整合到一个按钮上,于是就干脆重写了判断逻辑。

比如点击抽奖按钮之后,需要查看用户的抽奖次数、信息填写情况、配置填写用户信息的时机、活动时间等等等。

原先的做法是在不同组件内进行判断,并将不同判断结果发到一个中心化的数据中心管理,再在不同需要判断的地方调用这些结果进行一层一层判断。这样写的麻烦之处在于有很多条件的判断是耦合的,不是很好拆,而且如果需求一改动,在耦合的判断逻辑下(而且还是不同组件),改起来很恶心。虽然主要原因还是我原先代码写的太烂了。

后来想起来隔壁后端同事之前在搞可配置的发布流程管理,这一层层的条件逻辑判断以及状态改变达到不同页面(对抽奖而言就是不同提示),似乎有点眼熟。记得之前听到测试妹子说是用有限状态机写的,于是走投无路(并没有)的我研究了起状态机。

鉴于我很懒,只摘录了我觉得比较有用的概念。

  • 第一个是 State ,状态。一个状态机至少要包含两个状态。例如上面自动门的例子,有 open 和 closed 两个状态。
  • 第二个是 Event ,事件。事件就是执行某个操作的触发条件或者口令。对于自动门,“按下开门按钮”就是一个事件。
  • 第三个是 Action ,动作。事件发生以后要执行动作。例如事件是“按开门按钮”,动作是“开门”。编程的时候,一个 Action 一般就对应一个函数。
  • 第四个是 Transition ,变换。也就是从一个状态变化为另一个状态。例如“开门过程”就是一个变换。

比如抽奖流程:点击开始抽奖=>验证身份=>验证活动时间=>验证抽奖剩余次数=>抽奖

上面这个是我随便写的,大概意思是一个抽奖行为是有流程的,虽然只是一个点击按钮其实是有非常多状态的改变的。然后在不同状态下会有不同需要触发的行为,并且它还要改变到下一个状态。

当使用这套逻辑时,整个流程的判断就可以都放到一起,代码会好理解很多,改起来也没那么恶心了。

为啥说伪呢,因为下面这个是一个简陋实现版乞丐版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const [lotteryState, setLotteryState] = useState<string | undefined>();

const getState = async () => {
switch (lotteryState) {
case 'start':
if (条件1) {
setLotteryState('状态A');
} else {
store.reducers.doActionEnd;
setLotteryState('end');
}
break;
case '状态A':
if (条件2) {
store.reducers.doAction111;
setLotteryState('状态B');
} else {
store.reducers.doAction222;
setLotteryState('状态C');
}
break;
case '状态B':
if (条件3) {
store.reducers.doAction444;
setLotteryState('状态C');
} else {
store.reducers.doActionEnd;
setLotteryState('end');
}
break;
case '状态C':
if (条件4) {
store.reducers.doAction555;
setLotteryState('end');
} else {
store.reducers.doActionEnd;
setLotteryState('end');
}
break;
case 'end':
break;
default:
break;
}
}

const lottery = async () => {
setLotteryState('start');
};

return (
<img onClick={lottery}/>
);