Vue 图片重试指令 (v-img-retry) 增强:集成 visibility 控制,实现无缝加载过渡

介绍:

本文档展示了对 v-img-retry-from-src 自定义 Vue 指令的升级。通过在指令的生命周期钩子和核心加载函数 (attemptLoad) 中精确控制 元素的 visibility 样式,我们实现了以下增强:图片在挂载或 src 更新时初始隐藏,仅在原始图片成功加载或所有重试失败并应用 fallback 图片后才变为可见。此改进保留了原有的指数退避重试和 fallback 逻辑,同时优化了加载过程的视觉表现,防止了破损图标的出现。

关键逻辑调整:

  1. 统一入口到 attemptLoad: mounted 和 updated 在确认 src 有效后,都直接调用 attemptLoad,不再尝试区分缓存情况。attemptLoad 内部的 new Image() 会处理缓存(如果浏览器有缓存且有效,onload 会很快触发)。
  2. visibility 由 attemptLoad 控制: 只有 attemptLoad 内部的 onload(成功)和 onerror(重试耗尽设 fallback)会负责将 el.style.visibility 设置为 visible。
  3. 初始隐藏和占位: mounted 和 updated 开始时设置 visibility: hidden背景色(可选,已注释),确保加载流程启动前是隐藏状态且有占位。
  4. 空 src 处理: mounted 和 updated 中对 src 为空的情况,直接设置 fallback 并设为 visible。
  5. handleError 角色明确: 只捕获 元素自身的首次加载错误,然后转交给 attemptLoad 处理。

改进代码:

js 复制代码
// 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 秒
/** 占位背景色 */
const PLACEHOLDER_BG_COLOR = "#eee"; // 灰色占位背景

// --- 状态存储 ---
/**
 * 使用 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)。
 * @param {boolean} isUpdate - 标记是否在 updated 钩子中调用。
 * @returns {RetryState} - 初始化或重置后的状态对象。
 */
function initializeState(el, binding, isUpdate = false) {
  // 获取模板中期望的 src 值
  const currentTemplateSrc = el.getAttribute("src");
  // 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: currentTemplateSrc, // 存储模板期望的 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] ${isUpdate ? 'Re-initialized' : 'Initialized'} state for ${currentTemplateSrc || 'empty src'}`, state);
  return state;
}

/**
 * 核心函数:尝试加载图片,包含完整的重试和 fallback 逻辑。
 * 使用临时的 Image 对象进行加载尝试。
 * **此函数负责在最终成功或失败时将元素设为可见。**
 * @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.`);
    // 如果没有 src,应该在 mounted/updated 中处理 fallback 和可见性
    // 如果已是 fallback,也应该已经可见了
    if (state.isFallbackActive && el.style.visibility === "hidden") {
      el.style.visibility = "visible"; // 确保 fallback 可见
    }
    state.isLoading = false;
    return;
  }

  // 2. 防止并发
  if (state.isLoading) {
    // console.log(`[v-img-retry] Load attempt skipped: Already loading.`);
    return;
  }

  // 3. 开始新的尝试
  state.isLoading = true;
  state.currentTry++;
  // 元素此时应为 hidden (由 mounted/updated 设置)

  // console.log(`[v-img-retry] Attempt ${state.currentTry}/${state.retryLimit} for ${state.originalSrc}`);

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

  // 5. 定义加载成功的回调 (img.onload)
  img.onload = () => {
    // console.log(`[v-img-retry] Success on try ${state.currentTry} for ${state.originalSrc}`);
    state.isLoading = false;
    state.isFallbackActive = false;

    // 检查 el 的 src 是否需要更新 (例如,如果之前因错误被设为 fallback)
    // 注意:这里比较 el.src (当前实际显示的) 和 state.originalSrc (期望的)
    if (el.src !== state.originalSrc) {
      el.src = state.originalSrc; // 更新真实 img 元素的 src
    }

    // --- 关键:加载成功,将元素设为可见 ---
    el.style.visibility = "visible";

    // 清理可能存在的重试定时器
    clearTimeout(state.currentTimerId);
    // 成功加载后,不再需要监听此 URL 的错误,移除监听器。
    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; // 本次尝试结束

    // 判断是否还有重试机会
    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);
      // --- 重试期间,元素保持 hidden ---
    } else {
      // 重试次数已用尽
      console.error(
        `[v-img-retry] Max retries (${state.retryLimit}) reached for ${state.originalSrc}. Falling back to ${state.fallbackSrc}`,
      );
      // 检查当前 src 是否已经是 fallback,避免不必要的 DOM 操作
      if (el.src !== state.fallbackSrc) {
        el.src = state.fallbackSrc; // 设置 fallback 图片
      }
      state.isFallbackActive = true; // 标记为 fallback 状态

      // --- 关键:设置了 fallback,将元素设为可见 ---
      el.style.visibility = "visible";

      clearTimeout(state.currentTimerId); // 清理定时器
      // 达到 fallback 状态后,移除监听器,不再尝试加载 originalSrc。
      el.removeEventListener("error", handleError);
      // 可选:为 fallback 图片添加错误处理
      // el.addEventListener('error', handleFallbackError);
    }
  };

  // 7. 将目标 URL 赋给临时 Image 对象的 src,正式开始加载尝试
  // console.log(`[v-img-retry] Setting temp image src: ${state.originalSrc}`);
  img.src = state.originalSrc;
}

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

  // 检查状态是否存在,且当前是首次尝试失败 (currentTry === 0),
  // 并且不是正在由 attemptLoad 处理中 (isLoading=false),
  // 并且未激活 fallback (isFallbackActive=false)
  if (
    state &&
    !state.isLoading &&
    !state.isFallbackActive &&
    state.currentTry === 0
  ) {
    // console.log('[v-img-retry] Initial error on main element caught. Starting retry process via attemptLoad.');
    // 此时元素应保持 hidden
    attemptLoad(el, state); // 调用 attemptLoad 启动加载/重试流程
  } else if (state && state.isFallbackActive) {
    // 如果当前已经是 fallback 状态,说明是 fallback 图片自身加载失败
    // console.error(`[v-img-retry] Fallback image itself failed to load: ${state.fallbackSrc}`);
    // Fallback 也失败了,保持元素可见(或根据需求隐藏)
    // el.style.visibility = 'hidden'; // 如果想隐藏
    el.removeEventListener("error", handleError); // 移除监听器
  }
  // 其他情况 (isLoading=true, currentTry>0) 忽略,因为错误应在 attemptLoad 内部处理
}

// --- 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, false); // 初始化状态

    // 处理初始 src 无效或为空
    if (!state.originalSrc) {
      // console.log('[v-img-retry] Initial src is empty. 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);
      // 统一调用 attemptLoad 来处理加载(包括缓存)和可见性
      attemptLoad(el, state);
    }
  },

  /**
   * updated: 元素 VNode 更新时调用 (通常因为绑定的 :src 变化)。
   */
  updated(el, binding) {
    const state = elementStates.get(el);
    // 获取模板绑定的最新值
    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}`);

    // --- src 变化,重置为隐藏状态,准备加载新图片 ---
    el.style.visibility = "hidden";
    // el.style.backgroundColor = PLACEHOLDER_BG_COLOR; // 确保占位背景

    clearTimeout(state.currentTimerId); // 清理旧的等待重试的定时器

    // --- 重新初始化状态 (传入 isUpdate=true) ---
    // 注意:这里会用新的 newSrc 覆盖 state.originalSrc
    const newState = initializeState(el, binding, true);

    // 清理旧的监听器
    el.removeEventListener("error", handleError);

    // 根据新 src 的有效性开始加载或设置 fallback
    if (!newState.originalSrc) {
      // console.log('[v-img-retry] Updated src is empty. Setting and showing fallback.');
      if (el.src !== newState.fallbackSrc) {
        el.src = newState.fallbackSrc;
      }
      newState.isFallbackActive = true; // 更新新状态
      // --- 直接显示 Fallback ---
      el.style.visibility = "visible";
      // 不需要错误监听
    } else {
      // --- 新 src 有效,添加监听器并启动加载流程 ---
      el.addEventListener("error", handleError);
      // 使用新状态调用 attemptLoad
      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;

为什么我们还要在 mounted 里面主动调用 attemptLoad(el, state) 呢?

mounted<img> 元素有了一个有效的 src,浏览器会立即开始尝试加载这个图片。

那么,为什么我们还要在 mounted 里面主动调用 attemptLoad(el, state) 呢?这看起来像是重复劳动。主要原因有以下几点,是为了指令能够完全、一致地控制加载流程、状态和最终的可见性

  1. 统一加载/重试逻辑入口: attemptLoad 是整个指令中负责实际图片加载尝试(无论是首次还是重试)、处理成功 (onload)、处理失败 (onerror)、管理重试计数和延迟、以及最终设置 fallback 的唯一核心函数 。通过在 mounted 时就调用它,可以确保所有 有效的 src(无论是初始的还是后续更新的)都通过同一个逻辑路径来处理。这使得代码更内聚、更容易维护。

  2. 接管加载状态管理: 指令需要维护自己的内部状态,比如 isLoading, currentTry, isFallbackActive。如果仅仅依赖浏览器自身的加载,指令无法准确地知道加载何时开始、何时结束(特别是成功时),以及是否应该进入 isLoading 状态。通过调用 attemptLoad,指令可以立即将状态设置为 isLoading = true,并在其内部的 onloadonerror 回调中精确地更新这些状态。

  3. 可靠地处理加载成功(尤其是缓存): 浏览器加载成功后会触发 load 事件,但这个事件的时机有时比较微妙,特别是对于缓存中的图片,它可能在你的 JS 代码(比如 mounted 里的 addEventListener('load', ...))准备好监听之前就已经触发了。attemptLoad 通过创建一个新的 Image 对象并监听其 onload,可以更可靠地捕获到加载成功的信号(即使是来自缓存,onload 通常也会非常快地触发),然后在这个回调里执行必要的操作,比如visibility 设置为 visible

  4. 确保错误处理流程启动: 虽然我们在 el 上添加了 handleError 监听器,它的主要目的是捕获由浏览器直接 发起的对 el.src首次 加载尝试失败。但是,attemptLoad 内部的 img.onerror 是处理所有后续重试失败 以及(在某些情况下)首次尝试失败 (如果 attemptLoadhandleError 更先捕获到错误)的地方。通过立即调用 attemptLoad,我们确保了即使首次加载就失败,也能无缝地进入由 attemptLoad 控制的重试/fallback 流程。

  5. 控制 visibility 最关键的是,根据我们引入 visibility 的需求,我们需要在确切知道 图片加载成功或 fallback 已应用时,才将 visibility 设置为 visibleattemptLoad 内部的 onloadonerror(处理 fallback 的分支)是执行这个操作的最精确的位置。如果在 mounted 时不调用 attemptLoad,我们就缺少一个统一的机制来在加载成功时设置 visible

总结来说:

调用 attemptLoad(el, state) 并不仅仅是为了"重新加载"图片,更是为了:

  • 启动并接管指令自身的加载状态管理和重试逻辑。
  • 提供一个统一的机制 来处理成功 (onload) 和失败 (onerror),特别是为了精确控制 visibility 的变更
  • 确保即使是快速从缓存加载的图片,也能被指令捕获到成功状态并设置为可见。

虽然看起来浏览器和 attemptLoad 内部的 new Image() 都在尝试加载,存在一定的冗余,但这是为了换取指令对整个加载生命周期(包括重试、fallback 和可见性控制)的完全掌控逻辑一致性所付出的代价。这种模式在需要复杂交互(如重试)的场景下是常见的。

那这样会导致图片加载2次吗?如果重复加载,会影响渲染性能吗?

通常不会导致图片从网络加载两次,但确实存在轻微的冗余处理。对渲染性能的影响通常很小,甚至可能是正面的(通过控制显示时机)。

让我们详细分析一下:

  1. 浏览器缓存机制是关键:

    • mounted 钩子执行时,如果 <img> 标签 (el) 有一个有效的 src,浏览器会立即开始发起网络请求(或者检查缓存)。
    • 紧接着,我们调用 attemptLoad,它内部创建了一个新的 Image 对象 (img = new Image()),并再次相同的 src (img.src = state.originalSrc) 赋给它。
    • 此时,浏览器收到第二个对同一 URL 的请求。现代浏览器拥有强大的缓存机制(HTTP 缓存,包括内存缓存和磁盘缓存)。
    • 大概率情况: 第一个请求(由 el.src 触发)可能已经完成或正在进行中,其结果(图片数据)很可能已经被浏览器缓存了。当第二个请求(由 img.src 触发)到达时,浏览器会发现这个 URL 的资源已经在缓存中,并且通常会直接从缓存中读取数据 ,而不会再次发起真正的网络请求
  2. 是否存在"重复加载"?

    • 网络层面: 通常不会重复从网络下载。第二次请求会命中缓存。
    • 处理层面: 浏览器确实需要为两个不同的上下文(原始的 <img> 元素和临时的 Image 对象)处理图片数据(即使是从缓存加载)。这意味着图片数据可能会被解码两次。
  3. 渲染性能影响:

    • 负面影响(通常很小):
      • 创建额外的 Image 对象有微小的内存开销。
      • 浏览器处理(解码)图片数据两次有微小的 CPU 开销。对于大多数图片和场景,这种开销相比于网络延迟、复杂的 JavaScript 计算或 DOM 操作来说是微不足道的。只有在极端情况下(例如页面上有成百上千张图片同时使用此指令,或者图片本身极其巨大且复杂)才可能需要关注。
    • 正面影响(更关键):
      • 控制显示时机: 通过 visibility: hidden 并在 onload 或最终 fallback 时才改为 visible,我们避免了图片加载过程中可能出现的闪烁或布局抖动(配合尺寸设定) ,并确保用户只看到最终的、完整的图像(或 fallback),这提升了感知性能和视觉稳定性
      • 优雅的错误处理和重试: 指令提供的重试和 fallback 机制,极大地改善了图片加载失败时的用户体验,这也是一种重要的"性能"提升(用户体验性能)。

结论:

虽然从代码逻辑上看,似乎触发了两次加载请求,但得益于浏览器强大的缓存机制,通常不会导致图片从网络重复下载。存在一些轻微的内部处理冗余(主要是图片数据解码),但其性能开销对于绝大多数应用场景可以忽略不计。

这种模式(通过 new Image() 来预加载/检查状态)是为了获得对加载过程的精确控制 (状态管理、成功/失败回调、重试逻辑、可见性控制)而付出的合理代价。它带来的用户体验提升(避免破图、加载占位、平滑显示、失败降级)往往远超其微小的性能开销。

例外情况: 如果图片的 HTTP 响应头强制禁止了缓存(例如 Cache-Control: no-store),或者是在浏览器缓存被禁用或极其特殊的情况下,才有可能发生真正的网络二次加载。但这对于标准的图片服务来说是不常见的。

相关推荐
寻星探路36 分钟前
【深度长文】万字攻克网络原理:从 HTTP 报文解构到 HTTPS 终极加密逻辑
java·开发语言·网络·python·http·ai·https
王达舒199437 分钟前
HTTP vs HTTPS: 终极解析,保护你的数据究竟有多重要?
网络协议·http·https
朱皮皮呀39 分钟前
HTTPS的工作过程
网络协议·http·https
Binary-Jeff43 分钟前
一文读懂 HTTPS 协议及其工作流程
网络协议·web安全·http·https
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端