✅ 1、计时逻辑放在 Web Worker,不放在主线程
- 现代浏览器为了省电和性能,会对非当前显示的标签页实施限制,
setTimeout / setInterval可能延迟; - 不会被主线程的渲染/JS执行阻塞影响。
✅ 2、递归 setTimeout,不用 setInterval
1、setInterval 的缺陷:
setInterval(fn, 1000) 的含义是: "每隔 1000ms,将 fn 放入任务队列",但不保证 fn 何时开始执行。
如果主线程繁忙(如渲染卡顿、大量计算),fn 可能延迟执行,而下一次回调仍按固定间隔排队,导致:累计误差越来越大,实际执行频率远低于预期。
2、setTimeout 递归的优势:
动态调整下一次延迟时间,补偿上一次的执行耗时,setTimeout 递归是"执行完再约下一次",避免任务堆积。
scss
function tick() {
// ...
setTimeout(tick, 1000); // 下一次在"本次结束后"再安排
}
tick();
每次都是"上一次结束 + 1000ms",不会堆积,误差不累积!
✅ 3、完整代码
Timer.vue:
ini
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import {
COMPLETE,
COUNTDOWN,
formatSeconds,
PAUSE,
RESET,
START,
UPDATE,
} from './timer.utils';
const worker = new Worker(new URL('./timer.worker.ts', import.meta.url), {
type: 'module',
});
const props = withDefaults(
defineProps<{
mode?: 'countdown' | 'countup'; // 计时模式: countdown:倒计时, countup:计时
remMinutes?: number; // 倒计时初始分钟数(仅 countdown 模式有效)
immediate?: boolean; // 是否立即开始计时
control?: boolean; // 是否显示控制按钮
step?: number; // 计时步长(单位:秒,限制整秒)
onStart?: () => void;
onUpdate?: (time: string[]) => void;
onPause?: (time: string[]) => void;
onComplete?: () => void;
onReset?: () => void;
}>(),
{
mode: COUNTDOWN,
immediate: true,
control: true,
step: 1,
}
);
// 当前运行状态:
// - undefined:未开始(初始或重置后)
// - true:正在运行
// - false:已暂停
const isStart = ref<boolean>();
// 当前总秒数
let currentSeconds = props.mode === COUNTDOWN ? (props.remMinutes ?? 0) * 60 : 0;
// 初始化时间格式 [HH, MM, SS]
const initialTime = formatSeconds(currentSeconds);
const hours = ref(initialTime[0]);
const minutes = ref(initialTime[1]);
const seconds = ref(initialTime[2]);
listenWorkerMsg();
if (props.immediate) {
start();
}
function listenWorkerMsg() {
worker.onmessage = event => {
const { time, type, rawSeconds } = event.data;
if (type === UPDATE) {
[hours.value, minutes.value, seconds.value] = time;
currentSeconds = rawSeconds;
props.onUpdate?.([hours.value, minutes.value, seconds.value] as string[]);
}
if (type === COMPLETE) {
isStart.value = undefined;
props.onComplete?.();
}
};
}
function start() {
if (isStart.value === true) {
return;
}
isStart.value = true;
const { mode, step } = props;
worker.postMessage({
action: START,
step: Math.max(1, Math.floor(step)), // 仅支持秒级更新
mode,
currentSeconds,
});
props.onStart?.();
}
function pause() {
if (isStart.value !== true) {
return;
}
isStart.value = false;
worker.postMessage({ action: PAUSE });
props.onPause?.([hours.value, minutes.value, seconds.value] as string[]);
}
function reset() {
worker.postMessage({ action: RESET });
isStart.value = undefined;
const { mode, remMinutes, immediate } = props;
currentSeconds = mode === COUNTDOWN ? (remMinutes ?? 0) * 60 : 0;
if (immediate) {
start();
}
props.onReset?.();
}
onBeforeUnmount(() => {
worker.terminate();
});
defineExpose({
start,
pause,
reset,
});
</script>
xml
<template>
<div class="timer">
<div class="timer-container">
<slot :hours="hours" :minutes="minutes" :seconds="seconds">
<span>{{ hours }}</span>
<span class="delimiter">:</span>
<span>{{ minutes }}</span>
<span class="delimiter">:</span>
<span>{{ seconds }}</span>
</slot>
</div>
<div class="controls-container">
<slot name="controls">
<div v-if="control" class="controls">
<button class="btn" :disabled="isStart === true" @click="start">开始</button>
<button class="btn" :disabled="isStart === false" @click="pause">暂停</button>
<button class="btn" @click="reset">重置</button>
</div>
</slot>
</div>
</div>
</template>
xml
<style lang="scss" scoped>
.timer-container {
font-size: 20px;
font-weight: 500;
letter-spacing: 3px;
display: flex;
align-items: center;
justify-content: center;
.delimiter {
transform: translateY(-1px);
}
}
.controls {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: center;
.btn {
margin: 0 4px;
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
}
.btn:hover {
border-color: #646cff;
}
.btn:focus,
.btn:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
&.disabled {
.btn {
background: #f4f4f4;
cursor: not-allowed;
}
}
}
</style>
timer.worker.ts:
ini
/**
* web worker 防止主线程阻塞、浏览器标签页未激活时,影响计时精度
*/
import {
COMPLETE,
COUNTDOWN,
COUNTUP,
formatSeconds,
PAUSE,
RESET,
START,
UPDATE,
} from './timer.utils.ts';
let isRunning = false; // 是否运行中
let timeoutId: number | null;
self.onmessage = event => {
const { action, mode, step, currentSeconds } = event.data;
// 重置/暂停
if (action === RESET || action === PAUSE) {
stopTimer();
return;
}
// 开始
if (action === START) {
stopTimer();
isRunning = true;
if (mode === COUNTDOWN) {
startCountdown(currentSeconds, step);
}
if (mode === COUNTUP) {
startCountup(currentSeconds, step);
}
}
};
function startCountdown(remaining: number, step: number) {
const tick = () => {
if (!isRunning) return;
// 发送当前时间状态
self.postMessage({
type: UPDATE,
time: formatSeconds(remaining),
rawSeconds: remaining,
});
// 如果已结束
if (remaining <= 0) {
self.postMessage({ type: COMPLETE });
stopTimer();
return;
}
// 计算下一次剩余时间(确保不小于 0)
const nextRemaining = Math.max(0, remaining - step);
const delay = (remaining - nextRemaining) * 1000; // 精确延迟
if (nextRemaining > 0) {
// 继续倒计时
timeoutId = self.setTimeout(tick, delay);
} else {
// 最后一次归零
timeoutId = self.setTimeout(() => {
self.postMessage({
type: UPDATE,
time: formatSeconds(0),
rawSeconds: 0,
});
self.postMessage({ type: COMPLETE });
stopTimer();
}, delay);
}
remaining = nextRemaining;
};
tick(); // 立即触发第一次更新(显示初始值)
}
function startCountup(elapsed: number, step: number) {
const tick = () => {
if (!isRunning) return;
self.postMessage({
type: UPDATE,
time: formatSeconds(elapsed),
rawSeconds: elapsed,
});
elapsed += step;
timeoutId = self.setTimeout(tick, step * 1000);
};
tick();
}
function stopTimer() {
if (timeoutId) {
self.clearTimeout(timeoutId);
timeoutId = null;
}
isRunning = false;
}
timer.utils.ts:
ini
function formatSeconds(totalSeconds: number) {
totalSeconds = Math.max(0, totalSeconds);
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return [h, m, s].map(v => String(v).padStart(2, '0'));
}
const COUNTDOWN = 'countdown';
const COUNTUP = 'countup';
const START = 'start';
const PAUSE = 'pause';
const RESET = 'reset';
const UPDATE = 'update';
const COMPLETE = 'complete';
export {
formatSeconds,
COUNTDOWN,
COUNTUP,
START,
PAUSE,
RESET,
UPDATE,
COMPLETE,
};