You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在React JS中实现页面切换/刷新后可恢复的累加计时器

累加计时器状态持久化实现方案

方案选型参考

根据需求场景选存储介质即可,useContext本身不具备持久化能力,仅用于跨组件共享状态,需要搭配Web Storage使用:

  • sessionStorage:单标签页生命周期内生效,刷新、同标签页路由跳转状态不丢失,关闭标签页自动清空,适合临时计时场景
  • localStorage:持久化存储(除非手动清除浏览器缓存),支持跨标签页状态同步,适合需要关闭浏览器后下次打开仍能续算的场景

核心实现逻辑

不要只持久化累计时长,否则计时器运行中跳页/刷新时,离开页面的这段时间不会被计入,会出现时间不准的问题。需要持久化三个核心字段:

  • 已经累计的计时毫秒数
  • 计时器当前运行状态(运行/暂停)
  • 最后一次更新状态的时间戳

初始化读取状态时做特殊处理:如果上次离开时计时器处于运行状态,用当前时间戳减去存储的最后更新时间戳,得到离开期间的时间差,加到累计时长上,即可实现无缝续算。每次状态变更、页面卸载、组件卸载时同步写入存储,就能保证状态不丢。

完整可运行代码(React技术栈)

以下是集成了sessionStorage持久化的计时器组件代码,JS项目直接删除TypeScript类型定义即可使用:

import { useState, useEffect, useRef, useCallback } from 'react';

// 存储键名,避免和站点其他存储字段冲突
const STORAGE_KEY = 'ACCUMULATIVE_TIMER_STATE';

type PersistedTimerState = {
  elapsed: number; // 累计毫秒数
  isRunning: boolean; // 运行状态
  lastTimestamp: number; // 最后一次更新的时间戳
};

// 初始化时读取存储状态,自动计算离线期间的时间差
const getInitState = (): PersistedTimerState => {
  const storedVal = sessionStorage.getItem(STORAGE_KEY);
  // 无存储记录时返回初始状态
  if (!storedVal) return { elapsed: 0, isRunning: false, lastTimestamp: Date.now() };

  try {
    const parsed = JSON.parse(storedVal) as PersistedTimerState;
    // 上次离开时计时器在运行,补上离开期间的时长
    if (parsed.isRunning) {
      const now = Date.now();
      return {
        ...parsed,
        elapsed: parsed.elapsed + (now - parsed.lastTimestamp),
        lastTimestamp: now
      };
    }
    return parsed;
  } catch (err) {
    // 存储内容损坏/格式错误时返回初始状态,避免页面崩溃
    return { elapsed: 0, isRunning: false, lastTimestamp: Date.now() };
  }
};

const PersistentTimer = () => {
  const [timerState, setTimerState] = useState<PersistedTimerState>(getInitState);
  const timerRef = useRef<number | null>(null);

  // 统一持久化方法
  const saveState = useCallback((state: PersistedTimerState) => {
    sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, []);

  // 启动
  const handleStart = useCallback(() => {
    setTimerState(prev => {
      const newState = { ...prev, isRunning: true, lastTimestamp: Date.now() };
      saveState(newState);
      return newState;
    });
  }, [saveState]);

  // 暂停
  const handlePause = useCallback(() => {
    setTimerState(prev => {
      const now = Date.now();
      const newState = {
        ...prev,
        isRunning: false,
        elapsed: prev.elapsed + (now - prev.lastTimestamp),
        lastTimestamp: now
      };
      saveState(newState);
      return newState;
    });
  }, [saveState]);

  // 重置
  const handleReset = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
    const newState = { elapsed: 0, isRunning: false, lastTimestamp: Date.now() };
    saveState(newState);
    setTimerState(newState);
  }, [saveState]);

  // 运行中计时逻辑
  useEffect(() => {
    if (timerState.isRunning) {
      // 100ms更新一次UI,保证显示流畅
      timerRef.current = window.setInterval(() => {
        setTimerState(prev => {
          const now = Date.now();
          return {
            ...prev,
            elapsed: prev.elapsed + (now - prev.lastTimestamp),
            lastTimestamp: now
          };
        });
      }, 100);
    }

    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, [timerState.isRunning]);

  // 页面刷新/关闭/路由跳走前自动存状态
  useEffect(() => {
    const handleBeforeUnload = () => saveState(timerState);
    window.addEventListener('beforeunload', handleBeforeUnload);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
      // 组件卸载(路由跳转)时存一次
      saveState(timerState);
    };
  }, [timerState, saveState]);

  // 毫秒转时分秒展示
  const formatDisplay = (ms: number) => {
    const totalSec = Math.floor(ms / 1000);
    const h = String(Math.floor(totalSec / 3600)).padStart(2, '0');
    const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
    const s = String(totalSec % 60).padStart(2, '0');
    return `${h}:${m}:${s}`;
  };

  return (
    <div className="timer-container">
      <div className="time-show">{formatDisplay(timerState.elapsed)}</div>
      <div className="btn-group">
        {timerState.isRunning ? 
          <button onClick={handlePause}>暂停</button> : 
          <button onClick={handleStart}>启动</button>
        }
        <button onClick={handleReset}>重置</button>
      </div>
    </div>
  );
};

export default PersistentTimer;

跨组件共享扩展(useContext实现)

如果需要在其他页面/组件中获取计时器状态、控制计时器,可以包一层Context做全局共享,避免重复初始化逻辑:

import { createContext, useContext, useMemo } from 'react';

type TimerContextValue = {
  elapsed: number;
  isRunning: boolean;
  start: () => void;
  pause: () => void;
  reset: () => void;
};

const TimerContext = createContext<TimerContextValue | null>(null);

// 全局Provider,包裹在路由根组件外层即可
export const TimerProvider = ({ children }: { children: React.ReactNode }) => {
  // 此处将上面PersistentTimer组件中的状态、方法逻辑移入即可
  // 省略重复逻辑...
  const contextValue = useMemo(() => ({
    elapsed: timerState.elapsed,
    isRunning: timerState.isRunning,
    start: handleStart,
    pause: handlePause,
    reset: handleReset
  }), [timerState.elapsed, timerState.isRunning, handleStart, handlePause, handleReset]);

  return <TimerContext.Provider value={contextValue}>{children}</TimerContext.Provider>;
};

// 自定义调用hook
export const useTimer = () => {
  const ctx = useContext(TimerContext);
  if (!ctx) throw new Error('useTimer 必须在 TimerProvider 内部使用');
  return ctx;
};

其他组件直接调用const { elapsed, isRunning } = useTimer()即可获取状态,不需要重复对接存储逻辑。

常见优化点

  • 需要关闭浏览器仍保留计时状态、跨标签页同步,直接将代码中所有sessionStorage替换为localStorage即可,可额外增加storage事件监听实现多标签页状态自动同步
  • 存储写入不需要和UI更新同频,仅在启动、暂停、重置、页面卸载几个节点写入即可,避免频繁IO影响性能
  • 无痕模式下部分浏览器会禁用Web Storage,可在初始化时加判断,降级为内存存储不影响基础使用

内容的提问来源于stack exchange,提问作者AniMi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 14:57:26