如 https://www.cnblogs.com/cup11/p/20207070 所述,接下来几天我将要发表关于全屏时钟的技术解析的文章。
前端时间校准的基本原理
电脑本地和服务器都可以使用 NTP 协议执行时间同步,但是 NTP 依赖于 UDP 而与 TCP 分离,在浏览器环境不可用,我们应该怎么办?
首先,无论是 NTP 还是前端的模拟校准算法,我们都需要理解一个最大的困境:
请求发送和接收的延迟。
道理很简单,客户端发出一个请求,100 ms 后收到服务器答复:服务器在 00:00:30.000 时收到请求,在 00:00:30.006 时返回请求。客户端怎么知道自己要把时间设置到什么地方呢?
显然,没有任何软件手段能获知请求的去程和响应的回程在路上传输的时间。
所以,我们必须引入一个核心假设 :去程和返程的延迟是相同的。
这很合理,因为一般而言往返走的线路不会有太大差异,但是也引入了 NTP 的最大误差 ,幸运的是,通过多次请求和寻找最佳服务器线路,精度可控制在亚毫秒级别。
若你想了解生产生活中人们如何获取更高的精度的授时,可以搜索:
PTP,GNSS/GPS 授时技术。
回到具体的例子,我们可以发现,"在路上"的请求时间是 100-6=94 ms,通过假设计算得单程延迟 94/2=47 ms,所以我们只需将本地时间设置为:00:00:30.006 + 0.047 = 00:00:30.053 即可。
前端的思路就是模仿这种策略,吸取其核心假设:
去程和返程的延迟是相同的。
那么,时间同步的服务去哪里找?自己后端搭一个当然可以,但是前提是保证服务器的时间准确。有没有现成的?我询问了各大 AI,给了我很多网站,有的需要认证,有的已经停止维护。我进行了逐家验证,最后向大家我推荐两个截至发文无需认证免费使用、较为稳定的后端时间请求 API:
-
https://api.shijian.online/timestamp/。它的返回格式如下:json{"status":1,"data":{"timestamp":1780057227741}} -
https://timeapi.io/api/Time/current/zone?timeZone=UTC。它的返回格式如下:json{ "year":2026,"month":5,"day":30,"hour":13, "minute":17,"seconds":51,"milliSeconds":112, "dateTime":"2026-05-30T13:17:51.1120409", "date":"05/30/2026","time":"13:17", "timeZone":"UTC","dayOfWeek":"Saturday","dstActive":false }
都至少精确到毫秒。所以我们比较服务器接受到请求的那一刻的本地和云端时间:
- 本地时间:
(start_time + end_time) / 2(估计,最大误差为(end_time - start_time) / 2);云端时间:API 返回的时间。 - 如能访问成功两个同步服务器,我们选择
逆方差加权(如有想了解的读者可自行搜索)。简单来说,延迟越低 ,可信度(权重)呈平方级增长。 - 为防止超时,我们将超时设为 1.5s,因为长时间低精度的时间同步本就失去了意义。
- 尤其注意时区处理和校准调时的前后方向,重点测试,不要闹笑话,一下子差掉几个小时,或者越调越偏。
代码实现
typescript
interface TimeSyncProvider {
name: string;
url: string;
toTimestamp(json: any): number;
}
interface SyncResult {
drift: number; // 定义偏移量 = 服务器时间 - 本地时间
rtt: number; // 往返延迟 (Round-Trip Time)
}
/**
* 获取单个源的时间偏移
*/
async function getOneDrift(provider: TimeSyncProvider): Promise<SyncResult | null> {
const start = Date.now();
try {
const response = await fetch(provider.url, {
cache: 'no-store',
signal: AbortSignal.timeout(1500) // 1.5s 超时断开
});
const json = await response.json();
const end = Date.now();
const serverTime = provider.toTimestamp(json);
const rtt = end - start;
// 核心逻辑:假设服务器收到请求的时刻是 (start + end) / 2
// drift = serverTime - (start + end) / 2
const drift = serverTime - (start + rtt / 2);
return { drift, rtt };
} catch (e) {
console.warn(`同步源 ${provider.name} 请求失败:`, e);
return null;
}
}
/**
* 逆方差加权融合多个源的结果
*/
async function getWeightedDrift(providers: TimeSyncProvider[]): Promise<number | null> {
const results = await Promise.all(providers.map(p => getOneDrift(p)));
const validResults = results.filter((r): r is SyncResult => r !== null);
if (validResults.length === 0) return null;
// 逆方差加权逻辑:权重 w = 1 / (rtt^2)。延迟越低,可信度呈平方级增长。
let totalWeight = 0;
let weightedDrift = 0;
validResults.forEach(res => {
const weight = 1 / Math.pow(Math.max(res.rtt, 1), 2);
weightedDrift += res.drift * weight;
totalWeight += weight;
});
return weightedDrift / totalWeight;
}
const providers: TimeSyncProvider[] = [
{
name: "shijian.online",
url: "https://api.shijian.online/timestamp/",
toTimestamp: (json) => json.data.timestamp
},
{
name: "timeapi.io",
url: "https://timeapi.io/api/Time/current/zone?timeZone=UTC",
toTimestamp: (json) => new Date(json.dateTime).getTime()
}
];
getWeightedDrift(providers).then(finalDrift => {
console.log(`最终计算得到的本地时间偏差: ${finalDrift.toFixed(2)}ms`);
// 展示时间 = Date.now() + finalDrift
});
误差来源分析
读者有没有好奇,为什么我们一直在说这种方式是"粗略"的?难道说,NTP 和我们 HTTP 走的不是同一条线路?
并非如此。网络路径的不对称性本身即是造成 NTP 的最大误差来源,而在浏览器中,我们看到的是加剧误差的机制:
- TCP:为了保证安全可靠的传输,浏览器不支持裸 UDP,而要再进行三次握手,极大增加的请求处理的延迟。
- 单线程:浏览器 JavaScript 和渲染共用同一线程,渲染任务可能造成请求的延时处理。
- 读取时间的精准性:计算机对 NTP 发来的请求会更加优先地调度资源读取当前时间,而网页端需要经过浏览器调度,浏览器又要经过操作系统调度。
后记
这个功能前前后后打磨了三四个小时,希望经验分享能帮到有类似想法的读者。
若读者对此项目本身感兴趣,可阅读项目介绍:https://www.cnblogs.com/cup11/p/20207070
欢迎交流!