构建健壮的 Vue 3 图片加载指令:自动重试与优雅降级 (v-img-retry)

471 阅读19分钟

图片重载进阶版

一、 设计目标与要解决的问题

在 Web 开发中,图片加载失败是一个常见问题,原因多种多样:

  • 网络波动或超时: 用户网络不稳定或服务器响应慢。
  • 服务器临时错误 (如 503 Service Unavailable): 提供图片的服务器暂时不可用。
  • 资源不存在 (404 Not Found): 图片 URL 错误或资源已被移除。
  • 跨域或权限问题 (CORS / 403 Forbidden): 浏览器安全策略阻止加载。
  • 图片文件损坏。

直接使用 <img> 标签时,加载失败通常只会显示一个破碎的图片图标或 alt 文本,用户体验不佳。我们可以创建一个 Vue 指令来改善这种情况,实现以下目标:

  1. 自动重试: 对于临时的网络或服务器问题(如 503),自动尝试重新加载图片若干次,提高加载成功率。
  2. 优雅降级: 在所有重试尝试都失败后,或者对于确定无法加载的资源(如 404),显示一个预定义的占位符(Fallback)图片,而不是破碎的图标。
  3. 自然集成: 指令应该易于使用,开发者只需像平常一样设置 <img>src 属性即可,指令自动附加重试和降级逻辑。
  4. 可配置性: 允许开发者自定义重试次数、延迟策略和 fallback 图片。
  5. 状态管理与清理: 指令需要管理每个图片的重试状态,并在元素卸载时进行清理,避免内存泄漏。
  6. 响应 src 变化: 当绑定的 src 属性动态改变时,指令应该能重置状态并尝试加载新的图片。

二、 核心设计思路

为了实现上述目标,我们采用了以下核心设计思路:

  1. 基于 src 属性驱动: 指令的核心逻辑应该围绕 <img> 元素的 src 属性。指令不直接从 binding.value 获取 URL,而是监听和响应 el.src 的变化以及加载结果。
  2. 监听 error 事件: <img> 元素在加载失败时会触发 error 事件。这是启动重试逻辑的关键入口点。
  3. 状态管理机制: 需要为每个应用了指令的 <img> 元素维护一个独立的状态对象,记录其原始目标 src、当前重试次数、是否正在加载、是否已显示 fallback 等信息。使用 WeakMap 是理想的选择,因为它允许在元素被垃圾回收时自动清理关联的状态。
  4. 异步加载与重试逻辑 (attemptLoad 函数):
    • 使用 new Image(): 为了避免直接操作 el.src 导致不必要的重绘或潜在的死循环(如果 fallback 图片也失败),创建一个临时的 Image 对象 (img = new Image()) 来执行实际的加载尝试。
    • onload 回调: 当临时 Image 加载成功时,将 originalSrc 赋值给真实的 el.src,并清理状态。
    • onerror 回调: 当临时 Image 加载失败时,判断是否还有重试次数。
      • 可重试: 计算延迟时间(采用指数退避策略增加等待时间,并设置最大上限),使用 setTimeout 安排下一次调用 attemptLoad
      • 重试耗尽: 将真实的 el.src 设置为 fallbackSrc,标记状态为 isFallbackActive,清理定时器。
  5. 生命周期钩子集成:
    • mounted: 初始化状态,添加 error 事件监听器,处理初始加载(特别是已完成但无效的情况)。
    • updated: 检测 el.src 属性是否发生变化。如果变化,重置状态,清理旧定时器,重新添加监听器,并调用 attemptLoad 开始加载新图片。
    • beforeUnmount: 清理定时器和事件监听器,从 WeakMap 中移除状态。
  6. 配置传递:
    • Fallback 图片: 通过指令的 arg (参数) 传递,例如 v-img-retry:[fallbackUrl]
    • 重试次数和延迟: 通过元素的 data-* 属性传递(如 data-retry-limit, data-retry-delay),指令内部解析这些属性。

三、 具体实现详解

// directives/vImgRetryFromSrc.js

// --- 全局配置常量 ---
// 这些常量定义了指令的默认行为,可以在指令外部或内部根据需要调整

/** 默认的最大重试次数 */
const DEFAULT_RETRY_LIMIT = 3;
/** 默认的初始重试延迟时间(毫秒),用于指数退避策略 */
const DEFAULT_RETRY_DELAY = 1000;
/** 默认的最终失败占位符图片 URL (请务必替换为你的实际路径!) */
const DEFAULT_FALLBACK_SRC = "/images/placeholder-error.png"; // 示例路径,请替换
/** 全局设置的最大重试延迟时间(毫秒),防止指数退避无限增长 */
const MAX_RETRY_DELAY_MS = 10000; // 例如 10 秒

// --- 状态存储 ---
/**
 * 使用 WeakMap 存储每个应用指令的 img 元素对应的状态对象。
 * WeakMap 的好处是当 img 元素被垃圾回收时,对应的状态也会被自动清理,
 * 有效防止内存泄漏。
 * Key: HTMLImageElement
 * Value: RetryState (包含重试逻辑所需的状态)
 */
const elementStates = new WeakMap();

// --- 类型定义 (JSDoc 形式,用于说明 state 对象结构) ---
/**
 * @typedef {object} RetryState
 * @property {string | null} originalSrc - 当前指令尝试加载的目标图片 URL。
 * @property {number} currentTry - 当前已尝试加载的次数(包括初始尝试)。
 * @property {number} retryLimit - 此元素允许的最大重试次数。
 * @property {number} retryDelay - 此元素的初始重试延迟时间 (ms)。
 * @property {string} fallbackSrc - 重试耗尽后显示的占位符图片 URL。
 * @property {boolean} isLoading - 标记当前是否正在后台尝试加载/重试图片。
 * @property {number | null} currentTimerId - 存储当前 setTimeout 的 ID,用于取消延迟重试。
 * @property {boolean} isFallbackActive - 标记当前 img 元素是否已显示 fallback 图片。
 */

// --- 核心函数 ---

/**
 * 初始化或重置指定 img 元素的重试状态。
 * 从指令绑定、元素 data-* 属性和全局常量中获取配置。
 * @param {HTMLImageElement} el - 目标 img 元素。
 * @param {object} binding - Vue 指令的绑定对象,用于获取 arg (fallbackSrc)。
 * @returns {RetryState} - 初始化或重置后的状态对象。
 */
function initializeState(el, binding) {
  // 1. 获取 Fallback URL:优先使用指令参数 `v-img-retry:[fallbackUrl]`,否则用默认值。
  const fallbackSrc = binding.arg || DEFAULT_FALLBACK_SRC;
  // 2. 获取重试次数:优先读取元素的 `data-retry-limit` 属性,解析为整数,无效则用默认值。
  const retryLimitRaw = parseInt(el.dataset.retryLimit, 10);
  const retryLimit = isNaN(retryLimitRaw) ? DEFAULT_RETRY_LIMIT : retryLimitRaw;
  // 3. 获取初始延迟:优先读取元素的 `data-retry-delay` 属性,解析为整数,无效则用默认值。
  const retryDelayRaw = parseInt(el.dataset.retryDelay, 10);
  const retryDelay = isNaN(retryDelayRaw) ? DEFAULT_RETRY_DELAY : retryDelayRaw;

  // 4. 创建状态对象
  const state = {
    originalSrc: el.src, // 获取元素在这一刻的 src 作为初始目标
    currentTry: 0,
    retryLimit: retryLimit,
    retryDelay: retryDelay,
    fallbackSrc: fallbackSrc,
    isLoading: false,
    currentTimerId: null,
    isFallbackActive: false,
  };
  // 5. 将状态存入 WeakMap
  elementStates.set(el, state);
  // console.log(`[v-img-retry] Initialized state for ${el.src || 'empty src'}`, state);
  return state;
}

/**
 * 核心函数:尝试加载图片,包含完整的重试和 fallback 逻辑。
 * 使用临时的 Image 对象进行加载尝试,避免直接操作 el.src 引发问题。
 * @param {HTMLImageElement} el - 目标 img 元素。
 * @param {RetryState} state - 与该元素关联的状态对象。
 */
function attemptLoad(el, state) {
  // 1. 前置检查:如果目标 URL 无效,或已处于 fallback 状态,则停止。
  if (!state.originalSrc || state.isFallbackActive) {
    // console.log(`[v-img-retry] Load attempt skipped: No src or fallback active for ${state.originalSrc}`);
    state.isLoading = false; // 确保 loading 状态结束
    return;
  }

  // 2. 防止并发:如果当前已在加载/重试过程中,则不再启动新的加载。
  if (state.isLoading) {
    // console.log(`[v-img-retry] Load attempt skipped: Already loading ${state.originalSrc}`);
    return;
  }

  // 3. 开始新的尝试
  state.isLoading = true; // 设置加载中状态
  state.currentTry++; // 增加尝试次数

  // console.log(`[v-img-retry] Attempt ${state.currentTry}/${state.retryLimit} for ${state.originalSrc}`);
  // (可选) 添加视觉反馈,例如给元素添加一个 'img-loading' 类
  // el.classList.add('img-loading');

  // 4. 创建临时的 Image 对象进行加载测试
  const img = new Image();

  // 5. 定义加载成功的回调 (img.onload)
  img.onload = () => {
    // console.log(`[v-img-retry] Success on try ${state.currentTry} for ${state.originalSrc}`);
    // 检查当前元素显示的 src 是否已是目标 src,避免不必要的 DOM 操作
    if (el.src !== state.originalSrc) {
      el.src = state.originalSrc; // 更新真实 img 元素的 src
    }
    // 更新状态:加载结束,非 fallback 状态
    state.isLoading = false;
    state.isFallbackActive = false;
    // (可选) 移除 loading 样式
    // el.classList.remove('img-loading');
    // 清理可能存在的重试定时器
    clearTimeout(state.currentTimerId);
    // 成功加载后,不再需要监听此 URL 的错误,移除监听器。
    // 注意:如果 src 之后动态变化,updated 钩子会重新添加监听器。
    el.removeEventListener("error", handleError);
  };

  // 6. 定义加载失败的回调 (img.onerror)
  img.onerror = () => {
    console.warn(
      `[v-img-retry] Failed attempt ${state.currentTry}/${state.retryLimit} for ${state.originalSrc}.`,
    );
    state.isLoading = false; // 本次尝试结束
    // (可选) 移除 loading 样式
    // el.classList.remove('img-loading');

    // 判断是否还有重试机会
    if (state.currentTry < state.retryLimit) {
      // 计算下一次重试的延迟时间
      // 使用指数退避策略:delay * 2^(try-1)
      // prettier-ignore
      const delayMs = Math.min(
                state.retryDelay * (2 ** (state.currentTry - 1)), // 确保指数计算正确
                MAX_RETRY_DELAY_MS // 应用全局最大延迟限制
            );
      console.log(
        `[v-img-retry] Scheduling retry #${state.currentTry + 1} after ${delayMs}ms`,
      );
      // 清除上一次可能设置的定时器(虽然理论上不应存在)
      clearTimeout(state.currentTimerId);
      // 使用 setTimeout 安排下一次调用 attemptLoad
      state.currentTimerId = setTimeout(() => attemptLoad(el, state), delayMs);
    } else {
      // 重试次数已用尽
      console.error(
        `[v-img-retry] Max retries (${state.retryLimit}) reached for ${state.originalSrc}. Falling back to ${state.fallbackSrc}`,
      );
      // 检查当前 src 是否已经是 fallback,避免重复设置
      if (el.src !== state.fallbackSrc) {
        el.src = state.fallbackSrc; // 设置 fallback 图片
        state.isFallbackActive = true; // 标记为 fallback 状态
      }
      clearTimeout(state.currentTimerId); // 清理定时器
      // 达到 fallback 状态后,移除监听器,不再尝试加载 originalSrc。
      // 但保留监听器可能有助于响应 fallback 图片自身的加载失败 (见 handleError)。
      // 根据之前的讨论,这里移除也是一个合理的选择,依赖 updated 处理后续 src 变化。
      el.removeEventListener("error", handleError);
      // 如果希望处理 fallback 图片加载失败,可以在这里重新添加,或者修改 handleError 逻辑
    }
  };

  // 7. 将目标 URL 赋给临时 Image 对象的 src,正式开始加载尝试
  img.src = state.originalSrc;
}

/**
 * img 元素 'error' 事件的统一处理器。
 * 负责判断错误发生的时机,并决定是否启动重试流程或处理 fallback 失败。
 * @param {Event} event - 错误事件对象。
 */
function handleError(event) {
  const el = event.target; // 获取事件源 img 元素
  const state = elementStates.get(el); // 获取关联的状态对象

  // 检查状态是否存在,以及当前是否是“空闲”状态(非加载中、非 fallback)
  if (state && !state.isLoading && !state.isFallbackActive) {
    // --- 关键改进:只有在非重试进行中才启动 ---
    // 检查当前尝试次数是否为 0,表示这是初始加载失败,或 src 更新后的首次失败
    if (state.currentTry === 0) {
      // console.log('v-img-retry: Initial error detected or src changed and failed. Starting retry process.');
      // 调用 attemptLoad 启动加载/重试流程。
      // attemptLoad 内部会将 currentTry 递增。
      attemptLoad(el, state);
    } else {
      // 如果 currentTry > 0,说明可能是在重试的 setTimeout 等待期间
      // 发生了意外的 error 事件(例如浏览器行为)。
      // 为避免重置计数器,选择忽略这个冗余的错误事件。
      console.warn(
        `v-img-retry: Redundant error event ignored while retry attempt ${state.currentTry} might be pending or just failed.`,
      );
    }
  } else if (state && state.isFallbackActive) {
    // 如果当前已经是 fallback 状态,说明是 fallback 图片自身加载失败
    console.error(
      `v-img-retry: Fallback image itself failed to load: ${state.fallbackSrc}`,
    );
    // 在这里可以执行最终的降级措施,比如隐藏图片或显示特定样式
    // el.style.border = '1px dashed red'; // Example
    // el.alt = 'Image failed to load, including fallback.'; // 更新 alt 文本

    // --- 移除监听器 ---
    // 既然 fallback 都失败了,对于当前状态不再需要监听错误。
    // 如果 src 之后更新,updated 钩子会重新添加。
    el.removeEventListener("error", handleError);
  }
  // 如果 state.isLoading is true, 说明错误发生在 attemptLoad 内部的 img.onerror,
  // 它会自行处理重试或 fallback 逻辑,无需在此处干预。
}

// --- Vue 自定义指令的定义 (`v-img-retry`) ---
const vImgRetryFromSrc = {
  /**
   * mounted: 元素首次挂载到 DOM 时调用。
   * - 初始化状态。
   * - 添加核心的 error 事件监听器。
   * - 处理初始 src 为空或无效的情况。
   */
  mounted(el, binding) {
    // console.log('v-img-retry: mounted', el.src);
    const state = initializeState(el, binding); // 初始化并存储状态
    el.addEventListener("error", handleError); // 添加错误监听

    // 处理初始 src 无效或为空
    if (!state.originalSrc) {
      // console.log('v-img-retry: Initial src is empty. Setting fallback.');
      el.src = state.fallbackSrc;
      state.isFallbackActive = true;
      // 既然已经设置了 fallback,移除对这个初始无效 src 的错误监听。
      // 后续如果 src 更新,updated 钩子会重新添加监听器。
      el.removeEventListener("error", handleError);
    }
    // 处理特殊情况:图片(可能来自缓存)已加载完成但无效 (e.g., 0x0 尺寸)
    else if (el.complete && el.naturalHeight === 0) {
      console.warn(
        "v-img-retry: Initial image completed but seems invalid (naturalHeight=0). Triggering error handler.",
      );
      // 手动触发错误处理流程,启动重试
      handleError({ target: el });
    }
    // 对于有效的初始 src,等待浏览器自然加载或失败触发 error 事件
  },

  /**
   * updated: 元素 VNode 更新时调用 (通常因为绑定的 :src 变化)。
   * - 检测 src 属性是否真的改变。
   * - 如果改变,重置内部状态,清理旧定时器。
   * - 重新设置 error 监听器。
   * - 启动新 src 的加载流程 (调用 attemptLoad)。
   */
  updated(el, binding) {
    const state = elementStates.get(el);
    // 必须用 getAttribute 获取模板绑定的最新值,el.src 可能已被设为 fallback
    const newSrc = el.getAttribute("src");

    // 如果状态不存在或 src 未变化,则不做任何事
    if (!state || newSrc === state.originalSrc) {
      return;
    }

    // console.log(`v-img-retry: src attribute updated from ${state.originalSrc} to ${newSrc}`);

    // --- 重置流程,准备加载新图片 ---
    clearTimeout(state.currentTimerId); // 清理旧的等待重试的定时器

    state.originalSrc = newSrc; // 更新目标 URL
    state.currentTry = 0; // 重置尝试次数是必须的!
    state.isLoading = false; // 重置加载状态
    state.isFallbackActive = false; // 重置 fallback 状态
    // (可选) 重新读取配置,如果 data-* 属性或 arg 可能动态改变
    state.fallbackSrc = binding.arg || DEFAULT_FALLBACK_SRC;
    const retryLimitRaw = parseInt(el.dataset.retryLimit, 10);
    state.retryLimit = isNaN(retryLimitRaw)
      ? DEFAULT_RETRY_LIMIT
      : retryLimitRaw;
    const retryDelayRaw = parseInt(el.dataset.retryDelay, 10);
    state.retryDelay = isNaN(retryDelayRaw)
      ? DEFAULT_RETRY_DELAY
      : retryDelayRaw;

    // 确保 error 监听器存在且唯一
    el.removeEventListener("error", handleError);
    el.addEventListener("error", handleError);

    // 根据新 src 的有效性开始加载或设置 fallback
    if (!state.originalSrc) {
      // console.log('v-img-retry: Updated src is empty. Setting fallback.');
      el.src = state.fallbackSrc;
      state.isFallbackActive = true;
      // 新 src 为空,后续不可能加载成功或失败,可以移除监听器
      el.removeEventListener("error", handleError);
    } else {
      // console.log('v-img-retry: Starting load attempt for updated src.');
      // (可选) 可以在这里设置一个统一的 loading 外观
      // el.src = '/path/to/loading-spinner.gif'; // 或者添加 class
      attemptLoad(el, state); // 使用新 src 启动加载/重试流程
    }
  },

  /**
   * beforeUnmount: 元素从 DOM 卸载前调用。
   * - 清理所有资源:移除事件监听器,清除定时器,删除状态。
   */
  beforeUnmount(el) {
    // console.log('v-img-retry: beforeUnmount', el.src);
    const state = elementStates.get(el);
    if (state) {
      clearTimeout(state.currentTimerId); // 清除任何待处理的重试定时器
    }
    el.removeEventListener("error", handleError); // 移除错误事件监听器
    elementStates.delete(el); // 从 WeakMap 中删除状态,允许垃圾回收
  },
};

// 导出指令定义
export default vImgRetryFromSrc;

四、 工作流程串联

  1. 挂载 (mounted):
    • 指令应用于 <img> 元素,状态被初始化(记录 originalSrc, 配置等)。
    • error 监听器被添加。
    • 如果 src 初始无效,直接显示 fallback。如果有效,浏览器开始加载。
  2. 加载失败 (error 事件 -> handleError):
    • 浏览器加载 src 失败,触发 error 事件。
    • handleError 被调用。
    • handleError 检查状态,发现不是正在重试且不是 fallback,于是调用 attemptLoad (将 currentTry 重置为 0)。
  3. 第一次尝试 (attemptLoad):
    • isLoading 设为 truecurrentTry 变为 1。
    • 创建临时 Image 对象,设置 onloadonerror 回调。
    • img.src = originalSrc 开始加载。
  4. 第一次尝试失败 (img.onerror):
    • isLoading 设为 false
    • 检查 currentTry < retryLimit
    • 计算延迟(例如 1000ms),使用 setTimeout 安排 attemptLoad 在 1000ms 后再次执行。
  5. 第二次尝试 (attemptLoad - 由 setTimeout 调用):
    • isLoading 设为 truecurrentTry 变为 2。
    • 创建新的临时 Image 对象...(重复过程)
  6. 第二次尝试失败 (img.onerror):
    • isLoading 设为 false
    • 检查 currentTry < retryLimit
    • 计算延迟(例如 2000ms),setTimeout 安排 attemptLoad 在 2000ms 后执行。
  7. ... 重试直到成功或耗尽次数 ...
  8. 某次尝试成功 (img.onload):
    • el.src = originalSrc
    • isLoading = false, isFallbackActive = false
    • 清除定时器,移除 error 监听。流程结束。
  9. 重试耗尽 (img.onerrorcurrentTry >= retryLimit):
    • el.src = fallbackSrc
    • isFallbackActive = true
    • 清除定时器,移除 error 监听。流程结束(降级)。
  10. src 属性更新 (updated):
    • 如果 :src 绑定的值变化,updated 钩子触发。
    • 检测到 newSrcstate.originalSrc 不同。
    • 清理旧状态(定时器),重置 currentTry 等,更新 originalSrc
    • 重新添加 error 监听。
    • 调用 attemptLoad 开始加载新的 newSrc
  11. 元素卸载 (beforeUnmount):
    • 清理所有资源:清除定时器、移除监听器、删除 WeakMap 中的状态。

五、 优势与可扩展性

  • 非侵入式: 对现有 <img> 标签用法改动最小。
  • 健壮性: 处理了多种加载失败情况和 src 动态变化。
  • 资源友好: 使用 WeakMap 管理状态,临时 Image 对象用完即弃。
  • 可扩展:
    • 可以在 attemptLoad 中添加更复杂的延迟策略(如 Jitter)。
    • 可以添加对不同错误类型(如 404 vs 503)的不同处理逻辑(需要能从 error 事件中获取状态码,这通常比较困难,可能需要配合 Service Worker 或服务器端支持)。
    • 可以添加更丰富的 Loading 状态显示(不仅仅是 opacity)。
    • 可以提供更多配置项(通过 data-* 或对象值)。

这个详细的设计思路和实现过程展示了如何构建一个功能完善且健壮的 Vue 自定义指令来解决实际开发中常见的图片加载问题。

六、 如何在项目中注册和使用 v-img-retry 指令

  1. main.js (或 main.ts) 中全局注册指令:

    这是最常见的方式,让指令在整个应用中都可用。

    import { createApp } from 'vue'
    import App from './App.vue' // 你的根组件
    // 1. 导入你编写的指令实现文件
    import vImgRetryFromSrc from './directives/vImgRetryFromSrc' // 确保路径正确
    
    const app = createApp(App)
    
    // 2. 全局注册指令
    // 第一个参数是指令的名字 (在模板中使用时是 v-后面跟这个名字)
    // 第二个参数是导入的指令定义对象
    app.directive('img-retry', vImgRetryFromSrc)
    
    app.mount('#app')
    
    • 说明: app.directive('img-retry', ...) 这行代码将我们的指令实现注册为名为 img-retry 的全局指令。在模板中使用时,你需要写成 v-img-retry
  2. 在单个组件中局部注册指令 (如果只想在特定组件使用):

    虽然不太常见,但你也可以只在需要它的组件中注册。

    <script setup>
    import { defineComponent } from 'vue';
    import vImgRetryFromSrc from './directives/vImgRetryFromSrc'; // 导入指令
    
    // 在 setup 中定义 directives 对象
    const directives = {
      'img-retry': vImgRetryFromSrc
    };
    
    // 如果不使用 setup script,则在组件选项中定义
    // export default defineComponent({
    //   directives: {
    //     'img-retry': vImgRetryFromSrc
    //   }
    //   // ... other component options
    // })
    </script>
    
    <template>
      <img :src="imageUrl" v-img-retry alt="局部注册的指令">
    </template>
    
  3. 如何在组件模板中使用指令:

    注册完成后,使用指令非常简单,就像应用其他 Vue 指令一样。

    <template>
      <div>
        <h3>图片加载重试示例</h3>
    
        <!-- 基本用法:使用默认配置 -->
        <div>
          <p>默认配置 (重试3次, 初始延迟1s, 指数退避, 默认fallback):</p>
          <img :src="flakyImageUrl1" v-img-retry alt="默认重试图片" width="200" height="150">
        </div>
    
        <!-- 自定义配置:通过 data-* 属性和指令参数 -->
        <div>
          <p>自定义配置 (重试5次, 初始延迟500ms, 自定义fallback):</p>
          <img
            :src="flakyImageUrl2"
            v-img-retry:[customFallbackUrl] <!-- 指令参数 :[变量] 用于传递 fallback URL -->
            data-retry-limit="5"         
            data-retry-delay="500"
            alt="自定义重试图片"
            width="200"
            height="150"
          >
        </div>
    
        <!-- 初始 src 为空 -->
        <div>
           <p>初始 src 为空:</p>
           <img :src="emptyImageUrl" v-img-retry:[customFallbackUrl] alt="空 src" width="100" height="100">
        </div>
    
         <!-- 初始 src 无效 (例如 404) -->
        <div>
           <p>初始 src 无效:</p>
           <img src="/path/to/non-existent-image.jpg" v-img-retry alt="无效 src" width="100" height="100">
        </div>
    
        <!-- 动态改变 src -->
        <div>
          <p>动态改变 src:</p>
          <img :src="dynamicImageUrl" v-img-retry alt="动态图片" width="150" height="120">
          <button @click="changeDynamicImage" style="display: block; margin-top: 5px;">改变动态图片 URL</button>
        </div>
    
      </div>
    </template>
    
    <script setup>
    import { ref } from 'vue';
    
    const flakyImageUrl1 = ref('/api/flaky-or-503-image/1'); // 模拟可能失败的 URL
    const flakyImageUrl2 = ref('/another/flaky/image.png');
    const customFallbackUrl = ref('/images/my-custom-placeholder.svg'); // 你自己的 fallback 图片路径
    const emptyImageUrl = ref('');
    const dynamicImageUrl = ref('/api/images/initial.jpg');
    
    function changeDynamicImage() {
      // 模拟 URL 变化,可能是成功或失败的 URL
      dynamicImageUrl.value = Math.random() > 0.3
        ? `/api/images/new_${Date.now()}.jpg` // 假设这个会成功
        : '/definitely-fails.png';             // 假设这个会失败并触发重试
    }
    
    // 模拟一些 URL 在一段时间后才可用 (模拟 503 恢复)
    // 这需要在服务器端配合,前端指令无法直接模拟,
    // 但指令的重试机制就是为了应对这种情况。
    // 例如,'/api/flaky-or-503-image/1' 可能前两次访问返回 503,第三次返回 200。
    </script>
    
    <style scoped>
    img {
      display: block;
      margin-bottom: 10px;
      border: 1px solid #ccc;
      min-height: 50px; /* 提供最小高度避免布局跳动 */
      background-color: #f0f0f0; /* 加载时的背景色 */
      vertical-align: middle; /* 避免图片下方小间隙 */
    }
    p { margin-bottom: 2px; }
    div { margin-bottom: 15px; }
    </style>
    

使用说明要点:

  • 指令名称: 使用 v-img-retry 应用到 <img> 标签上。
  • 图片 URL: 像平常一样使用 :src="imageUrl" 来绑定你的图片 URL。指令会自动读取这个值。
  • Fallback 图片 URL (可选): 通过指令参数 (arg) 提供。使用方括号 [] 可以绑定一个动态的变量:v-img-retry:[yourFallbackVariable]。如果省略参数,会使用指令内部定义的 DEFAULT_FALLBACK_SRC
  • 自定义重试次数 (可选): 通过 data-retry-limit="数字" 属性设置。
  • 自定义初始重试延迟 (可选): 通过 data-retry-delay="毫秒数" 属性设置。注意这里的延迟是指数退避的初始值
  • 动态 src::src 绑定的值发生变化时,指令的 updated 钩子会自动处理,重置重试状态并尝试加载新图片。

七、 关键知识点

  1. el.complete:

    • 这是一个 HTML <img> 元素的只读布尔属性
    • 它指示浏览器是否已经完成(或尝试完成)加载图片,无论加载是成功还是失败。
    • 如果图片正在加载中,completefalse
    • 如果图片加载成功,或者加载失败(例如 404、网络错误),或者 src 属性为空,complete 通常会是 true
    • 关键点: completetrue 不代表图片加载成功并能正确显示。它只表示浏览器的加载过程结束了。
  2. el.naturalHeight:

    • 这也是一个 HTML <img> 元素的只读属性
    • 它返回图片的原始(固有)高度(以像素为单位)。
    • 只有当图片成功加载并被浏览器正确解码后,这个属性才会有大于 0 的值。
    • 如果图片加载失败、损坏、src 无效、或者格式不被支持,naturalHeight 通常会是 0

组合条件的含义 el.complete && el.naturalHeight === 0:

这个组合条件检查的是:

“浏览器已经结束了加载这个图片的过程 (complete is true),但是图片的原始高度是 0 (naturalHeight is 0)。”

这种情况强烈暗示了以下几种可能性:

  • 图片文件已损坏: 文件本身存在问题,浏览器无法解析其尺寸。
  • URL 指向的不是有效的图片资源: 例如,URL 可能返回了一个 HTML 错误页面 (如 404 页面) 或者一个非图片类型的文件,浏览器尝试加载但无法识别为图像。
  • 网络传输错误导致文件不完整: 下载的图片数据不全。
  • 浏览器或解码器问题 (罕见): 浏览器无法正确解码一个有效的图片格式。

为什么这个判断有效?

  • 覆盖 error 事件未触发的情况: 有些情况下,即使图片加载失败(例如返回的是一个 200 OK 但内容是 HTML 错误页),浏览器可能不会触发 error 事件。但此时 naturalHeight 会是 0。这个检查可以捕获这类情况。
  • 处理缓存中的无效图片: 如果浏览器从缓存中加载了一个之前失败或损坏的图片,complete 可能会很快变成 true,但 naturalHeight 仍然是 0。

局限性:

  • 极小的有效图片: 如果你真的有一个原始高度为 0 像素的有效图片(虽然非常罕见且几乎无用),这个判断会误判。
  • SVG 图片: 对于 SVG 图片,naturalHeight 的行为可能不同,或者 SVG 本身尺寸定义问题。如果你的应用大量使用需要重试的 SVG,可能需要额外的检查或不同的逻辑。
  • 依赖浏览器行为: 这个技巧依赖于浏览器在图片加载失败或无效时将 naturalHeight 设为 0 的行为,这在主流现代浏览器中是相当一致的。

结论:

虽然 el.complete && el.naturalHeight === 0 主要依赖 naturalHeight 来判断,但这在实践中是检测图片加载完成但无效(损坏、非图片资源等)的一个相当可靠且常用的方法。它补充了 error 事件监听器,捕获了一些 error 事件可能遗漏的失败场景,从而提高了指令的健壮性。当然,它并非绝对完美,但在绝大多数情况下是有效的。你也可以结合检查 el.naturalWidth === 0 来增加判断的严格性。