记一次线上必现的疑难杂症------当用户在 Vue3+Vite+TS 项目中断网后操作,重连时 iOS 端反复报「动态导入模块失败」,而安卓却安然无恙。这场从前端到客户端的「跨层排查」,最终揭开了 WKWebView 模块缓存机制的神秘面纱。
一、问题现场:断网点击,iOS 独有的「模块加载死锁」 技术栈与场景
项目基于 Vue3+Vite+TS 开发,包含首页、详情页等多页面,路由跳转通过 router.push 实现。核心复现步骤:
-
首页加载完成(模块预加载完毕);
-
主动断网(关闭设备WiFi/流量);
-
点击列表项,router.push 跳转至详情页;
-
观察控制台报错:
• 浏览器/安卓:Failed to fetch dynamically imported module(动态导入模块失败);
• iOS:TypeError: Importing a module script failed(更致命的模块脚本导入失败)。
二、初战:前端「网络恢复重载」方案的折戟
第一反应是通过「网络恢复后重载页面」修复模块加载状态,于是设计了 NetworkMonitor 工具类监听网络状态,核心逻辑如下:
1. 网络监听器:断网预警+智能重载
kotlin
// utils/networkMonitor.ts
import { ref } from 'vue';
interface NetworkState {
isOnline: boolean;
lastOnline: Date | null;
showOfflineWarning: boolean; // 断网提示显隐
}
class NetworkMonitor {
private state = ref<NetworkState>({
isOnline: navigator.onLine,
lastOnline: navigator.onLine ? new Date() : null,
showOfflineWarning: false,
});
private reloadTimer: number | null = null;
private offlineTime: number | null = null;
constructor() {
this.init();
}
private init() {
// 监听浏览器原生 online/offline 事件
window.addEventListener('online', this.handleOnline.bind(this));
window.addEventListener('offline', this.handleOffline.bind(this));
}
private handleOnline() {
console.log('[网络监控] 网络已恢复');
this.state.value.isOnline = true;
this.state.value.lastOnline = new Date();
// 断网超30秒:延迟3秒重载(确保网络稳定)
if (this.offlineTime) {
const offlineDuration = Date.now() - this.offlineTime;
if (offlineDuration > 30000) {
this.scheduleReload();
}
}
this.offlineTime = null;
}
private handleOffline() {
console.warn('[网络监控] 网络已断开');
this.state.value.isOnline = false;
this.state.value.showOfflineWarning = true;
this.offlineTime = Date.now();
// 取消待执行重载
this.reloadTimer && clearTimeout(this.reloadTimer);
}
private scheduleReload() {
this.reloadTimer = window.setTimeout(() => {
console.log('[网络监控] 网络稳定,重载页面修复模块状态');
window.location.reload(); // 硬刷新清除缓存
}, 3000);
}
// 暴露状态与方法
public getState = () => this.state;
public hideWarning = () => (this.state.value.showOfflineWarning = false);
public manualReload = () => window.location.reload();
public destroy = () => {
window.removeEventListener('online', this.handleOnline);
window.removeEventListener('offline', this.handleOffline);
this.reloadTimer && clearTimeout(this.reloadTimer);
};
}
export const networkMonitor = new NetworkMonitor(); // 单例导出
2. 优化:区分「已点击错误页」与「未点击页」的刷新策略
初期方案在浏览器中有效,但单页应用(SPA)的 reload() 会清空路由栈,导致自定义导航栏「返回箭头」异常变为「Home图标」。于是增加判断:
• 若用户已点击触发 chunk 加载失败(监听 router.onError),则 reload() 彻底重置;
• 若未点击(仅断网未操作),则用 replace() 刷新当前页,保留路由栈。
但新问题接踵而至:iOS App 内嵌 H5 即使触发 reload(),仍报 Importing a module script failed------前端方案彻底「败北」。
三、转机:深入 WKWebView 内核------模块缓存的「死亡标记」
既然前端无解,转向 iOS 端 WKWebView 的机制深挖。通过查阅 Apple 文档与调试发现:
✅ 正常联网流程
- 页面加载时,
<link rel="modulepreload">预加载初始依赖(如 vendor.js、main.js); - 路由跳转触发
import('./Detail.xxxx.js'),直接从内存/磁盘缓存读取模块; - 一切正常。
⚠️ 断网时的「致命缓存」
- 首次打开页面:仅预加载初始依赖,懒加载的路由 chunk(如详情页 Detail.xxxx.js)未被 modulepreload 覆盖(Vite 按需分割代码);
- 断网点击跳转:import() 尝试加载未预加载的 chunk,因网络失败,WKWebView 将该 URL 标记为「永久加载失败」并缓存(类似 HTTP 5xx 缓存策略);
- 网络恢复后:再次 import() 同一 URL,WebView 直接返回失败,不再发起网络请求------这就是 iOS 独有的「模块加载死锁」。
四、破局:联合客户端「清除 WebView 缓存」
核心思路:绕过 WebView 缓存,强制重新加载模块。需客户端提供 bridge 能力清除 WebView 缓存,结合前端网络状态联动实现。
1. 网络状态展示组件:触发缓存清除
新增组件监听网络状态,在网络恢复时调用客户端 clearWebCache 方法,并通过「时间戳 URL」强制刷新:
xml
<template>
<view v-if="showOfflineWarning" class="network-status">
<view class="offline-banner" :style="{ paddingTop: ((systemInfo as any)?.statusBarHeight || 12) + 'px' }" :class="{ reconnecting: isReconnecting }">
<view class="banner-content">
<text class="status-icon">
{{ isReconnecting ? '🔄' : '⚠️' }}
</text>
<text class="status-message">
{{ isReconnecting ? '网络已恢复,正在重新连接...' : '网络连接已断开,请检查网络设置' }}
</text>
<view class="banner-actions">
<view v-if="!isReconnecting" class="retry-btn" :disabled="retrying" @click="retryConnection">
{{ retrying ? '重试中...' : '重试连接' }}
</view>
<view v-if="isReconnecting" class="cancel-btn" @click="cancelReload">取消刷新</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { bridgeEnv, canIUse, jsBridge } from '@mono/utils'
import { useSystemInfo } from '@mono/hooks/global'
import { networkMonitor } from '@mono/utils/networkMonitor'
const { systemInfo } = useSystemInfo()
const retrying = ref(false)
const reloadScheduled = ref(false)
// 从网络监视器获取状态
const networkState = networkMonitor.getState()
// 计算属性
const showOfflineWarning = computed(() => networkState.value.showOfflineWarning)
const isOnline = computed(() => networkState.value.isOnline)
const isReconnecting = computed(() => isOnline.value && reloadScheduled.value)
// 重试连接
const retryConnection = async (): Promise<void> => {
retrying.value = true
try {
// 检查网络连接
const isAccessible = await networkMonitor.checkResourceAccess()
if (isAccessible) {
if (bridgeEnv.isIosApp() && canIUse('bridge.clearWebCache')) {
jsBridge.invoke('clearWebCache')
}
// 使用window.location.reload()进行硬刷新,确保清除模块加载错误状态
window.location.reload()
} else {
// 如果仍然无法访问,等待一段时间再重试
setTimeout(() => {
retrying.value = false
}, 2000)
}
} catch (error) {
alert(error)
console.error('重试连接失败:', error)
retrying.value = false
}
}
// 取消自动刷新
const cancelReload = (): void => {
reloadScheduled.value = false
networkMonitor.hideWarning()
}
// 监听网络状态变化
onMounted(() => {
// 监听网络恢复事件,设置重新连接状态
const unwatch = watch(
() => networkState.value.isOnline,
newVal => {
if (newVal) {
// 网络恢复,标记为正在重新连接
reloadScheduled.value = true
// 2秒后自动隐藏警告并重新加载
setTimeout(() => {
// 使用完整URL重定向,避免WKWebView缓存错误
if (reloadScheduled.value) {
networkMonitor.hideWarning()
if (bridgeEnv.isIosApp() && canIUse('bridge.clearWebCache')) {
jsBridge.invoke('clearWebCache')
}
window.location.reload()
}
}, 2000)
} else {
reloadScheduled.value = false
}
}
)
// 清理函数
onUnmounted(() => {
unwatch()
})
})
</script>
<style lang="less" scoped>
.network-status {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100000;
.offline-banner {
background: #ff4757;
color: white;
padding: 24rpx 0;
transition: all 0.3s ease;
&.reconnecting {
background: #2ed573;
}
.banner-content {
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
padding: 0 40rpx;
}
.status-icon {
margin-right: 20rpx;
font-size: 36rpx;
}
.status-message {
flex: 1;
font-size: 28rpx;
font-weight: 500;
}
.banner-actions {
margin-left: 30rpx;
}
.retry-btn,
.cancel-btn {
padding: 12rpx 24rpx;
border: none;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-btn {
background: white;
color: #ff4757;
&:hover:not(:disabled) {
background: #f8f8f8;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.cancel-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
}
}
</style>
2. 关键动作:客户端 clearWebCache 桥接
需 iOS 端同学实现 jsBridge.invoke('clearWebCache'),核心清除 WebView 的:
• 内存缓存(临时模块加载状态);
• 磁盘缓存(持久化的「失败模块标记」);
• 模块预加载缓存(modulepreload 残留状态)。
五、结果与反思
成果
多机型测试验证:iOS 端断网重连后不再报模块加载错误,且通过「是否点击错误页」的判断,尽可能保留了 SPA 路由栈体验。
遗留问题:SPA 路由栈清空的「痛」
尽管通过 replace() 优化了未点击错误页的场景,但 reload() 仍会清空路由栈(导航栏箭头变 Home 图标)。尝试过「记录历史路由栈→恢复」方案,但因 SPA 路由状态依赖内存,刷新后无法还原,暂未彻底解决。欢迎大佬们讨论更优解!
结语
这场「模块加载缓存」攻坚战,本质是前端「模块分割」与客户端「缓存机制」的碰撞。当纯前端手段失效时,「跨层协作」(前端监听+客户端清缓存)往往能打开新思路------毕竟,复杂的线上问题,从来不是单一端能独立搞定的。