Vue 自定义指令 v-img-retry-from-src:实现图片智能加载重试与回退(进阶版)

634 阅读15分钟

介绍:

在现代 Web 应用中,图片是传递信息和提升用户体验的关键元素。然而,由于网络波动、服务器瞬时故障或图片资源本身的问题,图片加载失败的情况时有发生,这往往会导致页面出现破坏性的“破图”图标,影响美观和用户感知。

为了优雅地处理此类问题,本文将详细剖析 v-img-retry-from-src 的 Vue.js 自定义指令。该指令旨在为 <img> 标签提供一套健壮的图片加载容错机制。它不仅能在图片初次加载失败时自动进行多次重试(采用指数退避策略以避免对服务器造成过大压力),还能在所有重试均告失败后,平滑地切换到用户指定的或默认的占位符(fallback)图片。

通过引入此指令,开发者可以轻松提升应用的鲁棒性,减少因图片加载问题带来的负面用户体验,确保即使在不稳定网络环境下,应用界面也能保持其完整性和专业性。接下来,我们将深入探讨该指令的实现原理、核心功能、设计思路以及具体使用方法。


下面我们来详细解析思路和设计要点:

图文解释

我们可以把这个指令想象成一个智能的图片加载助手:

  1. 接收任务: 你告诉助手 (<img> 标签 + v-img-retry-from-src="imageUrl") 要显示哪张图片。

    +-------------------+
    | User wants to see | ---imageUrl---> [Image Helper (Directive)]
    |  imageUrl.jpg     |
    +-------------------+
    
  2. 初步尝试: 助手先尝试直接让浏览器加载这张图片。

    • 成功: 图片显示,任务完成。

      [Image Helper] ---tries---> Browser ---loads---> 😊 (Image Displayed)
      
    • 失败: 浏览器报告错误。

      [Image Helper] ---tries---> Browser ---fails---> 😟 (Error)
      
  3. 秘密重试 (如果初步失败) :

    • 助手不会立刻放弃。它会悄悄地(在后台创建一个临时的 Image 对象)再次尝试加载。
    • 为了不打扰服务器,它会等一小会儿再试,如果又失败了,下次等待的时间会更长一点(指数退避)。
    • 这个过程会重复几次(预设的重试次数)。
    [Image Helper]
        |
        +--- (Attempt 1 failed) --- Wait 1s, try again with temp Image --- fails --> 😟
        |
        +--- (Attempt 2 failed) --- Wait 2s, try again with temp Image --- fails --> 😟
        |
        +--- (Attempt 3 failed) --- Wait 4s, try again with temp Image --- success --> 😊 (Update main image)
    
  4. 最终手段 (如果所有重试都失败) :

    • 如果所有秘密尝试都失败了,助手会放弃加载原图,并显示一张预设的“兜底”图片(占位符)。
    [Image Helper]
        |
        +--- (All retries failed) ---> Display FallbackImage.jpg  ---> 🖼️ (Fallback Displayed)
    
  5. 图片源更新: 如果你改变了要显示的图片 (imageUrl 变化了),助手会重新开始整个流程,并取消之前图片的加载尝试。

  6. 助手离场: 如果图片元素从页面上移除了,助手会清理掉所有进行中的尝试和记录,确保不留后患(内存泄漏)。

思路解析

代码核心逻辑:

  1. 指令钩子驱动: Vue 自定义指令的生命周期钩子(mounted, updated, beforeUnmount)是整个逻辑的入口和驱动力。

    • mounted: 元素首次插入 DOM。初始化状态,尝试加载初始 src
    • updated: 元素或其绑定值更新。检查 src 是否变化,如果变化则重置状态并为新 src 开始加载流程。
    • beforeUnmount: 元素即将销毁。进行清理工作,如清除定时器、移除事件监听器、删除状态。
  2. 状态管理: 每个应用该指令的 <img> 元素都需要独立的状态来跟踪其加载过程。

    • 使用 WeakMap (elementStates) 存储每个元素的状态。WeakMap 的键是 DOM 元素,当元素被垃圾回收时,对应的状态也会被自动清除,有效防止内存泄漏。
    • RetryState 对象详细定义了所需状态:原始图片 URL、当前尝试次数、重试上限、延迟时间、后备图片 URL、加载状态、定时器ID等。
  3. URL 处理:

    • 绝对路径转换: 原始 src 和后备 fallbackSrc 都会被尝试转换为绝对 URL (使用 new URL(path, window.location.href).href)。这确保了无论指令在何处使用,图片路径都能被正确解析,也便于后续比较和直接赋值给 Image对象的 src
    • Vite import.meta.glob: 用于在构建时或开发时动态地获取 placeholder-error.jpg 的 URL。这是一种现代 JavaScript 模块系统中处理静态资源的方式,Vite 会将其转换为实际的 URL 字符串。
  4. 核心加载与重试逻辑 (attemptLoad 函数) :

    • 后台加载: 创建一个临时的 Image 对象 (img = new Image()) 在后台尝试加载图片。这样做的好处是不会直接在页面上显示破碎的图片图标,直到图片确认加载成功。

    • onload 处理:

      • 如果临时 Image 对象加载成功,首先检查图片的 naturalWidthnaturalHeight。如果为0,视为“软错误”(图片本身可能损坏或无效),并触发重试逻辑。
      • 如果尺寸有效,则认为图片加载成功。更新实际 <img> 元素的 src 为原始 src,使其可见,并清除相关的错误处理和定时器。
      • tempSuccessAchievedForOriginalSrc 标记临时图片加载成功,用于 handleError 中的特定场景。
    • onerror 处理:

      • 如果临时 Image 对象加载失败,检查是否已达到重试次数上限。
      • 未达上限: 使用 setTimeout 和指数退避策略(retryDelay * 2 ** currentTry,并设有最大延迟 MAX_RETRY_DELAY_MS)安排下一次重试。存储 currentTimerId 以便可以取消。
      • 已达上限: 加载失败,将实际 <img> 元素的 src 设置为后备图片 fallbackSrc,并使其可见。
    • 异步安全 (loadAttemptId) : 每次新的加载尝试(无论是初始化还是 src 更新)都会生成一个新的 loadAttemptId。在 onloadonerror 回调中,会检查当前状态的 loadAttemptId 是否与触发回调时的 loadAttemptId 一致。如果不一致,说明 src 已经改变,这是一个过时的回调,应被忽略,防止旧的加载结果影响新的加载尝试。

  5. 主图错误处理 (handleError 函数) :

    • 此函数作为实际 <img> 元素的 error 事件监听器。
    • 主要捕获初始加载时主 <img> 元素直接发生的错误。
    • 如果后备图片本身加载失败,则记录错误并停止。
    • 一个特殊情况:如果 tempImg 曾成功加载 (tempSuccessAchievedForOriginalSrc 为 true),然后 el.src 被设置为 originalSrc,但 el 自身加载此 originalSrc 时还是失败了,则直接强制使用后备图片。
    • 如果是初次错误且未开始 attemptLoad 流程,则启动 attemptLoad
  6. 配置灵活性:

    • 通过全局常量 (DEFAULT_RETRY_LIMIT, DEFAULT_RETRY_DELAY, DEFAULT_FALLBACK_SRC) 提供默认配置。
    • 允许通过 data-* 属性 (如 data-retry-limit, data-retry-delay) 在 HTML 元素上覆盖默认的重试次数和延迟。
    • 后备图片可以通过指令的参数传递 (如 v-img-retry-from-src:fallbackImageUrl.png="imageUrl",这里 fallbackImageUrl.png 会被用作后备图)。如果未提供参数,则使用 DEFAULT_FALLBACK_SRC

设计要点

  1. 用户体验 (UX) :

    • 隐藏再显示: 图片在成功加载或切换到后备图之前,其 visibility 设置为 hidden,避免显示破碎的图片图标或因加载失败导致的布局跳动。
    • 自动重试: 对用户透明地处理临时的网络问题,提升图片加载成功率。
    • 优雅降级: 多次失败后提供占位符,比显示错误图标更好。
  2. 健壮性与错误处理:

    • 多重检查: 不仅检查 onerror,还检查 onload 后的 naturalWidth/Height (软错误)。
    • 绝对路径: 统一处理 URL 为绝对路径,减少因相对路径在不同上下文解析不一致导致的问题。
    • 异步安全: loadAttemptId 机制确保了在 src 快速变化时,只有最新的加载尝试的异步回调会生效。
    • 后备图加载失败处理: 考虑到了后备图片本身也可能加载失败的情况。
  3. 性能与资源管理:

    • WeakMap: 自动管理元素状态与元素生命周期,防止内存泄漏。
    • 指数退避: 避免在短时间内对服务器发起过多请求。
    • 清理机制: beforeUnmount 钩子确保清除定时器和事件监听器,释放资源。
    • 条件执行: 在 updated 钩子中,只有当 src 真正改变时才执行完整的更新逻辑。
  4. 模块化与可配置性:

    • 清晰的函数划分: initializeState, attemptLoad, handleError 各司其职。
    • 配置选项: 提供了全局默认值,并允许通过 data-* 属性和指令参数进行个性化配置。
    • Vite 集成: import.meta.glob 的使用是 Vite 项目中处理静态资源的推荐方式之一。
  5. 代码清晰度:

    • JSDoc 类型定义 (@typedef {object} RetryState) 增强了代码的可读性和可维护性。
    • 注释比较充分,有助于理解各部分逻辑。

完整代码

// directives/vImgRetryFromSrc.js
let placeholderErrorUrl = "/default-placeholder.jpg"; // 一个绝对的或根相对的默认路径以防glob失败

// 使用 import.meta.glob 获取图片模块
// 注意:import.meta.glob 的路径是相对于当前文件的
// 如果 placeholder-error.jpg 在 ./images/ 目录下
const imageModules = import.meta.glob("./images/placeholder-error.jpg", {
  eager: true,
  query: "?url",
});

// 假设只有一个匹配项
for (const path in imageModules) {
  placeholderErrorUrl = imageModules[path].default || imageModules[path];
  break;
}
// console.log("placeholderErrorUrl from glob:", placeholderErrorUrl); // Vite会将其替换为URL字符串

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

/** 默认的最大重试次数 */
const DEFAULT_RETRY_LIMIT = 3;
/** 默认的初始重试延迟时间(毫秒),用于指数退避策略 */
const DEFAULT_RETRY_DELAY = 1000;
/** 默认的最终失败占位符图片 URL (请务必替换为你的实际路径!) */
const DEFAULT_FALLBACK_SRC = placeholderErrorUrl; // 示例路径,请替换
/** 全局设置的最大重试延迟时间(毫秒),防止指数退避无限增长 */
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 图片。
 * @property {boolean} tempSuccessAchievedForOriginalSrc - 标记是否已成功加载过 originalSrc。
 * @property {number} loadAttemptId - 当前加载尝试的唯一标识符。
 */

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

/**
 * 初始化或重置指定 img 元素的重试状态。
 * 从指令绑定、元素 data-* 属性和全局常量中获取配置。
 * @param {HTMLImageElement} el - 目标 img 元素。
 * @param {object} binding - Vue 指令的绑定对象,用于获取 arg (fallbackSrc)。
 * @returns {RetryState} - 初始化或重置后的状态对象。
 */
function initializeState(el, binding) {
  let resolvedOriginalSrc = null;
  const attributeSrc = el.getAttribute("src");

  if (attributeSrc) {
    try {
      // 尝试将模板中提供的 src (可能为相对路径) 解析为相对于当前页面位置的绝对URL。
      // 例如,如果 attributeSrc 是 "/images/pic.jpg" 且页面在 "https://example.com/app/",
      // resolvedOriginalSrc 会变成 "https://example.com/images/pic.jpg"。
      // 如果 attributeSrc 已经是绝对URL,new URL() 也能正确处理它。
      resolvedOriginalSrc = new URL(attributeSrc, window.location.href).href;
    } catch {
      // 如果 new URL() 解析失败 (例如,attributeSrc 是一个无效的URL片段或格式不正确),
      // 则回退到使用 attributeSrc 的原始值。这在 attributeSrc 可能是
      // 一个已经损坏的绝对URL或者特殊协议URL(非HTTP/HTTPS)时可能发生。
      resolvedOriginalSrc = attributeSrc;
    }
  } else if (el.src && el.src !== window.location.href) {
    // 如果img标签没有src属性,但el.src已经由浏览器设置了 (且不是指向当前页面自身,这通常表示一个有效的图片源),
    // 则使用el.src。el.src通常已经是绝对URL。
    resolvedOriginalSrc = el.src;
  }

  let resolvedFallbackSrc = DEFAULT_FALLBACK_SRC;
  const fallbackSrcAttr = binding.arg || DEFAULT_FALLBACK_SRC;
  if (fallbackSrcAttr) {
    try {
      // 对 fallbackSrc 进行与 originalSrc 类似的绝对路径转换处理。
      resolvedFallbackSrc = new URL(fallbackSrcAttr, window.location.href).href;
    } catch {
      // 如果解析失败,回退到原始的 fallbackSrcAttr 值。
      resolvedFallbackSrc = fallbackSrcAttr;
    }
  }

  const retryLimitRaw = parseInt(el.dataset.retryLimit, 10);
  const retryLimit = isNaN(retryLimitRaw) ? DEFAULT_RETRY_LIMIT : retryLimitRaw;
  const retryDelayRaw = parseInt(el.dataset.retryDelay, 10);
  const retryDelay = isNaN(retryDelayRaw) ? DEFAULT_RETRY_DELAY : retryDelayRaw;

  const state = {
    originalSrc: resolvedOriginalSrc,
    currentTry: 0,
    retryLimit: retryLimit,
    retryDelay: retryDelay,
    fallbackSrc: resolvedFallbackSrc,
    isLoading: false,
    currentTimerId: null,
    isFallbackActive: false,
    tempSuccessAchievedForOriginalSrc: false,
    loadAttemptId: Date.now(),
  };
  elementStates.set(el, state);
  // console.log(`[v-img-retry] Initialized state: originalSrc=${state.originalSrc}, fallbackSrc=${state.fallbackSrc}`);
  return state;
}

/**
 * 核心函数:尝试加载图片,包含完整的重试和 fallback 逻辑。
 * 使用临时的 Image 对象进行加载尝试。
 * **此函数负责在最终成功或失败时将元素设为可见。**
 * @param {HTMLImageElement} el - 目标 img 元素。
 * @param {RetryState} state - 与该元素关联的状态对象。
 */
function attemptLoad(el, state) {
  if (!state.originalSrc || state.isFallbackActive) {
    if (state.isFallbackActive && el.style.visibility === "hidden") {
      el.style.visibility = "visible";
    }
    state.isLoading = false;
    return;
  }

  if (state.isLoading) {
    return;
  }

  state.isLoading = true;
  state.currentTry++;
  const currentLoadAttemptId = state.loadAttemptId;

  const img = new Image();

  img.onload = () => {
    // console.log(`[v-img-retry-DEBUG] tempImg.onload TRIGGERED for ${img.src}. Natural dimensions: ${img.naturalWidth}x${img.naturalHeight}`);
    const currentState = elementStates.get(el);
    if (!currentState || currentState.loadAttemptId !== currentLoadAttemptId) {
      return;
    }

    // Soft error check (natural dimensions)
    if (img.naturalWidth === 0 || img.naturalHeight === 0) {
      // console.warn(`[v-img-retry-DEBUG] tempImg.onload triggered, but natural dimensions are 0. Treating as a soft error for ${img.src}`);
      currentState.isLoading = false;
      if (currentState.currentTry < currentState.retryLimit) {
        const delayMs = Math.min(
          currentState.retryDelay * 2 ** currentState.currentTry,
          MAX_RETRY_DELAY_MS,
        );
        // console.log(`[v-img-retry] Scheduling retry (due to soft error) #${currentState.currentTry + 1} after ${delayMs}ms`);
        clearTimeout(currentState.currentTimerId);
        currentState.currentTimerId = setTimeout(() => {
          const latestState = elementStates.get(el);
          if (
            latestState &&
            latestState.loadAttemptId === currentLoadAttemptId
          ) {
            attemptLoad(el, latestState);
          }
        }, delayMs);
      } else {
        // console.error(`[v-img-retry] Max retries (soft error) for ${currentState.originalSrc}. Falling back.`);
        if (el.src !== currentState.fallbackSrc) {
          el.src = currentState.fallbackSrc;
        }
        currentState.isFallbackActive = true;
        el.style.visibility = "visible";
        clearTimeout(currentState.currentTimerId);
        el.removeEventListener("error", handleError);
      }
      return;
    }

    // If natural dimensions are valid, proceed with original onload success logic
    currentState.isLoading = false;
    currentState.isFallbackActive = false;
    currentState.tempSuccessAchievedForOriginalSrc = true; // Mark that tempImg loaded fine (with good dimensions)

    // Now el.src (actual rendered src) and currentState.originalSrc (desired src) should both be absolute URLs
    if (el.src !== currentState.originalSrc) {
      // This might happen if originalSrc was updated, or if el.src was somehow
      // different from the initial attribute that resolved to originalSrc.
      // console.log(`[v-img-retry] tempImg success: el.src (${el.src}) differs from state.originalSrc (${currentState.originalSrc}). Updating el.src.`);
      el.src = currentState.originalSrc;
    } else {
      // console.log(`[v-img-retry] tempImg success: el.src already matches state.originalSrc (${currentState.originalSrc}). No change to el.src.`);
    }

    el.style.visibility = "visible";
    clearTimeout(currentState.currentTimerId);
    // Since tempImg loaded successfully AND el.src is now (or already was) this successful originalSrc,
    // we can remove the error handler for this specific originalSrc.
    el.removeEventListener("error", handleError);
  };

  img.onerror = () => {
    const currentState = elementStates.get(el);
    if (!currentState || currentState.loadAttemptId !== currentLoadAttemptId) {
      clearTimeout(state.currentTimerId);
      return;
    }
    // console.warn(
    //   `[v-img-retry] tempImg.onerror: Failed attempt ${currentState.currentTry}/${currentState.retryLimit} for ${currentState.originalSrc}.`
    // );
    currentState.isLoading = false;

    if (currentState.currentTry < currentState.retryLimit) {
      const delayMs = Math.min(
        currentState.retryDelay * 2 ** currentState.currentTry,
        MAX_RETRY_DELAY_MS,
      );
      // console.log(`[v-img-retry] Scheduling retry #${currentState.currentTry + 1} after ${delayMs}ms`);
      clearTimeout(currentState.currentTimerId);
      currentState.currentTimerId = setTimeout(() => {
        const latestState = elementStates.get(el);
        if (latestState && latestState.loadAttemptId === currentLoadAttemptId) {
          attemptLoad(el, latestState);
        }
      }, delayMs);
    } else {
      // console.error(
      //   `[v-img-retry] Max retries for ${currentState.originalSrc}. Falling back to ${currentState.fallbackSrc}`
      // );
      if (el.src !== currentState.fallbackSrc) {
        el.src = currentState.fallbackSrc;
      }
      currentState.isFallbackActive = true;
      el.style.visibility = "visible";
      clearTimeout(currentState.currentTimerId);
      el.removeEventListener("error", handleError);
    }
  };
  // console.log(`[v-img-retry] attemptLoad: Setting tempImg.src to ${state.originalSrc}`);
  img.src = state.originalSrc; // Should be an absolute URL now
}

/**
 * img 元素 'error' 事件的统一处理器。
 * 主要用于捕获 *初始* 加载失败 (发生在主 img 元素上),
 * 并启动重试流程 (调用 attemptLoad)。
 * @param {Event} event - 错误事件对象。
 */
function handleError(event) {
  const el = event.target;
  const state = elementStates.get(el);

  if (!state || !state.originalSrc) {
    // Added check for state.originalSrc if it can be null
    return;
  }

  // If el.src is already the fallback, and it fails, this is a fallback failure.
  if (state.isFallbackActive && el.src === state.fallbackSrc) {
    // console.error(`[v-img-retry] Fallback image itself failed to load: ${state.fallbackSrc}`);
    el.removeEventListener("error", handleError);
    return;
  }

  // If tempImg previously indicated a soft error (0-dimension) for this originalSrc and led to a fallback state,
  // and now el somehow tries to load originalSrc again and fails, it might re-trigger handleError.
  // The existing isFallbackActive check should mostly cover this, but ensure no loops.

  // If tempSuccessAchievedForOriginalSrc was used and is true, and el fails loading originalSrc:
  // This means tempImg succeeded, el was set to originalSrc, but el itself failed.
  // Fallback directly.
  if (
    state.tempSuccessAchievedForOriginalSrc &&
    !state.isLoading &&
    el.src === state.originalSrc
  ) {
    // console.warn(`[v-img-retry] handleError: Element 'el' failed to load '${state.originalSrc}' even after 'tempImg' succeeded. Forcing fallback.`);
    if (el.src !== state.fallbackSrc) {
      el.src = state.fallbackSrc;
    }
    state.isFallbackActive = true;
    el.style.visibility = "visible";
    el.removeEventListener("error", handleError);
    clearTimeout(state.currentTimerId);
    return;
  }

  // Standard initial error on el before attemptLoad or during an unhandled phase
  if (!state.isLoading && state.currentTry === 0 && !state.isFallbackActive) {
    // console.log('[v-img-retry] handleError: Initial error on el. Starting attemptLoad.');
    attemptLoad(el, state);
    return;
  }
  // console.log("[v-img-retry] handleError: Unhandled case or error during retry process for el.", {src: el.src, state});
}

// --- Vue 自定义指令的定义 (`v-img-retry`) ---
const vImgRetryFromSrc = {
  /**
   * mounted: 元素首次挂载到 DOM 时调用。
   */
  mounted(el, binding) {
    // --- 初始设置为隐藏并添加背景色占位 ---
    el.style.visibility = "hidden";
    // el.style.backgroundColor = PLACEHOLDER_BG_COLOR;

    const state = initializeState(el, binding); // 初始化状态

    // 处理初始 src 无效或为空
    if (!state.originalSrc) {
      // console.log('[v-img-retry] mounted: No originalSrc. Setting and showing fallback.');
      if (el.src !== state.fallbackSrc) {
        el.src = state.fallbackSrc;
      }
      state.isFallbackActive = true;
      // --- 直接显示 Fallback ---
      el.style.visibility = "visible";
      // 不需要错误监听
    } else {
      // --- 初始 src 有效,添加错误监听并启动加载流程 ---
      el.addEventListener("error", handleError);
      // 检查浏览器是否已经成功加载了 (现在已经是绝对路径的) originalSrc
      if (el.complete && el.naturalWidth > 0 && el.src === state.originalSrc) {
        // console.log('[v-img-retry] mounted: Image already loaded by browser:', state.originalSrc);
        el.style.visibility = "visible";
        state.isLoading = false;
        el.removeEventListener("error", handleError); // 已经成功,不需要这个处理程序用于这个src
      } else {
        // console.log('[v-img-retry] mounted: Image not yet loaded by browser or failed. Starting attemptLoad.');
        if (!state.isLoading) {
          // 确保不是由于某些边缘情况已经加载
          attemptLoad(el, state);
        }
      }
    }
  },

  /**
   * updated: 元素 VNode 更新时调用 (通常因为绑定的 :src 变化)。
   */
  updated(el, binding) {
    const oldState = elementStates.get(el);
    // 对于更新,我们还需要将新 src 从属性解析为绝对 URL 进行比较和存储
    let newResolvedOriginalSrc = null;
    const newAttributeSrc = el.getAttribute("src");
    if (newAttributeSrc) {
      try {
        // 当指令的 src 绑定更新时,同样需要将新的 src (可能为相对路径)
        // 解析为绝对URL,以便与旧状态中的 originalSrc (也应是绝对URL) 进行准确比较,
        // 并用于初始化新状态。
        newResolvedOriginalSrc = new URL(newAttributeSrc, window.location.href)
          .href;
      } catch {
        // 解析失败则回退到原始属性值。
        newResolvedOriginalSrc = newAttributeSrc;
      }
    } else if (el.src && el.src !== window.location.href) {
      // 如果更新后 attribute "src" 被移除或为空,但 el.src 仍有有效值。
      newResolvedOriginalSrc = el.src;
    }

    if (!oldState || newResolvedOriginalSrc === oldState.originalSrc) {
      // 如果 src 相同,并且它已经成功加载(无论是由浏览器还是通过 attemptLoad)
      // 确保可见性正确,如果它被隐藏的话。
      if (
        oldState &&
        oldState.originalSrc &&
        el.complete &&
        el.naturalWidth > 0 &&
        el.src === oldState.originalSrc &&
        !oldState.isLoading &&
        !oldState.isFallbackActive
      ) {
        if (el.style.visibility === "hidden") {
          el.style.visibility = "visible";
        }
      }
      return;
    }
    // console.log(`[v-img-retry] updated: src changed from ${oldState.originalSrc} to ${newResolvedOriginalSrc}`);

    el.style.visibility = "hidden";
    if (oldState) {
      // 清除与旧状态相关的定时器
      clearTimeout(oldState.currentTimerId);
    }

    // 为新 src 重新初始化状态。 initializeState 本身将获取新的 el.getAttribute("src")
    const newState = initializeState(el, binding); // 这现在将绝对 newResolvedOriginalSrc 存储在内部

    el.removeEventListener("error", handleError); // 移除旧 src 的监听器

    if (!newState.originalSrc) {
      // initializeState 中的 originalSrc 已经是解析的
      // console.log('[v-img-retry] updated: New src is empty. Setting fallback.');
      if (el.src !== newState.fallbackSrc) {
        // fallbackSrc 也是解析的
        el.src = newState.fallbackSrc;
      }
      newState.isFallbackActive = true;
      el.style.visibility = "visible";
    } else {
      el.addEventListener("error", handleError); // 添加新 src 的监听器
      // 检查浏览器是否已经成功加载了 (现在已经是绝对路径的) new originalSrc
      if (
        el.complete &&
        el.naturalWidth > 0 &&
        el.src === newState.originalSrc
      ) {
        // console.log('[v-img-retry] updated: New image already loaded by browser:', newState.originalSrc);
        el.style.visibility = "visible";
        newState.isLoading = false;
        el.removeEventListener("error", handleError); // 已经成功
      } else {
        // console.log('[v-img-retry] updated: New image not yet loaded. Starting attemptLoad.');
        if (!newState.isLoading) {
          attemptLoad(el, newState);
        }
      }
    }
  },

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

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

如何在 Vue 项目中使用

首先,你需要在你的 Vue 应用中全局或局部注册这个指令。假设你已经将上述代码保存为 directives/vImgRetryFromSrc.js

全局注册 (main.js):

import { createApp } from 'vue';
import App from './App.vue';
import vImgRetryFromSrc from './directives/vImgRetryFromSrc';

const app = createApp(App);
app.directive('img-retry', vImgRetryFromSrc); // 指令名通常用 kebab-case
app.mount('#app');

在组件中使用:

<template>
  <div>
    <img :src="imageUrl1" v-img-retry alt="Image 1" />

    <img :src="imageUrl2" v-img-retry:./path/to/custom-fallback.jpg alt="Image 2" />

    <img
      :src="imageUrl3"
      v-img-retry
      data-retry-limit="5"
      data-retry-delay="2000"
      alt="Image 3"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue';

const imageUrl1 = ref('https://example.com/potentially-flaky-image.jpg');
const imageUrl2 = ref('https://example.com/another-image.png');
const imageUrl3 = ref('https://slow.server.com/image.gif');

// 模拟图片地址变化
setTimeout(() => {
  imageUrl1.value = 'https://example.com/new-image.jpg';
}, 5000);
</script>

<style scoped>
img {
  width: 200px;
  height: 150px;
  border: 1px solid #ccc;
  object-fit: cover; /* 保证图片内容填满容器,不变形 */
  background-color: #f0f0f0; /* 可以在图片加载完成前显示一个背景色 */
}
</style>

注意:v-img-retry:./path/to/custom-fallback.jpg 中的路径应该是相对于项目根目录或者可以通过 import.meta.glob 能解析的路径,或者是一个外部 URL。如果是一个相对项目内部的路径,确保它能被正确解析。代码中 initializeState 会尝试将其解析为绝对 URL。

总而言之,这是一个设计考虑周全、功能实用的 Vue 自定义指令,通过结合 Vue 的响应式系统和原生 JavaScript 的图片加载机制,优雅地解决了图片加载失败的常见问题。