深度工作计时器:从零实现一个环形倒计时组件

三月 24, 2026 / Mr.x / 7阅读 / 0评论/ 分类: 卡片类
深度工作计时器:从零实现一个环形倒计时组件 — 荣小站

深度工作计时器:从零实现一个环形倒计时组件

环形进度条、精确秒级倒计时、Web Audio 提示音——这是一个完整可用的计时器组件,核心逻辑不到 100 行。

这个计时器基于番茄工作法的理念设计,有四个预设时长可选,环形进度条随时间实时推进,完成后触发提示音。所有代码没有依赖任何外部库,用原生 HTML/CSS/JavaScript 实现。

效果演示

深度工作计时器演示

完整代码

点击展开/折叠完整代码
<!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>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: #faf8f5;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  }
  .focus-card {
    background: #fff;
    border: 1px solid #e8e0d5;
    border-radius: 20px;
    padding: 36px 40px;
    width: 360px;
    text-align: center;
    box-shadow: 0 4px 24px rgba(74, 63, 53, 0.06);
  }
  .timer-ring { position: relative; width: 200px; height: 200px; margin: 0 auto 24px; }
  .timer-ring svg { transform: rotate(-90deg); width: 200px; height: 200px; }
  .timer-ring circle { fill: none; stroke-width: 8; stroke-linecap: round; }
  .timer-bg { stroke: #f0e8df; }
  .timer-progress { stroke: #c9a96e; stroke-dasharray: 565; stroke-dashoffset: 0; transition: stroke-dashoffset 1s linear; }
  .timer-display { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; }
  .timer-time { font-size: 42px; font-weight: 700; color: #3d2e1f; }
  .preset-buttons { display: flex; gap: 8px; justify-content: center; margin-bottom: 16px; flex-wrap: wrap; }
  .preset-btn { background: #faf5ef; border: 1px solid #e8e0d5; border-radius: 8px; padding: 6px 14px; font-size: 12px; color: #8b6f47; cursor: pointer; transition: all 0.2s; }
  .preset-btn:hover, .preset-btn.active { background: #c9a96e; color: #fff; border-color: #c9a96e; }
  .btn-primary { background: #4a3f35; color: #fff; border: none; border-radius: 12px; padding: 12px 32px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  .btn-primary:hover { background: #3d2e1f; }
  .btn-primary:disabled { background: #ccc; cursor: not-allowed; }
</style>
</head>
<body>
<div class="focus-card">
  <div class="timer-ring">
    <svg viewBox="0 0 200 200">
      <circle class="timer-bg" cx="100" cy="100" r="90"/>
      <circle class="timer-progress" id="progressCircle" cx="100" cy="100" r="90"/>
    </svg>
    <div class="timer-display">
      <div class="timer-time" id="timerDisplay">25:00</div>
    </div>
  </div>
  <div class="preset-buttons">
    <button class="preset-btn active" onclick="setPreset(25, this)">25 分钟</button>
    <button class="preset-btn" onclick="setPreset(50, this)">50 分钟</button>
    <button class="preset-btn" onclick="setPreset(90, this)">90 分钟</button>
    <button class="preset-btn" onclick="setPreset(120, this)">120 分钟</button>
  </div>
  <button class="btn-primary" id="startBtn" onclick="startTimer()">开始专注</button>
</div>
<script>
const CIRCUMFERENCE = 2 * Math.PI * 90;
const progressCircle = document.getElementById('progressCircle');
const timerDisplay = document.getElementById('timerDisplay');
let totalSeconds = 25 * 60;
let remainingSeconds = totalSeconds;
let timerInterval = null;
let isRunning = false;
progressCircle.style.strokeDasharray = CIRCUMFERENCE;

function setPreset(minutes, btn) {
  if (isRunning) return;
  totalSeconds = minutes * 60;
  remainingSeconds = totalSeconds;
  updateDisplay();
  document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
  progressCircle.style.strokeDashoffset = 0;
}

function updateDisplay() {
  const m = Math.floor(remainingSeconds / 60);
  const s = remainingSeconds % 60;
  timerDisplay.textContent = `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}

function startTimer() {
  if (isRunning) return;
  isRunning = true;
  document.getElementById('startBtn').disabled = true;
  timerInterval = setInterval(() => {
    remainingSeconds--;
    updateDisplay();
    const offset = CIRCUMFERENCE * (1 - remainingSeconds / totalSeconds);
    progressCircle.style.strokeDashoffset = offset;
    if (remainingSeconds <= 0) {
      clearInterval(timerInterval);
      timerDisplay.textContent = 'DONE';
      // 提示音...
    }
  }, 1000);
}
updateDisplay();
</script>
</body>
</html>

核心原理解析

1. 环形进度条:SVG + stroke-dasharray

这是计时器最关键的部分。SVG 的 <circle> 有几个控制描边的属性:

<svg viewBox="0 0 200 200">
  <circle cx="100" cy="100" r="90" class="timer-bg"/>
  <circle cx="100" cy="100" r="90" class="timer-progress"/>
</svg>

stroke-dasharray 把一条连续的描边切成等长的虚线段,stroke-dashoffset 控制虚线的起点偏移量:

const CIRCUMFERENCE = 2 * Math.PI * 90; // 周长 ≈ 565.49
progressCircle.style.strokeDasharray = CIRCUMFERENCE;
progressCircle.style.strokeDashoffset = 0;

// 当倒计时进行时:
const offset = CIRCUMFERENCE * (1 - remainingSeconds / totalSeconds);
progressCircle.style.strokeDashoffset = offset;

transition: stroke-dashoffset 1s linear 让每次偏移变化平滑过渡——不需要 JavaScript 控制动画帧,CSS 帮你自动补间。

2. 倒计时逻辑:setInterval + 状态管理

let totalSeconds = 25 * 60;      // 总秒数
let remainingSeconds = totalSeconds;
let timerInterval = null;        // setInterval 返回的 ID
let isRunning = false;           // 运行状态锁

timerInterval = setInterval(() => {
  remainingSeconds--;
  updateDisplay();
  updateProgress();
  if (remainingSeconds <= 0) {
    clearInterval(timerInterval);
    playSound();                 // 触发提示音
  }
}, 1000);

关键点:状态锁 isRunning 防止计时器重复启动。如果用户快速连点"开始"按钮,第二个 startTimer() 调用会因为 isRunning === true 直接 return,而不会创建第二个 setInterval。

3. 预设按钮:选中状态 + 锁定运行中状态

function setPreset(minutes, btn) {
  if (isRunning) return;  // 计时中不允许切换时长
  totalSeconds = minutes * 60;
  remainingSeconds = totalSeconds;
  updateDisplay();
  document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
  btn.classList.add('active');
}

"计时中不允许切换时长"是一个有意为之的设计决策:强制用户为每一个计时周期做出明确的选择,减少随意性带来的仪式感。

4. 提示音:Web Audio API

const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();  // 声音振子
const gain = ctx.createGain();       // 音量控制器

osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;           // 频率 880Hz(A5 音符)
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.8);

频率 880Hz 听起来是一个干净的高音 A,exponentialRampToValueAtTime 实现自然的声音衰减,不需要音频文件,一个 API 调用就能合成出来。

数据结构

整个组件只有两个核心状态变量:

let totalSeconds = 25 * 60;      // 当前设定的总时长(秒)
let remainingSeconds = totalSeconds; // 倒计时剩余秒数
变量类型说明
CIRCUMFERENCE常量圆的周长(px),计算进度偏移量用
timerIntervalnumber|nullsetInterval 的 ID,null 表示未运行
isRunningboolean运行状态锁,防止重复启动
phrases[]string[]口号文案数组,随机选一条显示

可调节参数

参数默认值作用
CIRCUMFERENCE2π × 90根据 SVG 圆圈半径计算,调整半径时同步修改
25 * 601500 秒默认预设时长(25 分钟)
phrases[]5 条口号计时开始时随机显示的文案
osc.frequency.value880 Hz提示音频率,越高越尖锐

实际用途

这个计时器可以嵌入多种场景:

  • 博客侧边栏:作为南枝已谢或荣小站的挂件工具,读者在阅读文章时可以顺手开启一个专注计时
  • 个人首页:代替传统的"联系我"区块,用一个实用的工具留住访客
  • 团队看板:在内部工具中作为番茄工作法提醒,减少无效会议

#深度工作计时器:从零实现一个环形倒计时组件(1)

评论