当前位置: 首页 > 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 风格,将后续可能执行的业务逻辑通过封装成一个新的回调函数、并作为工具函数的参数传入,成功解决了代码冗余和书写 回调地域 式代码的问题,同时也让整个业务逻辑更加简洁、紧凑。

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

http://www.xdnf.cn/news/172981.html

相关文章:

  • 五种IO模型
  • 【数据挖掘】时间序列预测-时间序列预测策略
  • Kubernetes/KubeSphere 安装踩坑记:从 context deadline exceeded 到成功部署的完整排障笔记
  • 同样开源的自动化工作流工具n8n和Dify对比
  • Docker compose 部署微服务项目(从0-1出发纯享版无废话)
  • 代数拓扑和黎曼几何有什么联系吗?
  • 【深度好文】4、Milvus 存储设计深度解析
  • 公网域名如何解析到内网ip服务器?自己域名映射外网访问
  • 3. 使用idea将一个git分支的部分提交记录合并到另一个git分支
  • Golang | 集合求交
  • 常用的性能提升手段--提纲
  • 二叉树的前序、中序和后序遍历:详解与实现
  • 非计算机专业如何利用AI开展跨学科和交叉研究
  • 智能硬件行业售后服务管理:提升客户体验的关键所在
  • Java:网络编程
  • CesiumEarth更新至1.14.0版本,重新设计了图层设置页面,优化了许多界面交互问题
  • K8S Pod 常见数据存储方案
  • Lua 第12部分 日期和时间
  • PH热榜 | 2025-04-27
  • HTML倒数
  • java 类的实例化过程,其中的相关顺序 包括有继承的子类等复杂情况,静态成员变量的初始化顺序,这其中jvm在干什么
  • xe-upload上传文件插件
  • WPF常用技巧汇总 - Part 2
  • Qt项目全局设置UTF-8编码方法(MSVS编译中文报错解决办法)
  • 新能源汽车运动控制器核心芯片选型与优化:MCU、DCDC与CANFD协同设计
  • 设计一个新能源汽车控制系统开发框架,并提供一个符合ISO 26262标准的模块化设计方案。
  • Java高频常用工具包汇总
  • [特殊字符]实战:使用 Canal + MQ + ES + Redis + XXL-Job 打造高性能地理抢单系统
  • Spark Mllib 机器学习
  • 第二章,网络类型及数据链路层协议