校验美股开盘状态的递归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
相关产品推荐
相关产品推荐

