一次断网重连引发的「模块加载缓存」攻坚战

记一次线上必现的疑难杂症------当用户在 Vue3+Vite+TS 项目中断网后操作,重连时 iOS 端反复报「动态导入模块失败」,而安卓却安然无恙。这场从前端到客户端的「跨层排查」,最终揭开了 WKWebView 模块缓存机制的神秘面纱。

一、问题现场:断网点击,iOS 独有的「模块加载死锁」 技术栈与场景

项目基于 Vue3+Vite+TS 开发,包含首页、详情页等多页面,路由跳转通过 router.push 实现。核心复现步骤:

  1. 首页加载完成(模块预加载完毕);

  2. 主动断网(关闭设备WiFi/流量);

  3. 点击列表项,router.push 跳转至详情页;

  4. 观察控制台报错:

    • 浏览器/安卓: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 文档与调试发现:

正常联网流程

  1. 页面加载时,<link rel="modulepreload"> 预加载初始依赖(如 vendor.js、main.js);
  2. 路由跳转触发 import('./Detail.xxxx.js'),直接从内存/磁盘缓存读取模块;
  3. 一切正常。

⚠️ 断网时的「致命缓存」

  1. 首次打开页面:仅预加载初始依赖,懒加载的路由 chunk(如详情页 Detail.xxxx.js)未被 modulepreload 覆盖(Vite 按需分割代码);
  2. 断网点击跳转:import() 尝试加载未预加载的 chunk,因网络失败,WKWebView 将该 URL 标记为「永久加载失败」并缓存(类似 HTTP 5xx 缓存策略);
  3. 网络恢复后:再次 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 路由状态依赖内存,刷新后无法还原,暂未彻底解决。欢迎大佬们讨论更优解!

结语

这场「模块加载缓存」攻坚战,本质是前端「模块分割」与客户端「缓存机制」的碰撞。当纯前端手段失效时,「跨层协作」(前端监听+客户端清缓存)往往能打开新思路------毕竟,复杂的线上问题,从来不是单一端能独立搞定的。

相关推荐
jinzeming9992 小时前
Vue3 PDF 预览组件设计与实现分析
前端
编程小Y2 小时前
Vue 3 + Vite
前端·javascript·vue.js
GDAL2 小时前
前端保存用户登录信息 深入全面讲解
前端·状态模式
大菜菜2 小时前
Molecule Framework -EditorService API 详细文档
前端
Anita_Sun2 小时前
😋 核心原理篇:线程池的 5 大核心组件
前端·node.js
灼华_2 小时前
Web前端移动端开发常见问题及解决方案(完整版)
前端
_请输入用户名2 小时前
Vue3 Patch 全过程
前端·vue.js
孟祥_成都2 小时前
nest.js / hono.js 一起学!字节团队如何配置多环境攻略!
前端·node.js
用户4099322502122 小时前
Vue3数组语法如何高效处理动态类名的复杂组合与条件判断?
前端·ai编程·trae