介绍:
本文档展示了对 v-img-retry-from-src 自定义 Vue 指令的升级。通过在指令的生命周期钩子和核心加载函数 (attemptLoad) 中精确控制 元素的 visibility 样式,我们实现了以下增强:图片在挂载或 src 更新时初始隐藏,仅在原始图片成功加载或所有重试失败并应用 fallback 图片后才变为可见。此改进保留了原有的指数退避重试和 fallback 逻辑,同时优化了加载过程的视觉表现,防止了破损图标的出现。
关键逻辑调整:
- 统一入口到 attemptLoad: mounted 和 updated 在确认 src 有效后,都直接调用 attemptLoad,不再尝试区分缓存情况。attemptLoad 内部的 new Image() 会处理缓存(如果浏览器有缓存且有效,onload 会很快触发)。
- visibility 由 attemptLoad 控制: 只有 attemptLoad 内部的 onload(成功)和 onerror(重试耗尽设 fallback)会负责将 el.style.visibility 设置为 visible。
- 初始隐藏和占位: mounted 和 updated 开始时设置
visibility: hidden
和背景色
(可选,已注释),确保加载流程启动前是隐藏状态且有占位。 - 空 src 处理: mounted 和 updated 中对 src 为空的情况,直接设置 fallback 并设为 visible。
- 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)
呢?这看起来像是重复劳动。主要原因有以下几点,是为了指令能够完全、一致地控制加载流程、状态和最终的可见性:
-
统一加载/重试逻辑入口:
attemptLoad
是整个指令中负责实际图片加载尝试(无论是首次还是重试)、处理成功 (onload
)、处理失败 (onerror
)、管理重试计数和延迟、以及最终设置 fallback 的唯一核心函数 。通过在mounted
时就调用它,可以确保所有 有效的src
(无论是初始的还是后续更新的)都通过同一个逻辑路径来处理。这使得代码更内聚、更容易维护。 -
接管加载状态管理: 指令需要维护自己的内部状态,比如
isLoading
,currentTry
,isFallbackActive
。如果仅仅依赖浏览器自身的加载,指令无法准确地知道加载何时开始、何时结束(特别是成功时),以及是否应该进入isLoading
状态。通过调用attemptLoad
,指令可以立即将状态设置为isLoading = true
,并在其内部的onload
或onerror
回调中精确地更新这些状态。 -
可靠地处理加载成功(尤其是缓存): 浏览器加载成功后会触发
load
事件,但这个事件的时机有时比较微妙,特别是对于缓存中的图片,它可能在你的 JS 代码(比如mounted
里的addEventListener('load', ...)
)准备好监听之前就已经触发了。attemptLoad
通过创建一个新的Image
对象并监听其onload
,可以更可靠地捕获到加载成功的信号(即使是来自缓存,onload
通常也会非常快地触发),然后在这个回调里执行必要的操作,比如将visibility
设置为visible
。 -
确保错误处理流程启动: 虽然我们在
el
上添加了handleError
监听器,它的主要目的是捕获由浏览器直接 发起的对el.src
的首次 加载尝试失败。但是,attemptLoad
内部的img.onerror
是处理所有后续重试失败 以及(在某些情况下)首次尝试失败 (如果attemptLoad
比handleError
更先捕获到错误)的地方。通过立即调用attemptLoad
,我们确保了即使首次加载就失败,也能无缝地进入由attemptLoad
控制的重试/fallback 流程。 -
控制
visibility
: 最关键的是,根据我们引入visibility
的需求,我们需要在确切知道 图片加载成功或 fallback 已应用时,才将visibility
设置为visible
。attemptLoad
内部的onload
和onerror
(处理 fallback 的分支)是执行这个操作的最精确的位置。如果在mounted
时不调用attemptLoad
,我们就缺少一个统一的机制来在加载成功时设置visible
。
总结来说:
调用 attemptLoad(el, state)
并不仅仅是为了"重新加载"图片,更是为了:
- 启动并接管指令自身的加载状态管理和重试逻辑。
- 提供一个统一的机制 来处理成功 (
onload
) 和失败 (onerror
),特别是为了精确控制visibility
的变更。 - 确保即使是快速从缓存加载的图片,也能被指令捕获到成功状态并设置为可见。
虽然看起来浏览器和 attemptLoad
内部的 new Image()
都在尝试加载,存在一定的冗余,但这是为了换取指令对整个加载生命周期(包括重试、fallback 和可见性控制)的完全掌控 和逻辑一致性所付出的代价。这种模式在需要复杂交互(如重试)的场景下是常见的。
那这样会导致图片加载2次吗?如果重复加载,会影响渲染性能吗?
通常不会导致图片从网络加载两次,但确实存在轻微的冗余处理。对渲染性能的影响通常很小,甚至可能是正面的(通过控制显示时机)。
让我们详细分析一下:
-
浏览器缓存机制是关键:
- 当
mounted
钩子执行时,如果<img>
标签 (el
) 有一个有效的src
,浏览器会立即开始发起网络请求(或者检查缓存)。 - 紧接着,我们调用
attemptLoad
,它内部创建了一个新的Image
对象 (img = new Image()
),并再次 将相同的src
(img.src = state.originalSrc
) 赋给它。 - 此时,浏览器收到第二个对同一 URL 的请求。现代浏览器拥有强大的缓存机制(HTTP 缓存,包括内存缓存和磁盘缓存)。
- 大概率情况: 第一个请求(由
el.src
触发)可能已经完成或正在进行中,其结果(图片数据)很可能已经被浏览器缓存了。当第二个请求(由img.src
触发)到达时,浏览器会发现这个 URL 的资源已经在缓存中,并且通常会直接从缓存中读取数据 ,而不会再次发起真正的网络请求。
- 当
-
是否存在"重复加载"?
- 网络层面: 通常不会重复从网络下载。第二次请求会命中缓存。
- 处理层面: 浏览器确实需要为两个不同的上下文(原始的
<img>
元素和临时的Image
对象)处理图片数据(即使是从缓存加载)。这意味着图片数据可能会被解码两次。
-
渲染性能影响:
- 负面影响(通常很小):
- 创建额外的
Image
对象有微小的内存开销。 - 浏览器处理(解码)图片数据两次有微小的 CPU 开销。对于大多数图片和场景,这种开销相比于网络延迟、复杂的 JavaScript 计算或 DOM 操作来说是微不足道的。只有在极端情况下(例如页面上有成百上千张图片同时使用此指令,或者图片本身极其巨大且复杂)才可能需要关注。
- 创建额外的
- 正面影响(更关键):
- 控制显示时机: 通过
visibility: hidden
并在onload
或最终 fallback 时才改为visible
,我们避免了图片加载过程中可能出现的闪烁或布局抖动(配合尺寸设定) ,并确保用户只看到最终的、完整的图像(或 fallback),这提升了感知性能和视觉稳定性。 - 优雅的错误处理和重试: 指令提供的重试和 fallback 机制,极大地改善了图片加载失败时的用户体验,这也是一种重要的"性能"提升(用户体验性能)。
- 控制显示时机: 通过
- 负面影响(通常很小):
结论:
虽然从代码逻辑上看,似乎触发了两次加载请求,但得益于浏览器强大的缓存机制,通常不会导致图片从网络重复下载。存在一些轻微的内部处理冗余(主要是图片数据解码),但其性能开销对于绝大多数应用场景可以忽略不计。
这种模式(通过 new Image()
来预加载/检查状态)是为了获得对加载过程的精确控制 (状态管理、成功/失败回调、重试逻辑、可见性控制)而付出的合理代价。它带来的用户体验提升(避免破图、加载占位、平滑显示、失败降级)往往远超其微小的性能开销。
例外情况: 如果图片的 HTTP 响应头强制禁止了缓存(例如 Cache-Control: no-store
),或者是在浏览器缓存被禁用或极其特殊的情况下,才有可能发生真正的网络二次加载。但这对于标准的图片服务来说是不常见的。