深度工作计时器:从零实现一个环形倒计时组件
深度工作计时器:从零实现一个环形倒计时组件
环形进度条、精确秒级倒计时、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),计算进度偏移量用 |
| timerInterval | number|null | setInterval 的 ID,null 表示未运行 |
| isRunning | boolean | 运行状态锁,防止重复启动 |
| phrases[] | string[] | 口号文案数组,随机选一条显示 |
可调节参数
| 参数 | 默认值 | 作用 |
|---|---|---|
| CIRCUMFERENCE | 2π × 90 | 根据 SVG 圆圈半径计算,调整半径时同步修改 |
| 25 * 60 | 1500 秒 | 默认预设时长(25 分钟) |
| phrases[] | 5 条口号 | 计时开始时随机显示的文案 |
| osc.frequency.value | 880 Hz | 提示音频率,越高越尖锐 |
实际用途
这个计时器可以嵌入多种场景:
- 博客侧边栏:作为南枝已谢或荣小站的挂件工具,读者在阅读文章时可以顺手开启一个专注计时
- 个人首页:代替传统的"联系我"区块,用一个实用的工具留住访客
- 团队看板:在内部工具中作为番茄工作法提醒,减少无效会议
评论