前端重连机制

37 阅读3分钟

在前端的重连机制方案中,最暴力的就是直接使用 window.location.reload() ,但是这可能会导致用户陷入无限刷新的死循环(比如用户断网了,刷新多少次也加载不出来),同时也非常消耗性能和用户流量。

我们可以从重试机制、状态反馈、错误拦截三个维度来进行优化。下面以 mapbox 的底图加载失败为案例进行测试。

1. 指数退避的自动重试

不要立即刷新,而是尝试在代码层面重新加载地图资源。如果重试多次失败,再引导用户手动刷新。

let retryCount = 0; // vue 组件中的 script 只会在 setup 阶段渲染一次,不是响应式的状态可以直接用 let,react 每次渲染的时候,函数体都会重新执行,需要用 ref 维持
const MAX_RETRIES = 3;

this.mapInstance.on('error', (error) => {
  console.error('地图加载失败', error);

  if (retryCount < MAX_RETRIES) {
    retryCount++;
    const delay = Math.pow(2, retryCount) * 1000; // 2s, 4s, 8s 后重试
    
    console.warn(`正在进行第 ${retryCount} 次尝试重连...`);
    setTimeout(() => {
      // 尝试重新设置样式或触发加载逻辑,而不是刷新整个页面
      this.mapInstance.setStyle(this.currentStyleUrl); 
    }, delay);
  } else {
    // 超过次数后,显示 UI 提示而不是硬刷新
    this.showErrorUI('网络状况不佳,请检查网络后手动刷新');
  }
});

2. 添加 UI 状态遮罩|UI降级(防止白屏感)

与其让页面白着,不如给用户一个明确的反馈。在初始化地图时显示一个 Loading,如果加载失败,切换为“刷新按钮”。

  • 加载中: 显示骨架屏或 Loading 动画。
  • 失败后: 移除 Loading,显示“加载失败,点击重试”按钮。这样避免了自动刷新的突兀感。

3. 检测特定错误类型

MapBox 的 error 事件会捕获各种错误(包括样式错误、Tile 请求失败等)。有些错误不值得刷新页面:

this.mapInstance.on('error', (e) => {
  // 如果只是某个瓦片加载 404,不应该刷新页面
  if (e.error?.status === 404) return;

  // 如果是鉴权问题,刷新也没用
  if (e.error?.status === 401) {
    console.error('MapBox Token 无效');
    return;
  }

  // 针对核心资源加载失败的处理
  this.handleCriticalError();
});

4. 离线/断网检测

利用浏览器的原生事件,在网络恢复的第一时间尝试重载。

window.addEventListener('online', () => {
  if (this.mapLoadFailed) {
    this.mapInstance.setStyle(this.currentStyleUrl);
  }
});

5. 综合方案

将逻辑封装成一个状态机

  1. 状态 A (Loading): 页面中心显示转圈。

  2. 状态 B (Success): 监听到 loadidle 事件,隐藏转圈。

  3. 状态 C (Error): 监听到 error

    • 记录重试次数。
    • 若次数未满:微调样式 URL 触发重新请求(或调用 map.remove() 后重新 new Mapboxgl.Map())。
    • 若次数已满:弹出一个浮窗提示:“地图资源加载超时 [重试按钮]”。
// 状态配置
const MAP_CONFIG = {
  maxRetries: 3,         // 最大自动重试次数
  retryDelay: 3000,      // 基础重试延迟 (ms)
  isRecovering: false,   // 是否正在恢复中
  retryCount: 0
};

this.mapInstance.on('error', (e) => {
  // 1. 过滤掉非致命错误(例如某个瓦片 404 不应该导致重刷)
  if (e.error?.status === 404 || e.error?.status === 403) {
    console.warn('忽略非致命瓦片加载错误');
    return;
  }

  console.error('地图核心资源加载失败:', e.error);

  // 2. 自动重试逻辑
  if (MAP_CONFIG.retryCount < MAP_CONFIG.maxRetries) {
    MAP_CONFIG.retryCount++;
    
    // 使用简单的指数退避,避免频繁冲击服务器
    const delay = MAP_CONFIG.retryCount * MAP_CONFIG.retryDelay;
    
    console.log(`将在 ${delay}ms 后尝试第 ${MAP_CONFIG.retryCount} 次自动修复...`);
    
    setTimeout(() => {
      // 核心技巧:重新触发 style 加载通常比 reload 页面更轻量
      // 如果 mapInstance 还没坏死,尝试 resetStyle
      this.mapInstance.setStyle(this.currentStyleUrl);
    }, delay);

  } else {
    // 3. 最终降级处理:弹出友好的提示层而非强制刷新
    this.showMapErrorOverlay();
  }
});

// 4. 监听网络恢复
window.addEventListener('online', () => {
  if (MAP_CONFIG.retryCount >= MAP_CONFIG.maxRetries) {
    // 网络恢复了,自动尝试最后一次
    this.mapInstance.setStyle(this.currentStyleUrl);
    // 隐藏错误遮罩
    this.hideMapErrorOverlay();
  }
});

更加完善的内部封装

class MapManager {
  constructor() {
    this.mapInstance = null;
    this.retryCount = 0;
    this.maxRetries = 3;
    this.styleUrl = 'mapbox://styles/mapbox/streets-v11';
  }

  // 显示错误遮罩
  showErrorOverlay(message) {
    const container = document.getElementById('map'); // 地图容器ID
    let overlay = container.querySelector('.map-error-overlay');
    
    if (!overlay) {
      overlay = document.createElement('div');
      overlay.className = 'map-error-overlay';
      overlay.innerHTML = `
        <p class="error-msg">${message}</p>
        <button class="map-error-btn">立即重试</button>
      `;
      overlay.querySelector('button').onclick = () => window.location.reload();
      container.appendChild(overlay);
    } else {
      overlay.classList.remove('is-hidden');
      overlay.querySelector('.error-msg').innerText = message;
    }
  }

  // 隐藏错误遮罩
  hideErrorOverlay() {
    const overlay = document.querySelector('.map-error-overlay');
    if (overlay) overlay.classList.add('is-hidden');
  }

  initMap() {
    // ... 初始化 mapInstance 的代码 ...

    this.mapInstance.on('error', (e) => {
      // 过滤掉瓦片层级的 404 错误
      if (e.error?.status === 404) return;

      if (this.retryCount < this.maxRetries) {
        this.retryCount++;
        console.warn(`地图加载异常,第 ${this.retryCount} 次重试...`);
        
        // 尝试重置样式来恢复,而不是刷新页面
        setTimeout(() => {
          this.mapInstance.setStyle(this.styleUrl);
        }, this.retryCount * 2000);
      } else {
        // 达到上限,显示手动重试 UI
        this.showErrorOverlay('网络连接超时,请检查网络后重试');
      }
    });

    this.mapInstance.on('load', () => {
      this.retryCount = 0; // 加载成功,重置计数
      this.hideErrorOverlay();
    });
  }
}

6. 偶发网络问题手动重现

模拟各种 error 情况是至关重要的,如果是网络偶发问题,不需要去拔网线,通过修改关键key(将mapbox的style和token修改为错误的)和在浏览器开发者工具(F12)操作模拟网络问题就能覆盖 90% 的场景。

1. 模拟网络彻底断开 (Offline)

这是测试“重试逻辑”和“网络恢复自动加载”最直接的方法。

  • 操作步骤

    1. 打开 Chrome 开发者工具 -> Network (网络) 标签页。
    2. 找到 No Throttling (不限速) 下拉菜单。
    3. 选择 Offline (离线)
  • 结果:此时地图的所有瓦片请求和样式请求都会立即失败,触发 error 事件。

2. 模拟特定资源加载失败 (404/403/500)

如果你想模拟 Mapbox 的 Token 失效(401)或者某个图层样式找不到了(404),可以使用Request Blocking

  • 操作步骤

    1. 在开发者工具中,按下 Ctrl + Shift + P (Mac: Cmd + Shift + P)。

    2. 输入 Blocking,选择 Show Network Request Blocking

    3. 点击 + 号,添加过滤规则或者选中某个请求右键进行block,刷新后会针对性的block

      • 输入 api.mapbox.com:拦截所有地图数据请求。
      • 输入 *.png*.pbf:只拦截瓦片请求。
  • 结果:被拦截的请求会显示为 (blocked:devtools),触发地图的错误回调。

3. 模拟弱网/高延迟 (Latency)

模拟地图加载极其缓慢,导致超时的场景。

  • 操作步骤

    1. Network 标签页的下拉菜单中选择 Slow 3G
    2. 或者点击 Add... 自定义一个配置(例如延迟 10000ms)。
  • 结果:可以观察你的 Loading 遮罩层是否能正常显示,以及在长时间无响应后是否会触发你写的超时重试逻辑。

4. 代码层面手动触发 (Manual Trigger)

如果你只是想调试 UI 样式(比如看那个错误遮罩层好不好看),不需要真的制造网络错误,直接在控制台调你的实例方法:

  • 操作步骤: 在 Console 中输入:
// 假设你的 mapInstance 挂载在 window 下或者你能访问到
mapInstance.fire('error', { error: { message: '模拟错误', status: 401 } });
  • 结果:这会手动触发你绑定的 .on('error', ...) 监听函数。

5. 模拟地理位置权限失败

如果你的地图涉及用户定位:

  • 操作步骤

    1. 点击地址栏左侧的 锁图标
    2. 位置信息 (Location) 设置为 屏蔽 (Block)
  • 结果:测试 geolocate 插件报错时的 UI 反馈。