最近接手一个移动端H5项目,用户频繁反馈"页面点了没反应""提交按钮卡半天"。排查到最后,十有八九是用户网络断了,前端却一脸无辜地转圈。我们写接口请求时都会处理loading、超时、错误回调,但很少有人认真想过一个问题——用户断网了,你的前端代码知道吗?
前端要检测网络状态,绕不开 navigator.onLine 和 online/offline 事件。这俩东西在面试题里出现频率不低,实战项目里真正用好的却不多。今天把这套方案的原理、代码、坑位一次讲透,看完你不仅能写出一个能用的网络状态模块,还能在项目评审时说清楚为什么这么设计。
1. 网络状态检测藏着的真实业务需求
1.1 不是所有断网场景都需要前端插手
先泼一盆冷水:如果你的项目只是管理后台,用户都在办公室连Wi-Fi,网络状态检测的优先级可以很低。因为断网概率小,即便断了,刷新页面多半也能解决。这时候做一套复杂的网络状态系统,属于过度设计。
但下面这些场景,网络状态检测就是刚需:
- H5电商/支付流程:用户下单时断网,表单提交失败,需要明确提示"网络异常",而不是让用户干等超时,然后重复提交造成重复订单。
- 在线文档/笔记/协同编辑:断网时本地编辑内容不能丢,需要在网络恢复后自动同步。
- 音视频/直播类页面:断网要快速切换备播流、降清晰度或显示"直播已断开"。
- 离线优先(Offline First)应用:比如PWA应用,断网时切到本地缓存数据,恢复后重新拉取。
- 弱网环境的WebView内嵌页面:扫码点餐、智慧停车这类场景经常在地下车库、电梯里操作,网络忽好忽坏。
这些场景的共同点是:断网导致的后果不可接受,且用户不会主动去刷新页面。所以前端需要主动感知网络变化,并给出对应反馈。
1.2 从"超时兜底"升级为"主动感知"
很多团队处理网络异常只有一招:接口请求失败时 catch 住,弹个Toast说"网络异常"。
这种做法最大的问题是滞后。你只能在请求发出并失败之后才知道网络有问题,而且这个失败可能因为服务器500、接口404等原因产生,这时提示"网络异常"反而是误导。
网络状态检测把"事后catch"升级成了"事前感知"。当浏览器告诉你"你现在离线",前端可以立即:
- 停止发起新的网络请求,减少无效流量和用户等待
- 明确展示"已离线"状态,而非假装还能用
- 启用本地缓存,允许用户继续浏览已加载的内容
这种体验差异,就像导航断网时,一个直接显示"当前无网络连接",另一个等你在路口转弯半天后怒吼"偏航"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. navigator.onLine到底检测了什么
2.1 它的判定逻辑比你想的要"粗糙"
navigator.onLine 返回的是一个布尔值。所有主流浏览器都实现了这个属性,但它对"在线"的定义非常宽松——浏览器只要能访问到本地网络(local network),就认为你在线,注意不是互联网(Internet)。
举个例子:办公室路由器本身没插外网网线,但你的电脑连上了路由器。此时 navigator.onLine 返回 true。你的网页能加载出来是因为有本地缓存或者资源本来就加载完了,但任何需要访问外网的请求都会失败。
反过来,移动端断网时,很多浏览器也会把 onLine 置为 false,但Android WebView里却经常出现断网后 onLine 依然是 true 的情况。我实际测过几个国产安卓机自带的浏览器内核,在Wi-Fi信号消失后,有的需要等30秒到1分钟才更新状态。
2.2 不同平台上的表现差异
| 环境 | 断网时 onLine 变化 | 备注 |
|---|---|---|
| Chrome 桌面 | 快速变为 false,恢复后很快回到 true | 以太网/无线断开会立即触发 offline 事件 |
| Firefox 桌面 | 同上,表现较稳定 | 不影响主线程 |
| iOS Safari / WKWebView | 断网后状态更新偏慢,有时会一直保持 true | 建议配合主动探测 |
| Android Chrome | Wi-Fi断开后变化较快,但网络类型切换时不稳定 | 4G切Wi-Fi容易误报 offline |
| Android WebView (部分国产壳) | 可靠性最差,经常不触发事件 | 必须配备心跳探测 |
因为 navigator.onLine 的实现依赖于浏览器的网络状态监听机制,而浏览器对"网络接口"的检测颗粒度跟实际互联网连通性是两码事。所以这个属性只能作为辅助信号,不能当作唯一判定依据。
2.3 移动端为什么特别不可靠
移动端网络变化频繁,比如电梯里信号消失、从4G切到Wi-Fi、地铁隧道里短暂断网。系统层面的网络切换时机、WebView 对网络状态回调的延迟,都会导致 navigator.onLine 的状态发生变化的速度跟不上真实情况。
实测中常见的现象:
- 断网后
offline事件根本没有触发,但接口请求已经全部超时 - WiFi切换网络时,触发了
offline又立刻触发online,造成闪烁 - 应用退到后台再回前台,状态是旧的,需要重新探测
所以结论是:navigator.onLine + online/offline 事件只能作为基础信号,要做出稳定的网络状态检测,还需要主动探测兜底。
3. online/offline事件:触发机制和实际行为细节
3.1 事件类型与绑定方式
浏览器在检测到网络状态变化时,会往 window 对象上派发 online 和 offline 事件。绑定方式有三种:
javascript复制// 方式一:addEventListener(推荐)
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 方式二:直接赋值(不推荐,会覆盖其他监听)
window.ononline = handleOnline;
window.onoffline = handleOffline;
// 方式三:在 body 元素上监听(不推荐,事件目标不同,行为不一致)
document.body.ononline = handleOnline;
注意:事件挂在 window 上,不要写到 document 上去监听,否则部分浏览器不触发。
3.2 事件触发顺序
当网络状态变化时,浏览器通常会先更新 navigator.onLine 的值,然后派发事件。顺序大概是:
- 网络断开或恢复
- 浏览器底层网络状态更新
navigator.onLine变化- 对应事件派发到
window
但这里有个细节:事件触发不保证100%可靠。有些低端安卓手机在熄屏后网络断开,回前台不会触发 offline;WebView里如果原生层没有正确回调WebChromeClient,online/offline 事件也可能丢失。
3.3 事件对象里有什么
online/offline 事件是 Event 类型,不是 CustomEvent,所以事件对象上除了 type 之外,没有额外信息。你拿不到信号强度、网络类型、带宽这些数据。
javascript复制window.addEventListener('offline', (e) => {
console.log(e.type); // "offline"
console.log(e.detail); // undefined
});
想获取网络类型(比如是Wi-Fi还是蜂窝数据),需要配合 Network Information API:
javascript复制const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
console.log(connection.type); // "wifi" / "cellular" / "ethernet" / "none"
connection.addEventListener('change', () => {
console.log('网络类型变化:', connection.type);
});
}
不过这个API的兼容性比较一般,Safari直到近几年才逐步支持,项目里可以考虑做特性检测后再用。
4. 主动探测:让"假在线"现形的关键手段
4.1 为什么需要主动探测
我把 navigator.onLine 称为"被动状态",因为它依赖浏览器主动告诉你状态。但浏览器不是网络层的小卫士,它的判断并不可靠。主动探测的思路则完全不同——前端自己发一个轻量请求,能通就认为在线,失败了就认为离线。
这就像你给朋友发微信,他回你消息了你才知道他手机有电。navigator.onLine 则是"我猜他手机应该有电",考虑了一下,还是发条消息确认更靠谱。
4.2 探测接口怎么选
探测接口是主动探测的核心。选接口有几个原则:
- 跨域友好:最好用CDN静态资源、图片或者支持CORS的GET接口
- 体积小:传输内容越小越好,避免消耗用户流量
- 足够稳定:接口挂了你就会一直误报"离线"
- 不会被业务网关拦截:有些后台接口需要鉴权,探测请求带了token,断网后反而报网络错误正常,但恢复后如果接口返回401,你可能会误判
常见的探测方式:
javascript复制// 方式一:请求一个不带缓存的小图片
// 注意带上前缀时间戳,避免浏览器缓存
const url = `https://你的域名/ping?timestamp=${Date.now()}`;
fetch(url, {
method: 'HEAD', // 或 GET
cache: 'no-store',
mode: 'cors',
credentials: 'omit',
}).then(res => {
// 只要请求成功,就认为在线
}).catch(() => {
// 请求失败,认为离线
});
也可以用 Image 对象探测,有些老接口不支持HEAD或CORS,图片方式更容易绕过:
javascript复制function probeWithImage(url, timeout = 5000) {
return new Promise((resolve) => {
const img = new Image();
const timer = setTimeout(() => {
img.src = '';
resolve(false);
}, timeout);
img.onload = () => {
clearTimeout(timer);
resolve(true);
};
img.onerror = () => {
clearTimeout(timer);
resolve(false);
};
img.src = `${url}?t=${Date.now()}`;
});
}
实际项目中,如果前后端同域,可以直接用同域的 /api/health 或 /ping 接口。如果跨域,要确保服务端设置了 Access-Control-Allow-Origin。
4.3 探测频率怎么控制
主动探测不能太频繁。断网时每个探测请求都会白白发起,加重网络负担;在线时太频繁又浪费流量和服务器资源。
我的实践方案是:事件驱动 + 定时兜底。
- 监听到
offline事件,立即把状态置为离线 - 监听到
online事件,不立即置为在线,而是主动探测一次,成功后才置为在线 - 应用处于前台时,每30秒探测一次;后台时停止定时器
- 有用户交互行为(点击、滑动)时,可以立即探测一次
这个策略的核心是:把事件作为最快响应信号,把探测作为纠偏手段。事件可能不准,但触发快;探测可靠,但有时延。两者结合,才能做到既快又稳。
5. 工程化封装:一套可用可扩展的网络状态检测模块
5.1 模块设计目标
在设计这个模块时,我给自己定了几个硬性要求:
- 框架无关:React、Vue、原生JS都能用
- 支持主动探测:不仅监听事件,还能定期探测
- 状态收敛:对外只暴露 "在线 / 离线 / 未知" 三种状态,避免内部复杂性外溢
- 可定制:探测地址、探测间隔、超时时间都能配置
- 自动清理:页面卸载时移除所有监听器和定时器,防止内存泄漏
5.2 核心实现
下面是一个可直接复制到项目的实现,基于TypeScript编写:
typescript复制type NetworkStatus = 'online' | 'offline' | 'unknown';
interface NetworkDetectorOptions {
/** 探测接口地址 */
probeUrl?: string;
/** 探测超时时间,默认5000ms */
probeTimeout?: number;
/** 在线时定时探测间隔,默认30000ms */
onlineInterval?: number;
/** 离线时的探测间隔,默认10000ms,离线时要更频繁地尝试恢复 */
offlineInterval?: number;
/** 是否启用主动探测,默认true */
enableProbe?: boolean;
}
interface NetworkState {
status: NetworkStatus;
/** 最近一次探测到的时间戳 */
lastChangedAt?: number;
/** 浏览器原生 onLine 的值 */
nativeOnline: boolean;
}
class NetworkDetector {
private options: Required<NetworkDetectorOptions>;
private timer: number | null = null;
private handlers: Array<(state: NetworkState) => void> = [];
private status: NetworkStatus = 'unknown';
private nativeOnline: boolean = navigator.onLine;
constructor(userOptions: NetworkDetectorOptions = {}) {
this.options = {
probeUrl: userOptions.probeUrl || '/api/health',
probeTimeout: userOptions.probeTimeout ?? 5000,
onlineInterval: userOptions.onlineInterval ?? 30000,
offlineInterval: userOptions.offlineInterval ?? 10000,
enableProbe: userOptions.enableProbe ?? true,
};
this.windowEventListener = this.windowEventListener.bind(this);
this.probe = this.probe.bind(this);
this.init();
}
private init() {
window.addEventListener('online', this.windowEventListener);
window.addEventListener('offline', this.windowEventListener);
// 初始化状态
this.status = this.nativeOnline ? 'unknown' : 'offline';
if (this.options.enableProbe) {
this.probe();
this.startTimer();
} else {
this.status = this.nativeOnline ? 'online' : 'offline';
}
}
private windowEventListener(e: Event) {
this.nativeOnline = navigator.onLine;
if (e.type === 'online') {
// 浏览器说在线了,但不直接采信,发起探测
if (this.options.enableProbe) {
this.probe();
} else {
this.updateStatus('online');
}
} else if (e.type === 'offline') {
// 浏览器说离线了,大概率真的离线,立即更新
this.updateStatus('offline');
this.startTimer();
}
}
async probe() {
const { probeUrl, probeTimeout } = this.options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), probeTimeout);
try {
await fetch(probeUrl, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
signal: controller.signal,
});
this.updateStatus('online');
} catch {
this.updateStatus('offline');
} finally {
clearTimeout(timer);
}
}
private startTimer() {
this.stopTimer();
const interval = this.status === 'offline'
? this.options.offlineInterval
: this.options.onlineInterval;
this.timer = window.setInterval(() => {
if (document.visibilityState === 'hidden') {
// 页面不可见时不做探测,减少资源消耗
return;
}
this.probe();
}, interval);
}
private stopTimer() {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = null;
}
}
private updateStatus(newStatus: NetworkStatus) {
if (this.status === newStatus) return;
this.status = newStatus;
this.nativeOnline = navigator.onLine;
this.startTimer();
const state: NetworkState = {
status: this.status,
lastChangedAt: Date.now(),
nativeOnline: this.nativeOnline,
};
this.handlers.forEach(h => h(state));
}
subscribe(handler: (state: NetworkState) => void) {
this.handlers.push(handler);
return () => {
this.handlers = this.handlers.filter(h => h !== handler);
};
}
getState(): NetworkState {
return {
status: this.status,
lastChangedAt: undefined,
nativeOnline: this.nativeOnline,
};
}
destroy() {
this.stopTimer();
window.removeEventListener('online', this.windowEventListener);
window.removeEventListener('offline', this.windowEventListener);
this.handlers = [];
}
}
export default NetworkDetector;
5.3 如何在React/Vue中使用
React里可以用Hook订阅状态:
tsx复制import { useEffect, useState } from 'react';
export function useNetworkStatus() {
const [status, setStatus] = useState<'online' | 'offline' | 'unknown'>('unknown');
useEffect(() => {
const detector = new NetworkDetector({
probeUrl: '/api/health',
});
const unsubscribe = detector.subscribe(state => {
setStatus(state.status);
});
return () => {
unsubscribe();
detector.destroy();
};
}, []);
return status;
}
使用:
tsx复制function NetworkBanner() {
const status = useNetworkStatus();
if (status === 'offline') {
return <div className="offline-banner">当前网络不可用,已切换为离线模式</div>;
}
if (status === 'unknown') {
return <div className="pending-banner">正在检测网络状态...</div>;
}
return null;
}
Vue里写一个组合式函数也很快:
typescript复制import { ref, onMounted, onUnmounted } from 'vue';
export function useNetworkStatus() {
const status = ref<'online' | 'offline' | 'unknown'>('unknown');
let detector: NetworkDetector | null = null;
onMounted(() => {
detector = new NetworkDetector({ probeUrl: '/api/health' });
detector.subscribe(state => {
status.value = state.status;
});
});
onUnmounted(() => {
detector?.destroy();
});
return status;
}
这些封装的思路比直接到处写 navigator.onLine 判断要靠谱得多,因为状态源唯一,变更逻辑集中。
6. 生产环境中我踩过的真实坑位
6.1 探测接口被HTTP缓存坑了
有一次我把探测接口地址设为同域下的 /api/ping,后端返回的是一个固定的 { code: 0 },但响应头没设置 Cache-Control。断网后,浏览器直接使用了内存缓存,导致探测请求根本不会真正发出,或者发了也立即从缓存返回,然后判定"在线"。
解决方式很简单:
- 服务端对探测接口设置
Cache-Control: no-store - 请求时加上时间戳参数或者随机数,打破缓存
- 使用
fetch时设置cache: 'no-store'
6.2 断网后的请求堆积
监听 offline 事件后,如果页面里正在发请求,这些请求不会自动取消。用户断网的瞬间,已经发出的请求会一直挂着直到超时,通常是60秒甚至更长。如果用户在这期间不断触发新的操作,请求会越攒越多,恢复网络后全部一次性发出,服务器压力巨大,前端也卡。
我在项目里做了一个"离线模式下的请求暂停队列":
- 检测到
offline,把后续网络请求都放进队列里 - 检测到
online(并且探测成功),再把队列里的请求按顺序发出去
这个思路对在线文档类应用特别有用,能避免断网期间用户操作产生的无意义请求。
6.3 后端服务挂了,别误判成断网
主动探测最要命的问题是:探测接口依赖后端,后端一挂,所有用户都会显示"离线"。这相当尴尬。我遇到过一次后端发布上线期间网关短暂不可用,结果前端全都出现了离线横幅。
所以设计时要考虑:
- 探测接口和业务接口分离,最好用CDN静态资源或独立健康检查服务
- 如果必须用后端接口,探测失败时先标记为"unknown",不要直接展示"离线"
- 连续2次探测失败再切换为离线状态,连续1次成功就恢复在线,通过阈值减少抖动
6.4 WebView中事件不触发的特殊处理
在App内嵌的WebView里,window 上的 online/offline 事件有时候完全不触发。这是因为原生层没有把网络状态变化通知给WebView的渲染进程。这种情况最稳定的做法是:让原生端通过 WebViewJavascriptBridge 注入网络状态,或者前端轮询接口。
如果原生端愿意配合,可以让他们在Java/Kotlin或OC/Swift层监听网络变化,然后通过 evaluateJavascript 调用前端的全局回调:
javascript复制// 原生端调用
window.__nativeNetworkChanged && window.__nativeNetworkChanged(true);
前端在 window 上挂一个方法:
javascript复制window.__nativeNetworkChanged = (isOnline) => {
console.log('原生网络状态:', isOnline ? 'online' : 'offline');
if (isOnline) {
detector.probe();
} else {
detector.updateState('offline');
}
};
没有原生端配合的时候,只能靠主动探测兜底,把探测间隔缩短到5-8秒。
7. 检测到状态变化之后,前端还该做什么
很多开发者把网络状态检测做完就结束了,其实这只是第一步。检测到断网或恢复只是手段,真正的价值在于状态驱动业务逻辑。
7.1 断开时的降级策略
检测到离线后,可以做这些事:
- 停止发请求:避免用户反复点击造成的大量失败请求
- 缓存关键数据:把当前页面的数据存到
localStorage或IndexedDB - 启用本地编辑:允许用户离线填写表单,但不提交
- 展示离线UI:顶部提示条、按钮置灰、隐藏"提交"按钮
7.2 恢复时的同步策略
检测到恢复在线后,需要处理这些事:
- 刷新数据:重新拉取接口,因为离线期间的缓存可能不是最新的
- 提交离线数据:把离线期间填入的表单、操作记录一次提交
- 关闭离线UI:移除提示条,恢复按钮可用状态
- 重新建立长连接:WebSocket、SSE这类连接在断网时会被断开,而且通常不会自动重连
这里有一个容易忽略的点:** online 事件触发后,WebSocket不一定会自动恢复**。需要手动检查连接状态并重连。所以网络状态检测还承担了"长连接守护者"的职责。
7.3 与状态管理库的结合
在Vuex、Pinia、Redux或Zustand里维护网络状态,是一个不错的选择。把 NetworkDetector 的订阅逻辑收敛到一个store模块里,业务组件只读取store里的 networkStatus,不会各自为政。
以Zustand为例:
typescript复制import { create } from 'zustand';
interface NetworkStore {
status: 'online' | 'offline' | 'unknown';
setStatus: (s: NetworkStore['status']) => void;
}
export const useNetworkStore = create<NetworkStore>((set) => ({
status: 'unknown',
setStatus: (status) => set({ status }),
}));
// 初始化时订阅
export function initNetworkDetection() {
const detector = new NetworkDetector({ probeUrl: '/api/health' });
detector.subscribe(state => {
useNetworkStore.getState().setStatus(state.status);
});
return detector;
}
这样网络状态就成了全局状态的一部分,任何组件、任何工具函数都能读取,且变更路径清晰。调试的时候通过redux-devtools或Vue Devtools也能直接看到网络状态的变化记录,排查问题非常方便。
8. 一些额外的边界场景
8.1 弱网不等于断网
还有一类情况容易被忽略:网络不是完全断开,而是非常弱。比如信号只有一格,请求发出后10秒、20秒才返回。这时候 navigator.onLine 可能一直是 true,主动探测如果超时时间设得太长,会一直不出结果。
弱网和断网的处理策略不一样。断网可以直接展示离线提示;弱网应该展示"网络较慢"或者调整请求策略,比如降低图片质量、延长超时时间。如果想精细处理弱网,可以用性能监测API或自定义请求耗时统计来判断,但这已经超出 online/offline 的讨论范围了,属于网络质量评估体系的课题。
8.2 隐私模式和IndexedDB的坑
如果离线时想用 IndexedDB 存储数据,在Safari的隐私模式下,IndexedDB 可能抛异常。断网降级方案如果依赖本地存储,建议写一个统一的存储适配层,优先IndexedDB,降级到 localStorage,再降级到内存。同时要 try-catch 包裹存储操作,避免离线状态下的存储异常影响页面主流程。
8.3 多标签页状态同步
如果用户在多个标签页打开同一应用,一个标签页断网,其他标签页里的网络状态是独立的。理论上每个标签页都会收到自己的 offline 事件,但实际由于标签页的后台冻结策略,后台标签页可能不触发事件,恢复后状态无法同步。
想要多个标签页之间同步网络状态,可以用 BroadcastChannel 或 localStorage 事件来广播状态变化,但也可能遇到存储被清除、对方标签页被冻结等问题。这是我目前遇到的复杂度比较高的边界场景,一般的业务项目不需要做到这个程度。
如果你确实需要,可以做一个折中方案:在每个标签页可见时(visibilitychange 到 visible)强制触发一次 detector.probe(),保证从后台切回前台的标签页拿到最新状态。
9. 我自己的项目实施经验小结
用 navigator.onLine 和 online/offline 事件这套方案,我在实际项目里的核心体会是:它们只是"信号源",不是"权威结论"。真正能支撑业务稳定性的,是围绕事件做的状态机和主动探测兜底。
把一个零散的需求做成项目里可靠的模块,有几个先后顺序值得记住:
- 先明确业务真正需要的是"断网提示"还是"离线数据同步",这决定了你要做到什么深度。
- 别把浏览器事件当成唯一的判断依据,先接上
online/offline监听,再补一层主动探测。 - 探测接口、探测频率、失败阈值都做成可配置的,上线后根据真实环境和后端稳定性调参,而不是把所有判断逻辑都写死在业务代码里。
- 状态变更之后要触发的业务逻辑,一定要收敛到一处处理,否则几十个组件各自监听网络事件,排查起来会想打人。
如果你在面试里被问到"前端如何检测网络状态",除了说出 navigator.onLine 和事件绑定之外,能主动补一句"这个API存在平台差异,我会配合主动探测做二次确认",面试官多半会对你高看一眼。因为这已经不是背API的层面了,说明你真的考虑过把它放到生产环境里怎么用。
