介绍:
在现代 Web 应用中,图片是传递信息和提升用户体验的关键元素。然而,由于网络波动、服务器瞬时故障或图片资源本身的问题,图片加载失败的情况时有发生,这往往会导致页面出现破坏性的“破图”图标,影响美观和用户感知。
为了优雅地处理此类问题,本文将详细剖析 v-img-retry-from-src 的 Vue.js 自定义指令。该指令旨在为 <img> 标签提供一套健壮的图片加载容错机制。它不仅能在图片初次加载失败时自动进行多次重试(采用指数退避策略以避免对服务器造成过大压力),还能在所有重试均告失败后,平滑地切换到用户指定的或默认的占位符(fallback)图片。
通过引入此指令,开发者可以轻松提升应用的鲁棒性,减少因图片加载问题带来的负面用户体验,确保即使在不稳定网络环境下,应用界面也能保持其完整性和专业性。接下来,我们将深入探讨该指令的实现原理、核心功能、设计思路以及具体使用方法。
下面我们来详细解析思路和设计要点:
图文解释
我们可以把这个指令想象成一个智能的图片加载助手:
-
接收任务: 你告诉助手 (
<img>标签 +v-img-retry-from-src="imageUrl") 要显示哪张图片。+-------------------+ | User wants to see | ---imageUrl---> [Image Helper (Directive)] | imageUrl.jpg | +-------------------+ -
初步尝试: 助手先尝试直接让浏览器加载这张图片。
-
成功: 图片显示,任务完成。
[Image Helper] ---tries---> Browser ---loads---> 😊 (Image Displayed) -
失败: 浏览器报告错误。
[Image Helper] ---tries---> Browser ---fails---> 😟 (Error)
-
-
秘密重试 (如果初步失败) :
- 助手不会立刻放弃。它会悄悄地(在后台创建一个临时的
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) - 助手不会立刻放弃。它会悄悄地(在后台创建一个临时的
-
最终手段 (如果所有重试都失败) :
- 如果所有秘密尝试都失败了,助手会放弃加载原图,并显示一张预设的“兜底”图片(占位符)。
[Image Helper] | +--- (All retries failed) ---> Display FallbackImage.jpg ---> 🖼️ (Fallback Displayed) -
图片源更新: 如果你改变了要显示的图片 (
imageUrl变化了),助手会重新开始整个流程,并取消之前图片的加载尝试。 -
助手离场: 如果图片元素从页面上移除了,助手会清理掉所有进行中的尝试和记录,确保不留后患(内存泄漏)。
思路解析
代码核心逻辑:
-
指令钩子驱动: Vue 自定义指令的生命周期钩子(
mounted,updated,beforeUnmount)是整个逻辑的入口和驱动力。mounted: 元素首次插入 DOM。初始化状态,尝试加载初始src。updated: 元素或其绑定值更新。检查src是否变化,如果变化则重置状态并为新src开始加载流程。beforeUnmount: 元素即将销毁。进行清理工作,如清除定时器、移除事件监听器、删除状态。
-
状态管理: 每个应用该指令的
<img>元素都需要独立的状态来跟踪其加载过程。- 使用
WeakMap(elementStates) 存储每个元素的状态。WeakMap的键是 DOM 元素,当元素被垃圾回收时,对应的状态也会被自动清除,有效防止内存泄漏。 RetryState对象详细定义了所需状态:原始图片 URL、当前尝试次数、重试上限、延迟时间、后备图片 URL、加载状态、定时器ID等。
- 使用
-
URL 处理:
- 绝对路径转换: 原始
src和后备fallbackSrc都会被尝试转换为绝对 URL (使用new URL(path, window.location.href).href)。这确保了无论指令在何处使用,图片路径都能被正确解析,也便于后续比较和直接赋值给Image对象的src。 - Vite
import.meta.glob: 用于在构建时或开发时动态地获取placeholder-error.jpg的 URL。这是一种现代 JavaScript 模块系统中处理静态资源的方式,Vite 会将其转换为实际的 URL 字符串。
- 绝对路径转换: 原始
-
核心加载与重试逻辑 (
attemptLoad函数) :-
后台加载: 创建一个临时的
Image对象 (img = new Image()) 在后台尝试加载图片。这样做的好处是不会直接在页面上显示破碎的图片图标,直到图片确认加载成功。 -
onload处理:- 如果临时
Image对象加载成功,首先检查图片的naturalWidth和naturalHeight。如果为0,视为“软错误”(图片本身可能损坏或无效),并触发重试逻辑。 - 如果尺寸有效,则认为图片加载成功。更新实际
<img>元素的src为原始src,使其可见,并清除相关的错误处理和定时器。 tempSuccessAchievedForOriginalSrc标记临时图片加载成功,用于handleError中的特定场景。
- 如果临时
-
onerror处理:- 如果临时
Image对象加载失败,检查是否已达到重试次数上限。 - 未达上限: 使用
setTimeout和指数退避策略(retryDelay * 2 ** currentTry,并设有最大延迟MAX_RETRY_DELAY_MS)安排下一次重试。存储currentTimerId以便可以取消。 - 已达上限: 加载失败,将实际
<img>元素的src设置为后备图片fallbackSrc,并使其可见。
- 如果临时
-
异步安全 (
loadAttemptId) : 每次新的加载尝试(无论是初始化还是src更新)都会生成一个新的loadAttemptId。在onload和onerror回调中,会检查当前状态的loadAttemptId是否与触发回调时的loadAttemptId一致。如果不一致,说明src已经改变,这是一个过时的回调,应被忽略,防止旧的加载结果影响新的加载尝试。
-
-
主图错误处理 (
handleError函数) :- 此函数作为实际
<img>元素的error事件监听器。 - 主要捕获初始加载时主
<img>元素直接发生的错误。 - 如果后备图片本身加载失败,则记录错误并停止。
- 一个特殊情况:如果
tempImg曾成功加载 (tempSuccessAchievedForOriginalSrc为 true),然后el.src被设置为originalSrc,但el自身加载此originalSrc时还是失败了,则直接强制使用后备图片。 - 如果是初次错误且未开始
attemptLoad流程,则启动attemptLoad。
- 此函数作为实际
-
配置灵活性:
- 通过全局常量 (
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。
- 通过全局常量 (
设计要点
-
用户体验 (UX) :
- 隐藏再显示: 图片在成功加载或切换到后备图之前,其
visibility设置为hidden,避免显示破碎的图片图标或因加载失败导致的布局跳动。 - 自动重试: 对用户透明地处理临时的网络问题,提升图片加载成功率。
- 优雅降级: 多次失败后提供占位符,比显示错误图标更好。
- 隐藏再显示: 图片在成功加载或切换到后备图之前,其
-
健壮性与错误处理:
- 多重检查: 不仅检查
onerror,还检查onload后的naturalWidth/Height(软错误)。 - 绝对路径: 统一处理 URL 为绝对路径,减少因相对路径在不同上下文解析不一致导致的问题。
- 异步安全:
loadAttemptId机制确保了在src快速变化时,只有最新的加载尝试的异步回调会生效。 - 后备图加载失败处理: 考虑到了后备图片本身也可能加载失败的情况。
- 多重检查: 不仅检查
-
性能与资源管理:
WeakMap: 自动管理元素状态与元素生命周期,防止内存泄漏。- 指数退避: 避免在短时间内对服务器发起过多请求。
- 清理机制:
beforeUnmount钩子确保清除定时器和事件监听器,释放资源。 - 条件执行: 在
updated钩子中,只有当src真正改变时才执行完整的更新逻辑。
-
模块化与可配置性:
- 清晰的函数划分:
initializeState,attemptLoad,handleError各司其职。 - 配置选项: 提供了全局默认值,并允许通过
data-*属性和指令参数进行个性化配置。 - Vite 集成:
import.meta.glob的使用是 Vite 项目中处理静态资源的推荐方式之一。
- 清晰的函数划分:
-
代码清晰度:
- JSDoc 类型定义 (
@typedef {object} RetryState) 增强了代码的可读性和可维护性。 - 注释比较充分,有助于理解各部分逻辑。
- JSDoc 类型定义 (
完整代码
// 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 的图片加载机制,优雅地解决了图片加载失败的常见问题。