Code前端首页关于Code前端联系我们

Vue3秒表怎么写才能既好用又有扩展性?

terry 1小时前 阅读数 27 #Vue

做前端练手项目或者开发时间管理、运动健身类小程序/网页时,秒表绝对是绕不开的基础组件,之前用Vue2写过的朋友可能还记得,setInterval经常踩内存泄漏的坑,数据更新的逻辑也容易耦合,那切换到Vue3后,有没有更优雅的写法?新手从零开始会不会难?遇到暂停后再次开启跳秒、精确到毫秒时抖动这些常见问题该怎么解决?这些问题咱们今天挨个聊清楚。

从零写一个最基础的Vue3秒表

咱们先别搞花里胡哨的,先搭一个能实现「开始/暂停」「复位」「记录分段时间」三个核心功能的基础版本,用Vue3的组合式API写,这才是现在的主流,setup语法糖也更简洁。

准备工作

首先得确保你有一个Vue3的项目环境,不管是用Vite快速创建还是用Vue CLI,只要能正常跑Vue3就行,这里推荐用Vite,启动快,热更新体验好,练手效率高。

梳理核心数据和状态

秒表的核心无非是几个变量:

  1. 累计时间:从第一次点击开始到现在的总毫秒数,用毫秒是为了后续转成时分秒更灵活,也能满足毫秒级显示的需求
  2. 当前分段时间:上一次点击「分段」到现在的毫秒数
  3. 定时器ID:存setInterval的返回值,方便暂停的时候清除,避免内存泄漏
  4. 运行状态:判断现在是开始还是暂停,控制按钮的文字和功能
  5. 分段时间数组:把每次点击的分段时间存起来,展示给用户

这几个变量,我们都要用ref或者reactive来声明吗?其实累计时间、当前分段时间、定时器ID、运行状态都是单一值,用ref就行;分段时间数组是数组对象,用ref或者reactive都可以,这里我习惯用ref,因为遍历和更新单个元素更直接。

核心功能实现

开始/暂停

先理清楚逻辑:如果现在是未运行状态(包括初始状态和暂停后),点击就启动定时器,更新运行状态;如果是运行状态,点击就清除定时器,更新运行状态。

这里有个新手特别容易踩的坑:直接用setInterval(() => { totalTime.value += 10 }, 10)来累加时间,以为这样就能精确到10毫秒,但实际上,浏览器的setInterval和setTimeout并不是严格精确的,因为JS是单线程的,如果主线程有别的任务在跑,定时器回调就会被延迟,时间久了就会出现跳秒或者累计不准的问题。

那怎么解决呢?其实不用累加,而是用时间戳来计算,第一次点击开始的时候,记录一个「起始时间戳」(或者暂停时的「结束时间戳」加上之前的「累计暂停差值」?不对,更简单的方法是:每次更新显示的时候,用当前的时间戳减去第一次点击开始时的「总起始时间戳」,再减去之前所有暂停时间段的「总暂停时长」),哦对,还要加一个「暂停起始时间戳」,用来计算每次暂停的时长。

好,那我们重新调整一下变量:

  • 把原来的「累计时间」改成计算属性,用时间戳实时计算,这样就不需要手动累加了,也更精确
  • 新增「总起始时间戳」:第一次点击开始时的时间戳
  • 新增「暂停起始时间戳」:每次点击暂停时的时间戳
  • 新增「总暂停时长」:所有暂停时间段的总和

那开始/暂停的逻辑就变成:

// 运行状态,false是未运行/暂停,true是正在运行
const isRunning = ref(false);
// 定时器ID,ref存的话可以赋值为null,方便判断有没有启动
let timerId = ref<number | null>(null);
// 总起始时间戳
const totalStartTime = ref<number>(0);
// 暂停起始时间戳
const pauseStartTime = ref<number>(0);
// 总暂停时长
const totalPauseDuration = ref<number>(0);
// 计算总时间,单位毫秒
const totalTime = computed(() => {
  if (!isRunning.value) {
    // 未运行的话,就是总暂停前的累计时间
    return pauseStartTime.value ? pauseStartTime.value - totalStartTime.value - totalPauseDuration.value : 0;
  } else {
    // 正在运行的话,就是当前时间戳减去总起始时间戳再减去总暂停时长
    return Date.now() - totalStartTime.value - totalPauseDuration.value;
  }
});
// 开始/暂停函数
const toggleRunning = () => {
  if (isRunning.value) {
    // 正在运行,点击暂停
    pauseStartTime.value = Date.now();
    if (timerId.value) {
      clearInterval(timerId.value);
      timerId.value = null;
    }
  } else {
    // 未运行,点击开始
    if (totalStartTime.value === 0) {
      // 初始状态,第一次点击开始
      totalStartTime.value = Date.now();
    } else {
      // 暂停后再次开始,把本次暂停的时长加到总暂停时长里
      totalPauseDuration.value += Date.now() - pauseStartTime.value;
    }
    // 启动定时器,这里的10毫秒只是为了刷新显示,不是用来累加时间的
    timerId.value = window.setInterval(() => {
      // 其实这里不用做任何事,因为totalTime是计算属性,会自动响应式更新
      // 不过有的时候浏览器渲染可能有延迟,加个空函数触发一下也没关系
    }, 10);
  }
  // 切换运行状态
  isRunning.value = !isRunning.value;
};

你看,这样写的话,不管主线程卡不卡,累计时间都是通过时间戳实时算的,绝对不会出现跳秒的问题,精确性有保障。

复位

复位的逻辑就简单多了:不管现在是运行还是暂停,都先清除定时器,然后把所有的变量都重置成初始状态,包括分段时间数组。

// 分段时间数组
const splitTimes = ref<{ id: number; time: number; splitTime: number }[]>([]);
// 上一次分段的总时间,用来计算本次分段的时间
const lastSplitTotalTime = ref<number>(0);
// 复位函数
const resetStopwatch = () => {
  if (timerId.value) {
    clearInterval(timerId.value);
    timerId.value = null;
  }
  isRunning.value = false;
  totalStartTime.value = 0;
  pauseStartTime.value = 0;
  totalPauseDuration.value = 0;
  splitTimes.value = [];
  lastSplitTotalTime.value = 0;
};

记录分段时间

分段时间的逻辑是:每次点击「分段」的时候,把当前的总时间存下来,再减去上一次分段的总时间,得到本次的分段时间,然后一起push到分段时间数组里,数组的id可以用数组的length+1,简单方便,不需要额外生成唯一ID。

// 记录分段时间函数
const recordSplitTime = () => {
  const currentTotal = totalTime.value;
  const currentSplit = currentTotal - lastSplitTotalTime.value;
  splitTimes.value.push({
    id: splitTimes.value.length + 1,
    time: currentTotal,
    splitTime: currentSplit
  });
  // 更新上一次分段的总时间
  lastSplitTotalTime.value = currentTotal;
};

时间格式化

把毫秒数转成「HH:mm:ss:SS」或者「mm:ss:SS」这样的格式,肯定是要写一个工具函数的,这里要注意补零,比如小时只有1的话,要显示成「01」,毫秒只有5的话,要显示成「005」?不对,一般秒表的毫秒是显示两位的,代表10毫秒的精度,三位的话也可以,但两位的话用户看起来更舒服,也不会太乱。

那两位毫秒的格式化函数怎么写?

// 时间格式化工具函数,输入毫秒,输出HH:mm:ss:SS或者mm:ss:SS
const formatTime = (ms: number) => {
  // 先取整,避免小数
  const totalMs = Math.floor(ms);
  // 计算小时、分钟、秒、毫秒(两位)
  const hours = Math.floor(totalMs / 3600000);
  const minutes = Math.floor((totalMs % 3600000) / 60000);
  const seconds = Math.floor((totalMs % 60000) / 1000);
  const milliseconds = Math.floor((totalMs % 1000) / 10);
  // 补零函数,单独提出来,复用性更高
  const padZero = (num: number) => num.toString().padStart(2, '0');
  // 如果小时大于0,就显示小时,否则不显示
  return hours > 0 
    ? `${padZero(hours)}:${padZero(minutes)}:${padZero(seconds)}:${padZero(milliseconds)}`
    : `${padZero(minutes)}:${padZero(seconds)}:${padZero(milliseconds)}`;
};

这个工具函数不管是总时间还是分段时间都能用,非常方便。

组件模板

最后就是把这些逻辑和数据用到模板里了,模板里要注意响应式绑定,按钮的文字要根据运行状态切换,分段时间数组要倒序显示(因为最新的分段时间一般都是用户最关心的,放在最上面),还要加一点简单的样式,不然太丑了。

简单的模板和样式大概长这样:

<template>
  <div class="stopwatch-container">
    <!-- 显示总时间 -->
    <div class="total-time">{{ formatTime(totalTime) }}</div>
    <!-- 控制按钮组 -->
    <div class="control-buttons">
      <button @click="resetStopwatch" class="reset-button">复位</button>
      <button @click="toggleRunning" class="toggle-button">
        {{ isRunning ? '暂停' : '开始' }}
      </button>
      <button @click="recordSplitTime" class="split-button" :disabled="!isRunning && totalTime === 0">
        分段
      </button>
    </div>
    <!-- 显示分段时间 -->
    <div class="split-times" v-if="splitTimes.length > 0">
      <div class="split-header">
        <span>分段</span>
        <span>分段时间</span>
        <span>累计时间</span>
      </div>
      <div class="split-list">
        <div v-for="item in splitTimes.slice().reverse()" :key="item.id" class="split-item">
          <span>{{ item.id }}</span>
          <span>{{ formatTime(item.splitTime) }}</span>
          <span>{{ formatTime(item.time) }}</span>
        </div>
      </div>
    </div>
  </div>
</template>
<style scoped>
.stopwatch-container {
  max-width: 400px;
  margin: 50px auto;
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
  text-align: center;
}
.total-time {
  font-size: 60px;
  font-family: 'Courier New', Courier, monospace;
  margin-bottom: 30px;
  color: #333;
}
.control-buttons {
  display: flex;
  justify-content: space-between;
  margin-bottom: 30px;
}
.control-buttons button {
  padding: 12px 24px;
  border: none;
  border-radius: 5px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.3s ease;
}
.reset-button {
  background-color: #f0f0f0;
  color: #333;
}
.reset-button:hover {
  background-color: #e0e0e0;
}
.toggle-button {
  background-color: #4CAF50;
  color: white;
}
.toggle-button:hover {
  background-color: #45a049;
}
.toggle-button.paused {
  background-color: #ff9800;
}
.toggle-button.paused:hover {
  background-color: #e68900;
}
.split-button {
  background-color: #2196F3;
  color: white;
}
.split-button:hover:not(:disabled) {
  background-color: #0b7dda;
}
.split-button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}
.split-header,
.split-item {
  display: flex;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid #f0f0f0;
  font-family: 'Courier New', Courier, monospace;
}
.split-header {
  font-weight: bold;
  color: #666;
  border-top: 1px solid #f0f0f0;
}
.split-item:last-child {
  border-bottom: none;
}
</style>

哦对,刚才的toggle-button还可以加一个动态类名,暂停的时候显示橙色,更直观:

<button @click="toggleRunning" class="toggle-button" :class="{ paused: !isRunning && totalTime > 0 }">

这样就更完善了。

进阶优化:给秒表加一点扩展性

刚才的基础版本已经能满足大部分需求了,但如果要用到实际项目里,可能还需要加一点扩展性,

  1. 支持切换毫秒显示的位数(两位/三位)
  2. 支持保存分段时间到本地存储,刷新页面后不会丢失
  3. 支持切换主题(浅色/深色)
  4. 封装成一个可复用的组件,支持通过props传参控制功能

我们挑两个比较实用的来聊聊:支持保存到本地存储,和封装成可复用组件。

支持保存到本地存储

本地存储(localStorage)是前端最常用的持久化存储方式之一,容量大概有5MB,足够存秒表的分段时间和运行状态了。

我们可以在组件的生命周期钩子函数里做这件事:

  • onMounted:组件挂载的时候,从localStorage里读取之前保存的数据,如果有的话,就赋值给对应的变量
  • watch:监听分段时间数组、运行状态、总起始时间戳、暂停起始时间戳、总暂停时长、上一次分段的总时间这些变量的变化,一旦变化,就保存到localStorage里

不过这里要注意,localStorage只能存字符串,所以存的时候要先用JSON.stringify转成字符串,取的时候要用JSON.parse转回来,还要注意JSON.parse可能会报错,所以最好加一个try-catch块。

那具体怎么写呢? 我们要给localStorage的键名起一个规范的名字,比如vue3-stopwatch-data,避免和其他网站的键名冲突。 我们可以把读取和保存的逻辑封装成两个小函数,复用性更高。 监听变量的时候,要用deep: true,因为分段时间数组是引用类型,只有监听深层变化才能触发保存。

具体的代码大概长这样:

import { ref, computed, onMounted, watch } from 'vue';
// localStorage的键名
const STORAGE_KEY = 'vue3-stopwatch-data';
// 从localStorage读取数据的函数
const loadFromStorage = () => {
  try {
    const data = localStorage.getItem(STORAGE_KEY);
    return data ? JSON.parse(data) : null;
  } catch (error) {
    console.error('读取本地存储失败:', error);
    return null;
  }
};
// 保存数据到localStorage的函数
const saveToStorage = (data: any) => {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
  } catch (error) {
    console.error('保存本地存储失败:', error);
  }
};
// 组件挂载时读取数据
onMounted(() => {
  const savedData = loadFromStorage();
  if (savedData) {
    isRunning.value = savedData.isRunning || false;
    totalStartTime.value = savedData.totalStartTime || 0;
    pauseStartTime.value = savedData.pauseStartTime || 0;
    totalPauseDuration.value = savedData.totalPauseDuration || 0;
    splitTimes.value = savedData.splitTimes || [];
    lastSplitTotalTime.value = savedData.lastSplitTotalTime || 0;
    // 如果之前是运行状态,现在组件挂载了,要重新启动定时器
    if (isRunning.value) {
      timerId.value = window.setInterval(() => {}, 10);
    }
  }
});
// 监听变量变化,保存到localStorage
watch(
  [isRunning, totalStartTime, pauseStartTime, totalPauseDuration, splitTimes, lastSplitTotalTime],
  () => {
    saveToStorage({
      isRunning: isRunning.value,
      totalStartTime: totalStartTime.value,
      pauseStartTime: pauseStartTime.value,
      totalPauseDuration: totalPauseDuration.value,
      splitTimes: splitTimes.value,
      lastSplitTotalTime: lastSplitTotalTime.value
    });
  },
  { deep: true }
);

这样的话,不管你是刷新页面还是关闭浏览器再打开,之前的分段时间和运行状态都会保留下来,非常实用。

封装成可复用的组件

如果要在多个页面或者多个项目里用到这个秒表,最好把它封装成一个可复用的组件,支持通过props传参控制功能,

  • showMilliseconds:是否显示毫秒,默认true
  • millisecondsDigits:毫秒显示的位数,默认2
  • enableSplit:是否启用分段功能,默认true
  • enableReset:是否启用复位功能,默认true
  • autoSave:是否自动保存到本地存储,默认true
  • storageKey:本地存储的键名,默认vue3-stopwatch-data

还要支持通过emit事件把总时间、分段时间数组、运行状态这些数据传递给父组件,方便父组件做进一步的处理,比如保存到后端数据库,或者分享给其他用户。

封装的步骤大概是:

  1. 把刚才的逻辑和模板都放到一个单独的.vue文件里,比如Stopwatch.vue
  2. 定义props,设置默认值
  3. 定义emit事件
  4. 根据props控制组件的显示和功能,比如如果enableSplit是false,就隐藏分段按钮
  5. 在适当的时机触发emit事件,比如点击开始/暂停、点击复位、点击分段的时候

封装好之后,在父组件里就可以这样用了:

<template>
  <div class="parent-container">
    <h1>我的运动秒表</h1>
    <Stopwatch
      :milliseconds-digits="3"
      :auto-save="true"
      @update:total-time="handleTotalTimeUpdate"
      @update:split-times="handleSplitTimesUpdate"
    />
  </div>
</template>
<script setup>
import Stopwatch from './components/Stopwatch.vue';
const handleTotalTimeUpdate = (time) => {
  console.log('总时间更新了:', time);
  // 这里可以做进一步的处理,比如保存到后端
};
const handleSplitTimesUpdate = (times) => {
  console.log('分段时间更新了:', times);
  // 这里可以做进一步的处理,比如展示图表
};
</script>

这样封装之后,组件的复用性就大大提高了,不管在哪个项目里,只要引入这个组件,传几个props就能用。

常见问题解答

为什么我的秒表暂停后再次开启会跳秒?

刚才在基础版本里已经提到过这个问题,主要原因是你用了setInterval直接累加时间,而不是用时间戳实时计算,解决方法就是把累计时间改成计算属性,用时间戳来计算,同时记录总暂停时长,这样就能避免跳秒的问题了。

为什么我的秒表精确到毫秒时会抖动?

抖动的原因主要有两个:

  • 一是你用了setInterval直接累加时间,导致时间更新不均匀
  • 二是你用了字体不是等宽字体,比如宋体、黑体,数字的宽度不一样,导致更新的时候文字左右晃动

解决方法也很简单:

  • 用时间戳实时计算时间,避免直接累加
  • 用等宽字体,Courier New', Courier, monospace,这样数字的宽度都是一样的,就不会抖动了

为什么我的秒表会出现内存泄漏?

内存泄漏的原因主要是你在组件卸载的时候没有清除定时器,导致定时器一直在后台运行,占用内存,解决方法就是在组件的onUnmounted生命周期钩子函数里清除定时器:

import { onUnmounted } from 'vue';
onUnmounted(() => {
  if (timerId.value) {
    clearInterval(timerId.value);
    timerId.value = null;
  }
});

为什么我的秒表刷新页面后数据会丢失?

刷新页面后数据丢失是因为你没有把数据保存到持久化存储里,比如localStorage、sessionStorage或者后端数据库,刚才在进阶优化里已经讲过怎么保存到localStorage了,如果你需要更长期的保存或者多设备同步,可以保存到后端数据库。

今天咱们从零开始写了一个既好用又有扩展性的Vue3秒表,解决了新手常遇到的跳秒、抖动、内存泄漏、数据丢失等问题,还讲了怎么封装成可复用的组件。

其实Vue3的组合式API真的非常灵活,用setup语法糖写出来的代码也非常简洁,逻辑清晰,容易维护,希望这篇文章能对你有所帮助,如果你有什么问题或者更好的想法,欢迎在评论区留言交流。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门