【玩转 JS 函数式编程_016】DIY 实战:巧用延续传递风格(CPS)重构倒计时特效逻辑
文章目录
- 巧用延续传递风格(CPS)重构倒计时特效逻辑
- 1 起因
- 2 换一种思路
- 3 填坑之旅
- 4 复盘与小结
写在前面
都说念念不忘,必有回响。写过的文章也好,看过的视频也罢,其实只要用心积累,不必刻意去死记硬背,这些看似分散的碎片都会在未来某个不经意的瞬间串联起来——人的大脑就是如此神奇。本篇分享就是源于本专栏的一次分享,以及几天前的一个教学视频。函数式编程的思想和实践其实离我们的日常工作和生活也并没有想象中的那么遥远。
巧用延续传递风格(CPS)重构倒计时特效逻辑
1 起因
前几天看到一个讲 JavaScript
函数式编程的系列视频,虽然内容质量还不错,总体感觉可以打到 8 分,但评论区的网友们似乎并不怎么买账,尤其是当大家看到视频中为了解释某个重要概念(比如函数柯里化)而生搬硬套某些写法的时候,更是忍不住在弹幕区疯狂吐槽。这再次印证了李笑来反复强调的 精心挑选演示案例的极端重要性,同时也成功勾起了我对相关话题的兴趣,想着什么时候遇到合适的应用场景了再来分享也不迟。好巧不巧今天就遇到了。
如下图所示,这是一个要求用原生 JavaScript
实现的倒计时特效,每隔一秒就会触发一次上翻动画:
【图 1 利用原生 JavaScript 实现的一个前端倒计时特效】
2 换一种思路
由于之前接过类似的项目,所以第一版很快就搞定了,用的是 WebAPI
中的原生方法 parent.appendChild(firstElem)
,对于已有的元素节点,浏览器会按剪切操作执行该方法。
但是这样一来,每个时间数字上都要提前安插 0
到 9
不等的图片元素,显得十分臃肿和冗余,例如:
<div class="time-item"><ul><li><img src="images/5.png" /></li><li><img src="images/4.png" /></li><li><img src="images/3.png" /></li><li><img src="images/2.png" /></li><li><img src="images/1.png" /></li><li><img src="images/0.png" /></li></ul>
</div>
<div class="time-item"><ul><li><img src="images/9.png" /></li><li><img src="images/8.png" /></li><li><img src="images/7.png" /></li><li><img src="images/6.png" /></li><li><img src="images/5.png" /></li><li><img src="images/4.png" /></li><li><img src="images/3.png" /></li><li><img src="images/2.png" /></li><li><img src="images/1.png" /></li><li><img src="images/0.png" /></li></ul>
</div>
<div class="time">秒</div>
难道就没有其他更简洁的方式了吗?仔细一想,还真找到一个:利用 transition
来监听 margin-top
属性,并且控制过渡效果的开关,也能打到同样的效果。这样一来,每个时间位上的数字卡片总数就从三个(十位的小时)到十个(个位的分钟和秒)变为统一的两个(当前的、后续的)了:
<figure><section class="second"><div class="s1"><ul><li class="num5"></li><li class="num4"></li></ul></div><div class="s2"><ul><li class="num9"></li><li class="num8"></li></ul></div></section><figcaption class="label">秒</figcaption>
</figure>
然后再用 JavaScript
控制每组 li
上的 CSS
样式类就行了。这是第一次重写后的 JavaScript
逻辑:
let timer = null;
function countDown() {if(timer) {return;}console.log('Start counting down...');timer = setInterval(countDownS2, 1000);
}function stopCountDown() {if(timer) {clearInterval(timer);timer = null;}console.log('Stop counting down...');
}
其中 countDownS2
是一个控制个位的秒上翻一页的函数。结果一测就出 Bug
:最最重要的自动停止倒计时的功能忘写了。
于是开启了今天的“套娃”模式……
3 填坑之旅
说起来这个 Bug
并不难修复,就是在秒的个位数每次回 0
时,需要同步看看前面的所有数位是否都已经变为 0
:如果是,则停止计时,否则继续翻页。但这个案例的特殊性就在于,每一个靠右的时间单位都以类似递归的方式影响着相邻左边单位的翻页,且彼此间的换算关系还不一样:
【图 2 各数位的不同标识及各自的进制换算示意图】
按照这个思路,有了下面的改进版:
function countDown() {if(timer) {return;}console.log('Start counting down...');timer = setInterval(() => {if(prevDigitsAllZero(digits)) {stopCountDown();showMessage('时间到!!!');return;}countDownS2();}, 1000);
}// 2nd digit of seconds
function countDownS2() {countDownNext(second2);if(comeToZero(second2)) {if(prevDigitsAllZero([hour1, hour2, minute1, minute2, second1])) {return;}countDownS1();}
}// 1st digit of seconds
function countDownS1() {countDownNext(second1);if(comeToZero(second1)) {if(prevDigitsAllZero([hour1, hour2, minute1, minute2])) {return;}countDownM2();}
}// 2nd digit of minutes
function countDownM2() {countDownNext(minute2);if(comeToZero(minute2)) {if(prevDigitsAllZero([hour1, hour2, minute1])) {return;}countDownM1();}
}// 1st digit of minutes
function countDownM1() {countDownNext(minute1);if(comeToZero(minute1)) {if(prevDigitsAllZero([hour1, hour2])) {return;}countDownH2();}
}// 2nd digit of hours
function countDownH2() {countDownNext(hour2);if(comeToZero(hour2)) {if(prevDigitsAllZero([hour1])) {return;}countDownH1();}
}// 1st digit of hours
function countDownH1() {countDownNext(hour1);if(comeToZero(hour1)) {return;}
}
可以看到,这里的每一个子函数都出现了严重的冗余,因为它们的基本流程都是一致的:
- 看看当前单位是否为 0——
- 若不为 0:则翻动一次左侧相邻的卡片;
- 若为 0:则看看前面所有的单位是否也都为 0 ——
- 若都为 0:则中止执行;
- 若不全为 0:则正常执行后续逻辑。
怎样简化这样的代码呢?我想到了之前更新 JS 函数式编程专栏文章(详见 《【玩转 JS 函数式编程_010】3.2 JS 函数式编程筑基之:以函数式编程的方式活用函数(上)》)时提过的 延续传递风格(Continuation-passing style,即 CPS 风格),重新构建了一个中间函数:
// before:
function countDownS2() {countDownNext(second2);if(comeToZero(second2)) {if(prevDigitsAllZero([hour1, hour2, minute1, minute2, second1])) {return;}countDownS1();}
}// after:
function _countDown(currUnit, prevUnits, nextFn) {countDownNext(currUnit);if(comeToZero(currUnit)) {if(prevDigitsAllZero(prevUnits)) {return;}nextFn();}
}
const digits = [hour1, hour2, minute1, minute2, second1, second2];
const countDownS2 = _countDown(second2, digits.slice(0, 5), countDownS1);
但是问题似乎并没有解决:countDownS2
的定义要看 countDownS1
,而 countDownS1
又是左边的 countDownM2
决定的……一直要递推到最右端的小时十位数翻页逻辑 countDownH1
的确定,整个过程才算结束。这样的重构无非是回调地域的另一种形式:
const countDownS2 = _countDown(second2, digits.slice(0, 5), function() {countDownNext(second1);if(comeToZero(second1)) {if(prevDigitsAllZero(digits.slice(0, 4))) {return;}countDownM2();}
});
貌似只能简化到这一步了,因为第 7 行的 countDownM2()
是一个函数的执行,而非函数引用本身,无法像简化 countDownS2
那样将 countDownM2
作为参数传递。如何将这段选择性执行的代码逻辑以传递函数引用的形式重构呢?
答案是利用 CPS 风格,将目标业务逻辑封装到一个新的回调函数中,再让右侧翻页逻辑使用该回调函数。因此整个逻辑都需要从右向左重新梳理:
// 小时的首位逻辑保持不变
const countDownH1 = () => {countDownNext(hour1);if(comeToZero(hour1)) {return;}
};
const digits = [hour1, hour2, minute1, minute2, second1, second2];
// 用中间函数重构后续的处理逻辑
const countDownH2 = () => _countDown(hour2, [hour1], countDownH1);
const countDownM1 = () => _countDown(minute1, digits.slice(0, 2), countDownH2);
const countDownM2 = () => _countDown(minute2, digits.slice(0, 3), countDownM1);
const countDownS1 = () => _countDown(second1, digits.slice(0, 4), countDownM2);
const countDownS2 = () => _countDown(second2, digits.slice(0, 5), countDownS1);
这样不仅可以将内部逻辑选择性地封装起来,还可以像写 async-await
那样处理异步函数调用,而最终的主逻辑丝毫不受影响:
function countDown() {if(timer) {return;}console.log('Start counting down...');timer = setInterval(() => {if(comeToZero(second2)) {if(prevDigitsAllZero(digits)) {stopCountDown();showMessage('时间到!!!');return;}}countDownS2();}, 1000);
}
可以看到,第 12 行的函数调用和改造前继续保持一致,唯独多了一块判定暂停的逻辑(这是为了修复 Bug
必须引入的)。至于中间的判定逻辑 comeToZero()
和 prevDigitsAllZero()
,可以放到最后来实现:
const container = document.querySelector(".container");
const $ = (selector, parentDom = container) => parentDom.querySelector(selector);const comeToZero = digit => {const currentLi = $('li:first-of-type', digit);const index = currentLi.className.slice(-1);return parseInt(index, 10) === 0;
};const prevDigitsAllZero = digits => digits.every(comeToZero);
正所谓擒贼先擒王,重构代码时 一定要分清主次,集中精力解决核心逻辑,其他旁枝末节锦上添花的部分作为支线任务放到最后完成。千万不要本末倒置。
这是修复 Bug
后,最终停止计时的效果图:
【图 3 修复 Bug 后最终的页面效果截图】
4 复盘与小结
完整代码后续我会免费放到网盘中,敬请留意!
此次代码重构创新引入了函数式编程中的 CPS
风格,将后续可能执行的业务逻辑通过封装成一个新的回调函数、并作为工具函数的参数传入,成功解决了代码冗余和书写 回调地域 式代码的问题,同时也让整个业务逻辑更加简洁、紧凑。
对于函数式编程这种十多年来仍无法顺利走进每个程序员撸码日常的“异类”而言,不结合具体业务场景而空谈其各种好处的内容创作,在我看来就是在炫技、自嗨。