前言
大家平时学防抖,是不是第一印象就是:搜索框输入、窗口resize,用来做性能降频?
今天分享一个脑洞玩法:脱离防抖原本的性能优化场景,拿防抖的滑动窗口逻辑,实现快速三连点击解锁隐藏面板 。
表面看,这就是一个简简单单的「读秒挑战」,用来考验人的估时能力;但背后藏着吸附计时的魔术效果,直播整活效果拉满。
🎬 演示视频:https://www.bilibili.com/video/BV1Qgsd6gEfz
🔗 在线Pages演示地址:https://dxiangwiki.github.io/stopwatch-challenge/
📦 Github仓库:https://github.com/dxiangwiki/stopwatch-challenge
✨ 项目效果预览
页面外观极其朴素,深色背景 + 超大毫秒计时器,显示格式 00:00.000,支持三位小数毫秒精度。
- 外人视角:一个普通的读秒小游戏,点击开始计时、暂停、归零,挑战自己估时间准不准
- 自己视角:顶部标题快速连续点击三下,调出隐藏配置面板,可以提前设置吸附目标时间和容错范围
- 魔术玩法:提前配置好
1.314、5.200,两次展示拼接起来就是1314520,藏了一个浪漫小彩蛋!
标题做了隐蔽处理:cursor: default,鼠标悬浮不会变成小手,从视觉上看不出标题可以点击,保密性很好。
🧠 核心技术亮点(重点!)
1. 防抖的非常规复用(本篇最有意思的点)
常规防抖:短时间多次触发事件,合并成一次执行,减少回调,优化性能。
我们这里不拿来降频,而是借用防抖底层逻辑:
每次点击,清除旧定时器,重新开启倒计时;超时无点击则计数清零。
用这套逻辑实现滑动窗口三连击检测 。
区别于固定时间窗口:它判断的是每两次点击之间的间隔不能太长,更符合人理解的"快速连点三下"。
伪逻辑:
点击一次 → count++,重置倒计时
count===3 → 解锁配置面板
倒计时到期还没新点击 → count清零
很多人第一眼听到这里会调侃:"防抖?你是怕别人手抖误按?",刚好可以拿来当节目效果的梗。
2. 毫秒级高精度计时
采用 performance.now() 获取时间戳,搭配 requestAnimationFrame 渲染,计时精度高,展示到小数点后3位毫秒。
暂停的时候自动判断:如果当前时间落在预设吸附区间内,自动吸附到目标时间。
3. 配置持久化
吸附配置保存在 localStorage,刷新页面不会丢失,不用每次重新添加吸附点。
📖 使用教程
- 打开在线演示页面,页面标题:读秒挑战
- 【开始】按钮启动计时,按钮切换为【暂停】;再次暂停触发吸附判断
- 【归零】:计时器重置为
00:00.000 - 调出后台:快速连续点击顶部标题3次,弹出吸附配置面板
- 面板填写:目标时间(单位秒,支持 1.314、5.200)、吸附容错范围,添加吸附点
- 支持删除已经添加的吸附规则
⚠️ 三连判定规则:滑动窗口防抖判断,如果点击间隔太长,计数器会自动清零,需要重新连续点击。
📝 完整源码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>读秒挑战</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;font-family:system-ui, sans-serif}
body{
background:#202038;
color:#fff;
min-height:100vh;
display:flex;
flex-direction:column;
align-items:center;
padding-top:40px;
}
.title{
font-size:44px;
cursor: default;
user-select:none;
margin-bottom:30px;
}
.time-display{
font-size:120px;
color:#4cc9f0;
font-variant-numeric:tabular-nums;
margin:20px 0 40px;
}
.btn-wrap{
display:flex;
gap:18px;
}
button{
font-size:26px;
padding:18px 48px;
border:none;
border-radius:16px;
cursor:pointer;
}
#startBtn{
background:#4361ee;
color:#fff;
}
#resetBtn{
background:#f72585;
color:#fff;
}
.modal{
display:none;
position:fixed;
inset:0;
background:rgba(0,0,0,0.65);
align-items:center;
justify-content:center;
}
.modal-content{
background:#2c2c4e;
padding:28px;
border-radius:20px;
width:min(90%,460px);
}
.modal h3{margin-bottom:16px}
input{
width:100%;
padding:12px;
margin:8px 0;
border-radius:8px;
border:0;
font-size:18px;
}
.cfg-item{
display:flex;
justify-content:space-between;
align-items:center;
background:#3b3b70;
padding:10px;
border-radius:8px;
margin:6px 0;
}
#closeModal{
margin-top:14px;
width:100%;
background:#555;
color:white;
}
</style>
</head>
<body>
<h1 class="title" id="title">读秒挑战</h1>
<div class="time-display" id="timeBox">00:00.000</div>
<div class="btn-wrap">
<button id="startBtn">开始</button>
<button id="resetBtn">归零</button>
</div>
<div class="modal" id="cfgModal">
<div class="modal-content">
<h3>⚙️ 吸附配置</h3>
<input type="number" id="inpTarget" placeholder="目标时间(秒)" step="0.001">
<input type="number" id="inpRange" placeholder="吸附范围 ±秒" value="0.300" step="0.001">
<button id="addItemBtn">添加吸附点</button>
<div id="cfgList"></div>
<button id="closeModal">关闭</button>
</div>
</div>
<script>
const titleEl = document.getElementById('title');
const timeBox = document.getElementById('timeBox');
const startBtn = document.getElementById('startBtn');
const resetBtn = document.getElementById('resetBtn');
const cfgModal = document.getElementById('cfgModal');
const inpTarget = document.getElementById('inpTarget');
const inpRange = document.getElementById('inpRange');
const addItemBtn = document.getElementById('addItemBtn');
const cfgList = document.getElementById('cfgList');
const closeModal = document.getElementById('closeModal');
let clickCount = 0;
let debounceTimer = null;
const MAX_GAP = 800;
titleEl.onclick = ()=>{
clickCount += 1;
clearTimeout(debounceTimer);
if(clickCount >= 3){
cfgModal.style.display = 'flex';
clickCount = 0;
return;
}
debounceTimer = setTimeout(()=>{
clickCount = 0;
}, MAX_GAP);
}
closeModal.onclick = ()=>{
cfgModal.style.display = 'none';
}
let startTime = 0;
let elapsed = 0;
let running = false;
let rafId = null;
let snapList = JSON.parse(localStorage.getItem('snapConfig')) || [];
function formatTime(s){
const min = Math.floor(s / 60);
const sec = s - min*60;
return String(min).padStart(2,'0') + ':' + sec.toFixed(3).padStart(6,'0');
}
function renderTime(){
timeBox.innerText = formatTime(elapsed);
}
function tick(){
if(!running) return;
elapsed = (performance.now() - startTime)/1000;
renderTime();
rafId = requestAnimationFrame(tick);
}
startBtn.onclick = ()=>{
if(!running){
startTime = performance.now() - elapsed*1000;
running = true;
rafId = requestAnimationFrame(tick);
startBtn.innerText = '暂停';
}else{
cancelAnimationFrame(rafId);
running = false;
startBtn.innerText = '开始';
let bestVal = null;
let minDiff = Infinity;
for(let item of snapList){
const diff = Math.abs(elapsed - item.t);
if(diff <= item.r && diff < minDiff){
minDiff = diff;
bestVal = item.t;
}
}
if(bestVal !== null){
elapsed = bestVal;
renderTime();
}
}
}
resetBtn.onclick = ()=>{
cancelAnimationFrame(rafId);
running = false;
elapsed = 0;
startBtn.innerText = '开始';
renderTime();
}
function saveConfig(){
localStorage.setItem('snapConfig', JSON.stringify(snapList));
}
function renderList(){
cfgList.innerHTML = '';
snapList.forEach((it,idx)=>{
const div = document.createElement('div');
div.className = 'cfg-item';
div.innerText = `目标:${it.t.toFixed(3)}s ±${it.r.toFixed(3)}s`;
const delBtn = document.createElement('button');
delBtn.innerText = '删除';
delBtn.onclick = ()=>{
snapList.splice(idx,1);
saveConfig();
renderList();
}
div.appendChild(delBtn);
cfgList.appendChild(div);
})
}
addItemBtn.onclick = ()=>{
const t = Number(inpTarget.value);
const r = Number(inpRange.value);
if(isNaN(t) || t <= 0) return;
snapList.push({t,r});
saveConfig();
renderList();
inpTarget.value = '';
}
renderList();
renderTime();
</script>
</body>
</html>
💡 学习感悟
很多时候我们学习API和函数,很容易被固定场景框住:防抖就是用来优化性能,sticky就是页面滚动吸附。
但底层逻辑本身是中立的,跳出教材给的固定场景,换一个思路复用机制,就可以做出很有意思的创意Demo。
这个项目适合前端新人练手,理解定时器、防抖思想、高精度时间API,同时还能用来直播整活😎
📌 补充
部署方式:单文件HTML,上传到GitHub仓库,开启GitHub Pages即可在线访问。