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),或者是在浏览器缓存被禁用或极其特殊的情况下,才有可能发生真正的网络二次加载。但这对于标准的图片服务来说是不常见的。

相关推荐
老兵发新帖几秒前
前端知识-hook
前端·react.js·前端框架
t_hj5 分钟前
Ajax的原理和解析
前端·javascript·ajax
蓝婷儿1 小时前
前端面试每日三题 - Day 29
前端·面试·职场和发展
小白上线*^_^*1 小时前
Vue——Axios
前端·javascript·vue.js
一直在学习的小白~1 小时前
HTML字符串转换为React元素实现
前端·react.js·html
gxn_mmf2 小时前
典籍知识问答模块AI问答功能feedbackBug修改+添加对话名称修改功能
前端·后端·bug
samroom2 小时前
Webpack基本用法学习总结
前端·学习·webpack
万能程序员-传康Kk3 小时前
食物数据分析系统vue+flask
前端·vue.js·flask
GIS瞧葩菜3 小时前
HTTP 状态码是服务器对客户端请求的响应标识,用于表示请求的处理结果
服务器·网络协议·http
老华带你飞4 小时前
音乐网站|基于SprinBoot+vue的音乐网站(源码+数据库+文档)
java·前端·数据库·vue.js·论文·毕设·音乐网站