【前端趣味项目】把防抖玩出新花样!一个带隐藏后门的毫秒级“读秒挑战”

前言

大家平时学防抖,是不是第一印象就是:搜索框输入、窗口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,刷新页面不会丢失,不用每次重新添加吸附点。

📖 使用教程

  1. 打开在线演示页面,页面标题:读秒挑战
  2. 【开始】按钮启动计时,按钮切换为【暂停】;再次暂停触发吸附判断
  3. 【归零】:计时器重置为 00:00.000
  4. 调出后台:快速连续点击顶部标题3次,弹出吸附配置面板
  5. 面板填写:目标时间(单位秒,支持 1.314、5.200)、吸附容错范围,添加吸附点
  6. 支持删除已经添加的吸附规则

⚠️ 三连判定规则:滑动窗口防抖判断,如果点击间隔太长,计数器会自动清零,需要重新连续点击。

📝 完整源码

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即可在线访问。

仓库地址:https://github.com/dxiangwiki/stopwatch-challenge

相关推荐
怕浪猫1 小时前
K3.1 直追 Astra,先补 13%
前端·javascript·面试
步行cgn2 小时前
JSON.stringify 详解:把 JavaScript 对象变成 JSON 字符串
开发语言·javascript·json
Liben2 小时前
Weex开发踩坑记录
android·前端·ios
jerrywus2 小时前
AI 写的代码能跑,为什么接手的人想跑?
前端·agent·claude
山雀~2 小时前
Web 多语言(i18n)技术方案详解
前端
biu_biubiu2 小时前
我开源了 Codebook
前端·vue.js
下山2 小时前
怎么把带自研插件的 DeepSeek Harness 部署到云服务器
前端
彭亚川Allen2 小时前
985 在职研究生到底怎么样?是不是水硕?是不是.....?
前端·后端·程序员
YufJi2 小时前
两棵树,两个线程:一个小程序引擎的架构拆解
前端