开发一个计时器组件

✅ 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,
};
相关推荐
克里斯蒂亚诺更新2 小时前
微信小程序 点击某个marker改变其大小
开发语言·前端·javascript
天才奇男子2 小时前
从零开始搭建Linux Web服务器
linux·服务器·前端
长空任鸟飞_阿康3 小时前
AI 多模态全栈应用项目描述
前端·vue.js·人工智能·node.js·语音识别
Mintopia3 小时前
🌐 实时协同 AIGC:多人在线 Web 创作的技术架构设计
前端·人工智能·trae
Mintopia3 小时前
🔥 “Solo Coding”的近期热度解析(截至 2025 年末)
前端·人工智能·trae
顾安r4 小时前
11.14 脚本网页游戏 猜黑红
前端·javascript·游戏·flask·html
码码哈哈0.04 小时前
Vue 3 + Vite 集成 Spring Boot 完整部署指南 - 前后端一体化打包方案
前端·vue.js·spring boot
@菜菜_达4 小时前
interact.js 前端拖拽插件
开发语言·前端·javascript
Baklib梅梅4 小时前
故事叙述的力量:用Baklib创作让内容更具温度与共鸣
前端·ruby on rails·前端框架·ruby