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

校验美股开盘状态的递归setTimeout提前触发是什么原因?

触发提前的核心原因

你遇到的1-2秒提前触发问题不是浏览器数值 rounding 导致的,主要成因有两点:

  • 本地设备时钟和服务端/交易所时钟存在偏差:普通消费级设备没有做高精度时间同步的前提下,1-2秒的时钟误差属于正常范围,完全基于本地时间计算的触发时机自然会和服务端真实时间错位
  • 浏览器后台调度策略:当页面处于后台隐藏状态时,主流浏览器都会对setTimeout的调度做节流,部分场景下调度时机偏移会超过1秒

可落地的优化方案

不需要上服务端SSE方案,以下前端侧优化就可以解决问题:

方案1:增加触发前二次校验+不符重试逻辑

定时器触发后先校验当前时间是否真的到达目标事件时间,没到就重新计算延迟重设定时器;请求返回后如果状态和预期不符(比如16点请求仍返回开盘状态),就加1秒延迟重试,最多重试3次即可。

方案2:同步服务端时间校正偏差

首次请求接口时从响应头的Date字段拿到服务端当前时间,计算本地时间和服务端时间的偏差值,后续所有时间计算都用该偏差值校正,从根源解决时钟不准问题。

方案3:统一加固定兜底偏移

计算延迟的时候统一加2秒固定偏移,比直接修改事件时间的逻辑更清晰,不影响原有事件时间的定义。

优化后的完整代码

import React from 'react'
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'
import { AxiosResponse } from 'axios'
import { useQuery } from 'react-query'
import { ThemeProvider, StyledEngineProvider, CssBaseline } from '@mui/material'

import { light, dark } from '../theme'
import App from './App'

const timeZone = 'America/New_York'
// 兜底偏移2秒
const OFFSET_MS = 2000
// 重试最多3次
const MAX_RETRY = 3

type MarketTimes = {
  open: number
  earlyClose: number
  close: number
}

/* 市场事件时间 */
const getMarketTimes = (est: Date): MarketTimes => {
  const d = `${est.getFullYear()}-${est.getMonth() + 1}-${est.getDate()}`
  return {
    open: zonedTimeToUtc(`${d} 09:30:00`, timeZone).getTime(),
    earlyClose: zonedTimeToUtc(`${d} 13:00:00`, timeZone).getTime(),
    close: zonedTimeToUtc(`${d} 16:00:00`, timeZone).getTime(),
  }
}

type GlobalState = {
  initialLoading: boolean
  isMarketOpen?: boolean
  // 服务端时间和本地时间的偏差:serverTime = localTime + timeOffset
  timeOffset: number
}

export default () => {
  const [{ initialLoading, isMarketOpen, timeOffset }, setGlobalState] = React.useState<GlobalState>({
    initialLoading: true,
    timeOffset: 0
  })

  React.useEffect(() => {
    if (isMarketOpen !== undefined) {
      setTimeout(() => {
        setGlobalState(prevState => ({
          ...prevState,
          initialLoading: false,
        }))
      }, 5000)
    }
  }, [isMarketOpen])

  const { refetch } = useQuery('/market-status', {
    enabled: false,
    retry: 3,
    cacheTime: 0,
    onSuccess: (response: AxiosResponse<{ isMarketOpen: boolean }>) => {
      // 从响应头拿服务端时间计算偏差
      const serverDate = new Date(response.headers.date)
      const offset = serverDate.getTime() - Date.now()
      setGlobalState(prevState => ({
        ...prevState,
        isMarketOpen: response.data.isMarketOpen,
        timeOffset: offset
      }))
    },
  })

  React.useEffect(() => {
    refetch()
  }, [])

  /* 递归校验美股是否在美东时间9:30、13:00(节假日提前收盘)、16:00开盘 */
  React.useEffect(() => {
    const est = utcToZonedTime(new Date(), timeZone)
    let marketTimes = getMarketTimes(est)
    let retryCount = 0

    const getCurrentServerTime = (): number => {
      return Date.now() + timeOffset
    }

    const msUntilNextEvent = (): number => {
      const now = getCurrentServerTime()

      if (now < marketTimes.open) {
        return marketTimes.open - now + OFFSET_MS
      }

      if (now < marketTimes.earlyClose) {
        return marketTimes.earlyClose - now + OFFSET_MS
      }

      if (now < marketTimes.close) {
        return marketTimes.close - now + OFFSET_MS
      }

      est.setHours(est.getHours() + 24)
      marketTimes = getMarketTimes(est)
      return marketTimes.open - now + OFFSET_MS
    }

    let timeoutID: any

    const fetch = () => {
      const now = getCurrentServerTime()
      // 二次校验是否真的到了最近的事件时间
      const nextDelay = msUntilNextEvent()
      if (nextDelay > 100) {
        // 没到就重设定时器
        timeoutID = setTimeout(fetch, nextDelay)
        return
      }
      refetch().then(res => {
        const nextEventTime = Math.min(marketTimes.open, marketTimes.earlyClose, marketTimes.close)
        // 判断是否是收盘时间点,返回结果不符合预期就重试
        if (nextEventTime === marketTimes.close && res.data?.isMarketOpen && retryCount < MAX_RETRY) {
          retryCount++
          timeoutID = setTimeout(fetch, 1000)
          return
        }
        retryCount = 0
        timeoutID = setTimeout(fetch, msUntilNextEvent())
      })
    }

    timeoutID = setTimeout(fetch, msUntilNextEvent())

    return () => {
      clearTimeout(timeoutID)
    }
  }, [timeOffset])

  const theme = React.useMemo(() => {
    if (initialLoading) {
      return dark
    }

    return isMarketOpen ? light : dark
  }, [initialLoading, isMarketOpen])

  return (
    <StyledEngineProvider injectFirst>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <App isLoading={initialLoading} />
      </ThemeProvider>
    </StyledEngineProvider>
  )
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.26 21:54:04