首页 / 资讯中心 / 文章详情

用JS实现红绿灯效果:定时器、状态机与倒计时实战

用JS实现红绿灯效果:定时器、状态机与倒计时实战 ★ FEATURED ARTICLE
红灯停绿灯行黄灯亮了等一等。这句从小听到大的顺口溜放在前端开发里就是一道非常经典的JavaScript练习题用JS实现红绿灯效果。我第一次看到这个题目的时候还觉得挺简单真动手写了才发现里面藏着定时器、状态流转、DOM操作、样式切换一堆东西而且要做得好还得解决时间误差、状态衔接各种细节问题。不管你是刚学完JS基础想找个项目练手还是准备前端面试这个红绿灯项目都值得认真做一遍。题目拆开看其实是两个独立又耦合的需求一个是信号灯按固定时长循环切换另一个是每盏灯亮起的时候实时显示剩余秒数。这两个需求合在一起正好覆盖了JavaScript里最常用的setInterval、setTimeout、clearInterval这些定时器API以及事件循环、闭包、DOM更新这些核心概念。下面我就从零开始把整个项目的设计思路、代码实现、常见坑点全部捋一遍你可以直接照着敲也可以看完之后自己加点功能改着玩。1. 项目拆解与方案选型1.1 这个红绿灯项目到底在练什么很多人一上来就写代码结果写到一半发现逻辑绕来绕去核心原因是没有先把需求拆清楚。这个红绿灯题目表面上只有两个功能点实际上背后藏着三个层面的东西第一层是状态管理。红绿灯有三个状态红灯、绿灯、黄灯每个状态都有固定的持续时间而且三个状态按固定顺序循环。这其实就是前端开发里最基础的状态机模型。你后面写复杂的业务逻辑、动画流程、路由管理本质上都是在处理状态怎么流转的问题。第二层是时间控制。JavaScript里的时间控制不像人类想的那样过了5秒就怎样它依赖事件循环机制。setTimeout和setInterval都是把回调函数塞进任务队列等到指定时间之后再执行但实际执行时间会受到主线程任务的影响。理解了这个机制你才能解释为什么有时候定时器会不准也才能写出防抖、节流、倒计时这类真正可靠的时间逻辑。第三层是视图同步。灯亮了倒计时数字要变页面要刷新。这就涉及到DOM操作和样式切换用一个变量记录当前状态根据状态去更新界面这就是最朴素的数据驱动视图思想。虽然这个项目的复杂度远不如框架但思路是完全相通的。1.2 为什么不用纯CSS实现看到这个需求的第一反应可能是CSS也能实现红绿灯效果用animation动画设置不同时长的关键帧就能让灯循环变色好像连JS都不用写。确实能做但我还是建议用JavaScript实现。原因有三点第一这个项目的核心目的不是做出效果而是练习状态管理和时间控制。用纯CSS的话你写几个keyframes就完事了定时器怎么用、状态怎么切、倒计时怎么算这些关键技能一点都没练到。第二CSS动画适合做从A到B的过渡但红绿灯的切换是瞬时完成的灯开关之间没有渐变过程用CSS动画反而要大费周章地去控制关键帧百分比。而且倒计时数字是动态变化的CSS根本没法根据当前时间动态渲染文本内容。第三现实中的信号灯系统还要考虑联动控制、故障切换、手动干预这些逻辑这些不是单纯的样式动画能搞定的。用JS写状态机后续扩展也好、调试也好都更方便。1.3 状态机先定好规则再写代码写代码之前我强烈建议先花两分钟把规则列清楚。就拿最常见的交通信号灯规则来说红灯亮持续5秒绿灯亮持续5秒黄灯亮持续2秒循环顺序红灯 → 绿灯 → 黄灯 → 红灯为什么要单独列这个因为如果不把规则明确下来写着写着就会出现状态错乱。比如黄灯亮完了不知道该切到红灯还是绿灯倒计时归零了灯却没变这些都是规则没定清楚导致的。定了规则之后我们可以定义一个配置对象const LIGHT_CONFIG { red: { duration: 5, next: green }, green: { duration: 5, next: yellow }, yellow: { duration: 2, next: red } };这样一个简单的配置对象就能描述整个循环。每个灯对应一个持续时间和下一个状态代码逻辑就会变得非常清晰。后面想改时间直接改配置就行不用动核心代码。这种配置与逻辑分离的写法在任何项目里都是加分项。2. 页面结构的搭建与样式细节2.1 HTML骨架三盏灯加一个倒计时页面结构很简单就是三个圆形的灯和一块倒计时显示区域。但结构简单不代表随便写合理的语义化标签和层级关系会让后续的样式和JS操作都省力很多。我习惯的结构是这样div classtraffic-light div classlight-panel div classlight red-light>.light { width: 100px; height: 100px; border-radius: 50%; background-color: #333; box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); transition: all 0.3s ease; } .light.red.active { background-color: red; box-shadow: 0 0 25px 5px rgba(255, 0, 0, 0.6); }box-shadow的关键在于最后一层颜色带透明度这样光就不仅仅是灯体本身还能散出去一点显得更真实。其次是颜色不用纯色。纯红色、纯绿色、纯黄色在视觉上都很扎眼稍微调一下色值会舒服得多。我一般用这几个值红灯#ff4d4f绿灯#52c41a黄灯#faad14这几个色值不是标准色卡但视觉上比较柔和光晕也自然。最后是暗灯的底色。所有灯熄灭的时候不应该是纯黑色而应该是有一定灰度的我习惯用#4a4a4a这种深灰色这样和亮灯时的颜色有对比也能看出来灯罩的形状。2.3 布局与响应式的取舍红绿灯的布局没什么难度但有一点要注意最好让三盏灯垂直排列因为现实中的红绿灯大多是竖着长的而倒计时数字放在灯的正下方或正上方会比较协调。垂直排列用flex轻松搞定.light-panel { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 25px; background-color: #2c2c2c; border-radius: 20px; box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); }至于响应式这个项目其实不用做太复杂因为核心是逻辑不是页面布局。只要规定灯的大小用固定像素或者用相对单位能适应常见的手机屏幕就行了。真要做到完全适配各种设备CSS量会翻倍对初学者来说反而分散了注意力。3. JavaScript核心逻辑亮灯转换与倒计时3.1 思路分层逻辑和数据分开放很多新手写这个项目会陷入一个误区把所有代码都往setInterval里塞一层套一层结果逻辑全粘在一起改一个功能就崩一片。我的做法是先分两层一层管状态一层管视图。状态层做的事情是记录当前是哪盏灯、还剩下几秒。视图层做的事情是把状态渲染到页面上也就是切换灯的颜色、更新倒计时的数字。两层之间通过一个渲染函数连接。这样分完之后核心逻辑的复杂度瞬间就降下来了。不管定时器怎么触发它只需要做两件事更新状态然后调用渲染函数。3.2 亮灯切换的实现先说最简单、也最不容易出错的实现方式使用setInterval每隔1秒检查一次当前状态根据剩余时间决定要不要切换。我把状态用一个对象来管理let currentLight red; let remainingTime LIGHT_CONFIG[currentLight].duration;每秒执行一次的逻辑是setInterval(() { remainingTime--; if (remainingTime 0) { currentLight LIGHT_CONFIG[currentLight].next; remainingTime LIGHT_CONFIG[currentLight].duration; } render(); }, 1000);这套逻辑的巧妙之处在于它用配置对象来驱动状态流转。不管当前是哪个灯只需要查配置里的next属性就能知道下一个该切到哪。整个循环就被一个查表操作串起来了不需要一堆if-else判断。render就是刷新视图的函数function render() { const lights document.querySelectorAll(.light); lights.forEach(light { const isActive light.dataset.color currentLight; light.classList.toggle(active, isActive); }); document.querySelector(.countdown-number).textContent remainingTime; }这里用classList.toggle的第二个参数来控制是否加active类比自己写if判断再add/remove要简洁得多。dataset.color读取的是前面HTML里定义的自定义属性正好用来判断当前这盏灯是不是需要点亮。3.3 倒计时的两种实现方式倒计时这块有个很容易被忽略的细节倒计时的更新频率。很多人直接用setInterval每秒减1就像我上面写的那样这确实是最直观的方式但有个天然缺陷——如果整个页面只用这一个定时器每秒减1看起来没问题但一旦时间长了误差就会累积。这里我想对比两种倒计时方案方案一每秒减1靠定时器驱动。优点是实现简单、逻辑清楚适合初学者理解和调试。缺点是定时器本身不是精确的。如果主线程被别的任务卡住了比如页面加载了图片、执行了复杂的计算setInterval的回调可能会滞后累积起来就会出现红灯该停了结果还亮着的情况。方案二记录结束时间戳用时间差计算。每次切灯时记录一个目标结束时间然后依然每秒执行一次但倒计时数字不是直接减1而是用结束时间减去当前时间再换算成秒数。这个方案的优点是无论定时器怎么执行最终显示的剩余时间都和真实时间保持一致。我用代码来对比一下。方案二的核心是let timer null; let currentLight red; let endTime Date.now() LIGHT_CONFIG[currentLight].duration * 1000; function tick() { const now Date.now(); remainingTime Math.ceil((endTime - now) / 1000); if (remainingTime 0) { currentLight LIGHT_CONFIG[currentLight].next; endTime Date.now() LIGHT_CONFIG[currentLight].duration * 1000; } render(); } timer setInterval(tick, 100);注意这里我把定时器的间隔从1000毫秒改成了100毫秒这是故意的。因为用时间戳计算定时器只是用来触发检查而不是用来计数的。检查频率稍微高一点数字切换的准确性会更好。100毫秒这个值不是乱选的它足够频繁又不会对性能造成明显影响。两种方案各有适用场景。练手用方案一足够但如果你打算把这个项目扩展成更复杂的模拟器比如多路口联动那我建议直接用方案二因为多个路口之间需要保持时间同步方案一的误差会成倍放大。3.4 定时器的清理与避免重叠这个点很容易被忽略但实际开发中特别重要定时器一定要清理干净。如果你点击了一个开始按钮然后再次点击新的setInterval会被创建旧的还在跑两个定时器同时操作同一个状态页面就会乱掉。表现出来就是灯切换速度突然变快或者数字跳得不对。解决办法是在创建定时器之前先清理旧的function start() { clearInterval(timer); timer setInterval(tick, 100); }这个习惯在任何项目里都适用不只是红绿灯。不管是用setInterval轮询接口还是用setTimeout做延迟先清理再创建能避免绝大多数功能重叠的bug。4. 完整代码与运行效果4.1 可复现的完整实现我在这里把整个项目串起来给出一个可以直接复制就能跑起来的完整版本。HTML、CSS、JS我按职责分开写放在同一个文件里就能看到效果。HTML部分div classtraffic-container div classlight-panel div classlight red-light>.traffic-container { display: flex; flex-direction: column; align-items: center; padding: 40px; background: linear-gradient(145deg, #ececec, #d6d6d6); border-radius: 20px; max-width: 280px; margin: 50px auto; } .light-panel { display: flex; flex-direction: column; gap: 16px; padding: 24px; background: #2c2c2c; border-radius: 18px; box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.6); } .light { width: 90px; height: 90px; border-radius: 50%; background-color: #4a4a4a; transition: box-shadow 0.3s ease, background-color 0.3s ease; } .light.red-light.active { background-color: #ff4d4f; box-shadow: 0 0 30px 8px rgba(255, 77, 79, 0.6); } .light.yellow-light.active { background-color: #faad14; box-shadow: 0 0 30px 8px rgba(250, 173, 20, 0.6); } .light.green-light.active { background-color: #52c41a; box-shadow: 0 0 30px 8px rgba(82, 196, 26, 0.6); } .countdown { margin-top: 16px; font-family: Courier New, monospace; font-size: 20px; color: #333; } .countdown-label { font-size: 14px; margin-right: 6px; } .countdown-number { font-size: 32px; font-weight: bold; color: #111; } .start-btn { margin-top: 20px; padding: 10px 24px; border: none; background: #1677ff; color: #fff; border-radius: 8px; cursor: pointer; font-size: 15px; transition: background-color 0.2s; } .start-btn:hover { background: #0958d9; }JS部分const LIGHT_CONFIG { red: { duration: 5, next: green }, green: { duration: 5, next: yellow }, yellow: { duration: 2, next: red } }; let currentLight red; let remainingTime LIGHT_CONFIG[currentLight].duration; let endTime Date.now() remainingTime * 1000; let timer null; const lightElements document.querySelectorAll(.light); const countdownNumber document.querySelector(.countdown-number); const startBtn document.querySelector(.start-btn); function render() { lightElements.forEach(light { const isActive light.dataset.color currentLight; light.classList.toggle(active, isActive); }); countdownNumber.textContent remainingTime; } function tick() { const now Date.now(); remainingTime Math.ceil((endTime - now) / 1000); if (remainingTime 0) { currentLight LIGHT_CONFIG[currentLight].next; remainingTime LIGHT_CONFIG[currentLight].duration; endTime Date.now() remainingTime * 1000; } render(); } function start() { clearInterval(timer); currentLight red; remainingTime LIGHT_CONFIG[currentLight].duration; endTime Date.now() remainingTime * 1000; render(); timer setInterval(tick, 100); } startBtn.addEventListener(click, start); render();4.2 代码关键点说明这段完整代码里有几个地方我特意处理过这里展开说一下。首先是数据配置。LIGHT_CONFIG里的duration决定了灯亮的时长next决定了切换顺序。其他代码只依赖这两个属性工作。你如果想修改红绿黄的时间只需要改duration的值不用动任何其他逻辑。其次是render与状态管理的分离。render不修改任何状态变量它只负责把状态里的数据渲染到页面上。这样即便将来要换成别的状态来源比如用随机时间切换render部分也完全不用改动。再次是时间戳方案的运用。我用endTime来记录当前灯的结束时间每次tick检查当前时间和endTime的差值。这样即便定时器本身因为主线程拥挤而延迟执行倒计时数字也不会凭空跳掉一两秒。这是这套代码和网上很多人写的版本最大的区别。最后是reset的处理。start函数里先把所有状态重置到初始值再清掉旧定时器、创建新定时器。这样无论你连续点击多少次开始运行状态都不会混乱。5. 常见问题与排查技巧实录5.1 定时器不准确这个我前面提过再展开说一下。setInterval和setTimeout的执行机制是到了指定时间回调函数会被插入到任务队列的末尾。如果主线程正在执行一个耗时很长的同步任务这个回调会一直等着等主线程空闲了才执行。所以如果你在代码里写了while (true) {}你会发现页面卡死定时器完全不走了。如果你有一个执行了1秒的计算任务定时器理论上每秒触发一次实际可能是每2秒甚至更久才触发一次。解决办法就是用时间戳方案。每一轮都把实际剩余时间重新计算一遍这样即使定时器触发晚了倒计时数字仍然会和真实时间一致。这也是我推荐方案二的根本原因。5.2 切换灯的时候状态错乱状态错乱多半是两种原因。一种是多个定时器在跑新旧定时器同时在修改状态变量。另一种是状态切换的边界条件没处理好比如剩0秒时是先切灯还是先减秒。边界条件的处理顺序很关键。我推荐的顺序是先判断剩余时间是否已经走完如果走完就先切灯、重置时间、更新结束时间然后再渲染。如果把顺序反过来先渲染再切灯就会有一瞬间显示的是旧的灯配合新的倒计时视觉上会产生闪烁。如果你发现一闪一闪的多半就是渲染和状态更新顺序搞反了可以重点检查一下这里。5.3 倒计时数字显示不实时用方案一(每秒减1)的时候数字看起来挺正常。但如果你把定时器间隔改成别的值比如500毫秒数字就会变成0.5步进看起来非常奇怪。这个问题的根源是倒计时的步进和数字的展示单位不同步。用时间戳方案可以完美解决这个问题。无论定时器间隔是100还是200毫秒倒计时数字都是通过Math.ceil((endTime - now) / 1000)计算出来的永远都是整数的秒数。定时器只是用来触发检查不会直接参与数字的加减。5.4 格式化补零倒计时如果超过了10秒比如显示9和15的时候位数不一致视觉上会有点跳。解决方法是统一补成两位数const formatted String(remainingTime).padStart(2, 0);这个在倒计时的场景里算是锦上添花但它演示了一个很常见的需求数字格式化。以后做计时器、日期展示、订单倒计时都用得上。6. 功能扩展与个人实操心得6.1 可以继续做的方向这个红绿灯项目虽然小但扩展空间不小。我列几个方向按难度从低到高加入手动控制按钮除了开始运行再加暂停和手动切换两个按钮。暂停的时候clearInterval继续的时候重启定时器手动切换则是跳过当前灯直接切到下一个状态。这个扩展能让你加深对定时器生命周期的理解。加入动态配置入口用几个输入框让用户自己设置红、绿、黄的持续秒数点确定之后立即按新配置重启。这个功能主要是让开发者体会配置驱动逻辑的好处。多路口模拟在页面上渲染多个红绿灯组件每个组件独立运行但共享同一条时间线。这个玩法可以用到模块化和组件化的思想如果再用上前端框架就是一个完整的组件封装案例。加入语音播报把浏览器自带的语音合成接口接进来切换灯的时候播报红灯请注意绿灯通行这样的提示。做起来不难就是调用接口但效果非常完整。每个方向都用得上前面的核心逻辑改起来也不会伤筋动骨。6.2 几点个人经验这个项目我前后做过好几遍每次都有不同的体会。第一遍是纯新手用了最笨的嵌套定时器写法代码乱成一团。第二遍学会把逻辑分层代码清爽多了。第三遍加上了时间戳方案才发现倒计时还能做得这么准。有几个经验想分享给正在练手的开发者Debug定时器的时候不要靠眼睛盯着页面直接在控制台打印或者用浏览器开发者工具里的Sources面板去看定时器的触发情况。你盯着页面看闪烁一两次根本分辨不出来但console里每一秒输出的日志都能看到规律。不要小看状态重置的问题。写了start函数之后测试的时候一定要连点好几下按钮看看效果不要只点一下就觉得没问题了。很多隐藏的bug都藏在重复调用这个场景里。多看别人怎么写的但不要照抄。我见过用setInterval嵌套setTimeout的版本也见过用Promise递归的版本每个版本的思路都不一样。多读几个版本你才能理解定时器、状态机、生命周期这些概念到底是怎么组合使用的。6.3 再聊两句红绿灯这个题目从我看第一眼到现在始终觉得是个被低估的练习项目。它不大没有复杂的算法没有多层级的组件结构但它把一个前端最核心的东西带出来了状态管理。你在这个项目里怎么处理当前是红是绿还剩几秒这些状态就决定了你以后面对更复杂业务时能写得多优雅。写代码之前先定规则状态和视图分开定时器统一清理。这三条习惯放到任何项目里都是通用的。把红绿灯做好了下一个练手项目可以试试做倒计时闹钟、番茄钟、多任务定时器逻辑都是一脉相承的。
阅读完成 · 觉得有帮助?
咨询建站