当前位置: 首页 > news >正文

【玩转 JS 函数式编程_016】DIY 实战:巧用延续传递风格(CPS)重构倒计时特效逻辑

文章目录

    • 1 起因
    • 2 换一种思路
    • 3 填坑之旅
    • 4 复盘与小结

写在前面
都说念念不忘,必有回响。写过的文章也好,看过的视频也罢,其实只要用心积累,不必刻意去死记硬背,这些看似分散的碎片都会在未来某个不经意的瞬间串联起来——人的大脑就是如此神奇。本篇分享就是源于本专栏的一次分享,以及几天前的一个教学视频。函数式编程的思想和实践其实离我们的日常工作和生活也并没有想象中的那么遥远。

1 起因

前几天看到一个讲 JavaScript 函数式编程的系列视频,虽然内容质量还不错,总体感觉可以打到 8 分,但评论区的网友们似乎并不怎么买账,尤其是当大家看到视频中为了解释某个重要概念(比如函数柯里化)而生搬硬套某些写法的时候,更是忍不住在弹幕区疯狂吐槽。这再次印证了李笑来反复强调的 精心挑选演示案例的极端重要性,同时也成功勾起了我对相关话题的兴趣,想着什么时候遇到合适的应用场景了再来分享也不迟。好巧不巧今天就遇到了。

如下图所示,这是一个要求用原生 JavaScript 实现的倒计时特效,每隔一秒就会触发一次上翻动画:

图 1 利用原生 JavaScript 实现的一个前端倒计时特效_1

图 1 利用原生 JavaScript 实现的一个前端倒计时特效_2

【图 1 利用原生 JavaScript 实现的一个前端倒计时特效】

2 换一种思路

由于之前接过类似的项目,所以第一版很快就搞定了,用的是 WebAPI 中的原生方法 parent.appendChild(firstElem),对于已有的元素节点,浏览器会按剪切操作执行该方法。

但是这样一来,每个时间数字上都要提前安插 09 不等的图片元素,显得十分臃肿和冗余,例如:

<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 各数位的不同标识及各自的进制换算示意图

【图 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 函数式编程专栏文章时提过的 延续传递风格(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 作为参数传递。如何将这段选择性执行的代码逻辑以传递函数引用的形式重构呢?

答案是将其封装到一个新的回调函数中,整个逻辑需要从右向左重新梳理:

// 小时的首位逻辑保持不变
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(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 后最终的页面效果截图

【图 3 修复 Bug 后最终的页面效果截图】

4 复盘与小结

完整代码后续我会免费放到网盘中,敬请留意!

此次代码重构创新引入了函数式编程中的 CPS 风格,将后续可能执行的业务逻辑通过封装成一个新的回调函数、并作为工具函数的参数传入,成功解决了代码冗余和书写 回调地域 式代码的问题,同时也让整个业务逻辑更加简洁、紧凑。

对于函数式编程这种十多年来仍无法顺利走进每个程序员撸码日常的“异类”而言,不结合具体业务场景而空谈其各种好处的内容创作,在我看来就是在炫技、自嗨。

相关文章:

  • 【HarmonyOS 5】鸿蒙检测系统完整性
  • 解决 Elasticsearch 启动错误:failed to obtain node locks
  • OpenSPG/KAG v0.7.1 发布, 针对新版若干优化和BUGFIX
  • DeepSeek智能时空数据分析(五):基于区域人口数量绘制地图散点-大模型搜集数据NL2SQL加工数据
  • 新能源汽车运动控制器核心芯片选型与优化:MCU、DCDC与CANFD协同设计
  • STM32 定时器TIM
  • bitset
  • risc-V学习日记(4):RV32I指令集
  • spark 课程总结
  • ubuntu安装git及使用(本地git)
  • Docker 安装 kafka (bitnami/kafka:4.0)
  • 2025系统架构师---论软件架构风格
  • android Observable 和Observer 是什么
  • 探索DeepWiki:GitHub源码阅读的变革性工具
  • Python 自动化办公:Excel 数据处理的“秘密武器”
  • Linux Socket编程:从API到实战
  • 【2025最近Java面试八股】Spring中循环依赖的问题?怎么解决的?
  • 深度理解linux系统—— 进程概念
  • 端到端算法在SLAM中的应用:从理论到实践全解析
  • PlatformIO 入门学习笔记(一):背景了解
  • 夜读丨怀念那个写信的年代
  • 外交部回应涉长江和记出售巴拿马运河港口交易:望有关各方审慎行事,充分沟通
  • 香港警务处高级助理处长叶云龙升任警务处副处长(行动)
  • 我国已形成完整人工智能产业体系,专利申请量位居全球首位
  • 国内生产、境外“游一圈”再进保税仓,这些“全球购”保健品竟是假进口
  • 哈马斯同意释放剩余所有以色列方面被扣押人员,以换取停火五年