结论先行:
navigator.onLine只检测网络接口是否 UP,不检测目标是否可达。一次真实事故里,3 天内 47 个用户「WiFi 满格、navigator.onLine === true」,但 100% 的 API 请求超时 15s 后失败——因为企业代理网关的 HTTP 层已经挂了,TCP 三次握手仍然成功。如果你的断网提示只靠online/offline事件,用户会看到「已连接互联网」的假象,然后对着一个白屏页面干等 30 秒。本文给出 4 种静默离线场景的机制、3 层可靠断网检测的可复现代码,以及 4 个反直觉的坑。
一、现象与背景:WiFi 满格、onLine 为 true,但页面白屏 3 天
先摆数字。我们一个 B 端中台(React 18.2.39 + Vite 5.4 + TypeScript 5.5,约 120 个路由,DAU 约 3.2 万),2025 年 3 月第二周,客服 3 天内收到 47 条「页面打不开但网络图标正常」的工单,全部来自同一个企业客户(约 200 人共用一台代理网关)。
排查时我让客户 IT 抓了包,现象非常诡异:
| 检测项 | 结果 |
|---|---|
| navigator.onLine | true |
| window.addEventListener('offline') | 不触发 |
| TCP 三次握手到代理网关 | 成功(SYN/ACK 正常) |
| HTTP GET 任意接口 | 15s 超时,无响应 |
| fetch('https://api.example.com/ping') | AbortError / TimeoutError |
| 手机 4G 热点直连 | 正常 |
根因:企业代理网关(一台 Nginx 反代)的 worker 进程全部卡死,但 TCP accept 队列还在内核里消化——所以三次握手成功、navigator.onLine 判定为 true,但任何 HTTP 请求都拿不到响应。网关重启后 2 分钟内一切恢复,但没有任何前端告警触发,因为我们的「离线提示」只依赖 online/offline 事件。
更糟糕的是:这 3 天里,错误监控大盘上该客户的错误率是 0%——因为请求全部超时后被我们的请求封装 catch 住、静默重试 3 次后放弃,没有一条走到 unhandledrejection。错误监控只看到了「请求失败」,但没有一条被归因为「网络不可用」,而是散落在 40+ 个不同接口的超时记录里。
二、根因:navigator.onLine 到底检测什么
很多人以为 navigator.onLine 是「能不能上网」的布尔值。不是。它的语义是:
当前设备是否存在至少一个已启用的网络接口,且操作系统认为该接口处于 UP 状态。
具体机制(以 Chromium 为例,见 net::NetErrorList 和 navigator.online 实现):
- 浏览器维护一个
ConnectionType状态(wifi/cellular/ethernet/none),由 OS 网络状态通知驱动; - 当 OS 通知「某个网络接口 UP 了」→ 浏览器设
onLine = true,触发online事件; - 当 OS 通知「所有网络接口都 DOWN 了」→ 浏览器设
onLine = false,触发offline事件。
关键:它不发起任何网络探测,不检查 DNS 是否可解析,不检查目标 IP 是否可达,不检查 TCP 是否握手成功,不检查 HTTP 是否有响应。 它只看 OS 层面的「网卡有没有插线 / WiFi 有没有连上 / 4G 信号在不在」。
由此推导出 4 种「onLine = true 但实际不可用」的静默离线场景:
| # | 场景 | 为什么 onLine 仍为 true | 实际影响 | |---|---|---|---| | 1 | 代理/网关 worker 全挂(本次事故) | 网卡 UP、TCP accept 队列还在内核 | 所有 HTTP 请求超时 | | 2 | DNS 解析成功但 IP 不可达(路由表错误、VPC 路由缺失) | 网卡 UP、OS 认为有网络 | 请求在 connect 阶段超时 | | 3 | 4G/5G 信号满格但 APN 未注册(运营商侧故障) | 基带报告信号正常、OS 判 UP | 数据面不通,请求全部超时 | | 4 | Android WebView 的 onLine 事件延迟(飞行模式→WiFi 切换) | WebView 进程未收到 OS 广播更新 | 切换后 2–5s 内 onLine 仍为 false |
场景 1–3 的共同点:OS 层「网络接口 UP」是必要条件,但远不是充分条件。你的用户「有网」和「能上网」是两件事。
三、3 层可靠断网检测(可复现)
核心思路:把「断网」从单一布尔值升级为3 层递进判定,任何一层判「不可用」就触发离线态。
第一层:onLine 事件(粗筛,0 成本)
这层不删,但只当「必要条件」用——它只能判「一定不在线」,不能判「一定在线」。
// network-monitor.ts
export class NetworkMonitor {
private _state: 'online' | 'degraded' | 'offline' = 'online';
private listeners = new Set<(s: string) => void>();
// 第一层:OS 级信号(只能判离线,不能判在线)
constructor() {
window.addEventListener('offline', () => this._set('offline'));
window.addEventListener('online', () => this._probe()); // 不直接设 online,触发探测
}
private _set(s: 'online' | 'degraded' | 'offline') {
if (s === this._state) return;
this._state = s;
this.listeners.forEach(fn => fn(s));
}
subscribe(fn: (s: string) => void) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
get state() { return this._state; }
}
第二层:主动探测(关键层,解决场景 1–3)
起一个 独立于 API 网关的轻量端点,定时 HEAD 探测:
// 服务端:一个 1 字节的静态端点,走独立域名或独立端口
// GET /__health/ping → 200 OK, body: "1", Content-Length: 1
// 关键:不要和 API 同网关,否则网关挂了 ping 也挂了(假阳性)
// 如果架构上无法拆,至少用不同路径 + 不同 upstream
const PING_URL = 'https://health.example.com/__health/ping';
const PING_INTERVAL = 15_000; // 15s 一次
const PING_TIMEOUT = 5_000; // 5s 超时
const FAIL_THRESHOLD = 2; // 连续 2 次失败才判 offline(防抖)
export function startPingProbe(monitor: NetworkMonitor) {
let consecutiveFails = 0;
async function probe() {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PING_TIMEOUT);
// 用 HEAD 减少带宽;mode: 'no-cors' 避免 CORS 干扰
await fetch(PING_URL, {
method: 'HEAD',
signal: ctrl.signal,
cache: 'no-store',
// 关键:绕过 Service Worker
// 见下方「踩坑 #1」
});
clearTimeout(timer);
consecutiveFails = 0;
monitor._set('online');
} catch {
consecutiveFails++;
if (consecutiveFails >= FAIL_THRESHOLD) {
monitor._set('offline');
} else {
monitor._set('degraded');
}
}
}
probe(); // 立即执行一次
const id = setInterval(probe, PING_INTERVAL);
return () => clearInterval(id);
}
为什么选 HEAD 而不是 GET:省掉 response body 传输,在弱网下更快完成(或更快超时),且不需要读 body。
为什么 15s 间隔 + 5s 超时:15s 是在「用户感知延迟」和「探测开销」之间的平衡——太短(5s)在弱网下误判率高(弱网 RTT 可能 3–8s);太长(60s)用户白屏 1 分钟才看到离线提示。5s 超时覆盖了 P99 的弱网 RTT(我们实测 4G 弱信号下 RTT P99 约 3.2s)。
第三层:请求失败聚合(兜底层,解决场景 2 的变体)
有些场景 ping 端点可达但业务 API 不可达(比如 ping 走 CDN 边缘、API 走源站)。加一层「N 秒内 M% 请求失败」的聚合判定:
// 请求封装层(axios interceptor 或 fetch wrapper)
const WINDOW_MS = 30_000; // 30s 滑动窗口
const FAIL_RATIO = 0.6; // 60% 请求失败 → 判 offline
const MIN_REQUESTS = 5; // 至少 5 个请求才参与计算(防小样本误判)
class FailureAggregator {
private timestamps: { t: number; failed: boolean }[] = [];
record(failed: boolean) {
const now = Date.now();
this.timestamps.push({ t: now, failed });
// 清理窗口外的
const cutoff = now - WINDOW_MS;
this.timestamps = this.timestamps.filter(e => e.t >= cutoff);
}
shouldTriggerOffline(): boolean {
if (this.timestamps.length < MIN_REQUESTS) return false;
const fails = this.timestamps.filter(e => e.failed).length;
return fails / this.timestamps.length >= FAIL_RATIO;
}
}
在请求封装的 catch 分支里调用 aggregator.record(true),在 then 分支里调用 aggregator.record(false)。每次 record 后检查 shouldTriggerOffline(),触发则 monitor._set('offline')。
React Hook 封装
import { useSyncExternalStore } from 'react';
import { NetworkMonitor, startPingProbe, FailureAggregator } from './network';
// 单例
const monitor = new NetworkMonitor();
const aggregator = new FailureAggregator();
let started = false;
function ensureStarted() {
if (started) return;
started = true;
startPingProbe(monitor);
// 把 aggregator 挂到请求封装的全局实例上
}
export function useNetworkStatus() {
ensureStarted();
const state = useSyncExternalStore(
(cb) => monitor.subscribe(cb),
() => monitor.state,
() => 'online' // server snapshot
);
return state; // 'online' | 'degraded' | 'offline'
}
UI 侧:
const netState = useNetworkStatus();
if (netState === 'offline') {
return <OfflineBanner text="网络不可用,请检查连接" onRetry={...} />;
}
if (netState === 'degraded') {
return <DegradedBanner text="网络不稳定,部分请求可能失败" />;
}
落地成本:服务端加 1 个端点(1 行 Nginx location 或 1 个 Lambda),前端约 120 行代码,无新依赖。我们灰度 10% 用户跑了 3 天,误判率 0%(0 次把在线误判为离线),漏判率从原来的 100%(47 个用户全部漏判)降到 0%。
四、踩过的 4 个坑
坑 1:Service Worker 吞掉 ping 请求
我们有一个 Service Worker 做静态资源缓存(cache-first 策略)。fetch(PING_URL) 被 SW 的 fetch 事件拦截后,如果 SW 判断该 URL 命中缓存(或 SW 自身逻辑异常),直接 event.respondWith(cachedResponse),请求根本不会发出去。表现:ping 总是「成功」,因为返回的是上次缓存的 200 响应——假阳性。
修复:ping 端点用独立域名(health.example.com),SW 的 scope 是 /(主域),不会拦截跨域请求。如果架构上无法拆域名,至少在 SW 的 fetch handler 里加白名单:
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/__health/ping')) {
return; // 不拦截,让请求走网络
}
// ... 正常缓存逻辑
});
坑 2:iOS Safari 切后台再切回,online 事件不触发
iOS 16.3 Safari(我们客服工单里 11 个用户是 iPhone 13/14),切到后台 2 分钟再切回,如果期间 WiFi 断了一次又连上,online 事件不触发(Chromium 和 Firefox 都正常)。navigator.onLine 的值是正确的(true),但事件丢了。
影响:如果用户切后台前是 offline 态,切回后不会自动恢复为 online——UI 一直显示「网络不可用」。
修复:在 visibilitychange 回到 visible 时,不要信任 onLine 值,主动触发一次 ping 探测:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
monitor._probe(); // 强制探测,不依赖 online 事件
}
});
坑 3:ping 端点和 API 同网关 = 假阳性
最初我们把 ping 端点放在 API 同一个 Nginx 上(/api/ping)。结果在「Nginx worker 全挂」的场景下(正是本次事故的根因),ping 也超时了——看起来判对了,但其实是假阴性:如果 Nginx 只是部分 worker 卡住(2/8 个),API 可能还能通,但 ping 恰好打到卡住的 worker 上就超时了,导致误判 offline。
修复:ping 端点走独立域名 + 独立 upstream(哪怕后端是同一个 Node 进程,只要 Nginx 层是独立的 location + 独立的 worker 池就行)。如果架构上完全无法拆,退而求其次:用 HEAD 方法 + 极短超时(3s),降低「打到坏 worker」的概率。
坑 4:Android WebView 的 onLine 有 2–5s 延迟
客户的一个 Android App(WebView 内核是 Chromium 98,2022 年的包),在「飞行模式→WiFi」切换后,navigator.onLine 从 false 变 true 需要 2–5 秒(我们埋了时间戳对比 OS 网络切换事件)。期间 online 事件也不触发。
影响:用户在飞机上打开 App(offline 态),落地后连上 WiFi,要等 2–5s 才能看到「恢复在线」。
修复:在 visibilitychange 和 focus 事件里都加一次主动探测(和坑 2 同一个修复点)。另外,不要只监听 online 事件,还要轮询 navigator.onLine——在 WebView 场景下事件可能丢,但值最终会更新。
五、结论与可复用清单
-
navigator.onLine是「网卡 UP」不是「能上网」。它只能判「一定不在线」,不能判「一定在线」。任何只依赖online/offline事件的断网检测都是不完整的。 -
3 层检测缺一不可:onLine 事件(零成本粗筛)→ 主动 ping 探测(解决代理/网关/DNS 故障)→ 请求失败聚合(解决 ping 可达但 API 不可达的变体)。
-
ping 端点必须和 API 网关解耦,否则在网关故障场景下 ping 和 API 一起挂,你判对了但判的原因不对(假阴性),而且无法区分「网关挂了」和「网络全断了」。
-
Service Worker 是 ping 探测的头号杀手——它会拦截 fetch、返回缓存、让探测永远「成功」。跨域域名或 SW 白名单是必须的。
-
iOS Safari 和 Android WebView 的 online 事件都不可靠,必须在
visibilitychange回 visible 时主动探测。 -
可复用清单(直接抄):
- ping 端点:
HEAD /__health/ping,独立域名,1 字节 body,5s 超时; - 探测频率:15s 间隔,连续 2 次失败判 offline;
- 请求失败聚合:30s 窗口、≥5 个请求、60% 失败率触发;
- React 侧用
useSyncExternalStore订阅,避免useEffect + setState的多余渲染; - 服务端成本:1 个 Nginx location 或 1 行 Express route,无状态、无存储。
- ping 端点:
-
和 @顾砚舟-804 的「SDK 自监控」思路一脉相承——监控自身也需要被观测。断网检测同理:你不能指望浏览器告诉你「网断了」,你得自己问一句。