头像组件崩溃、乱序、加载失败?一套队列机制+多级兜底全搞定

⚠️ 环境要求说明 > > - 本文部分代码(如 faviconURL 方法、chrome.runtime.getURL 等)仅适用于 Chrome 扩展(插件)环境。 > - 如果你在普通网页项目中使用,将无法获取 favicon,建议使用公开 favicon 服务(如 https://www.google.com/s2/favicons?domain=xxx.com)。

🚀 前端头像组件进化论:再也不怕图片挂掉啦!

你是否遇到过头像组件加载慢、频繁切换联系人时头像显示错乱的尴尬?本篇带你拆解一个"稳如老狗"的头像组件,核心亮点就在于------用队列串行机制,优雅消灭异步竞态问题,让多级兜底(图片、favicon、首字母)始终有序、永不穿越!

"头像图片又挂了?别慌,咱有 Plan B、Plan C、Plan D!"

🎯 需求背景

头像组件的常见痛点:

  • 图片地址失效,变成尴尬的叉叉

  • 有些站点 favicon,浏览器给你返回个地球图标(默认图标),毫无辨识度

  • 没图也没 favicon?那就干脆显示首字母吧

我们的目标就是------无论发生什么,头像都得有内容!


🏗️ 组件结构

xml 复制代码
<template>
  <!-- 能显示图片就显示图片 -->
  <img v-if="shouldShowImage" ... />
  <!-- 否则显示自定义图标(首字母背景) -->
  <div v-else class="custom-icon" :style="...">
    {{ getText }}
  </div>
</template>

是不是很直观?下面是核心 JS 逻辑。


🧠 智能加载与兜底机制

1. 主流程:优先级三连跳

  1. 优先加载传入的图片 URL能用就用,不能用就想办法兜底。

  2. 图片挂了?那就去 Chrome浏览器 要 缓存过的站点favicon (只要浏览器访问过那个站点,通常浏览器都会保存该站点的favicon), 咱直接拿来用!

  3. favicon 还是失败?那就显示首字母和相应的背景色 总不能让用户看到一片空白吧,至少得有个字母撑场面。


2. 判断 favicon 是不是"地球"

有些网站没 favicon或者favicon 资源加载失败,Chrome 会给你个地球图标。我们用 SHA-256 算法,把图片内容哈希一下,和已知的"地球"哈希值对比。一旦发现是地球,立刻切换 Plan C!

🚥 如何将图片内容哈希化?

有些朋友可能会好奇:"你说用 SHA-256 算法判断 favicon 是不是 Chrome 的'地球',那图片怎么变成哈希值呢?"

其实很简单,前端用原生 API 就能搞定:

  1. 获取图片的 ArrayBuffer 先用 fetch 拉图片资源,拿到二进制数据。

  2. crypto.subtle.digest 计算哈希浏览器原生支持 SHA-256 等多种哈希算法。

  3. 把哈希转成十六进制字符串方便和已知的"地球"哈希比对。

完整代码如下:

javascript 复制代码
async function getImageHash(url) {
  // 1. 拉取图片资源
  const response = await fetch(url)
  const arrayBuffer = await response.arrayBuffer()
  // 2. 计算哈希
  const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
  // 3. 转成十六进制字符串
  const hashHex = Array.from(new Uint8Array(hashBuffer))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
  return hashHex
}

用途举例:

javascript 复制代码
const defaultImageHashes = new Set([
  'b30d0997...bf74', // 地球图标的哈希
  'e5c321f5...cd5b'
])

const hash = await getImageHash(faviconUrl)
if (defaultImageHashes.has(hash)) {
  // 是"地球"图标,直接兜底用首字母
  showDefaultIcon()
}

这样,判断"是不是地球"就变得科学又精准啦!


3. 图片加载加速&缓存

  • 每次加载图片前,先查缓存,没变就不重复加载。
  • 加载图片有超时机制,3秒还没加载出来,直接兜底。

🌟 图片加载加速&缓存代码示例

javascript 复制代码
// 简单图片缓存,key 为图片URL,value 为加载结果(true/false 或图片对象)
const imageCache = new Map()

/**
 * 加载图片并缓存,支持超时兜底
 * @param {string} url 图片地址
 * @param {number} timeout 超时时间(毫秒),默认 3000
 * @returns {Promise<boolean>} 是否加载成功
 */
function loadImageWithCache(url, timeout = 3000) {
  // 已缓存且加载成功,直接返回
  if (imageCache.has(url) && imageCache.get(url) === true) {
    return Promise.resolve(true)
  }

  return new Promise((resolve) => {
    const img = new window.Image()

    let timer = setTimeout(() => {
      img.src = '' // 触发 onerror,防止图片继续加载
      imageCache.set(url, false)
      resolve(false) // 超时兜底
    }, timeout)

    img.onload = () => {
      clearTimeout(timer)
      imageCache.set(url, true)
      resolve(true)
    }

    img.onerror = () => {
      clearTimeout(timer)
      imageCache.set(url, false)
      resolve(false)
    }

    img.src = url
  })
}
使用示例
rust 复制代码
const url = 'https://example.com/avatar.jpg'
const isLoaded = await loadImageWithCache(url, 3000)
if (isLoaded) {
  // 显示图片
} else {
  // 走兜底逻辑(favicon 或首字母)
}

要点说明

  • 缓存加速:同一个图片 URL,只要加载成功一次,后续都直接命中缓存,不重复发请求。
  • 超时兜底:3 秒没加载出来,立刻走兜底逻辑
  • 灵活扩展 :你可以把 imageCache 做成更复杂的 LRU 缓存,甚至支持失效时间。

🎨 颜色算法:首字母也能有高级感

你以为首字母背景色是随意挑的吗?其实背后有一套"名字转彩虹"算法!

javascript 复制代码
const colorArr = [
  '#FF9C40', '#FF4040', '#F5CC3D', '#68C331', '#33CBA6',
  '#32C9C9', '#3AA8E5', '#5C6AE5', '#995CE6', '#E55CBF'
]

// 通过名字生成一个稳定的下标
function stringToNumberMod(str) {
  let sum = 0
  for (let index = 0; index < str.length; index++) {
    sum += str[index].charCodeAt(0)
  }
  return sum % colorArr.length
}

// 支持多种颜色格式
function getColorFromName(name, format = 'hex') {
  if (!name) return

  const index = stringToNumberMod(name)
  const hexColor = colorArr[index]

  if (format === 'hexAlpha') {
    // hex转hex+透明度
    return `${hexColor.replace('#', '')}FF`
  } else if (format === 'rgba') {
    // hex转rgba
    const hex = hexColor.replace('#', '')
    const r = parseInt(hex.substring(0, 2), 16)
    const g = parseInt(hex.substring(2, 4), 16)
    const b = parseInt(hex.substring(4, 6), 16)
    return `rgba(${r}, ${g}, ${b}, 1)`
  }

  return hexColor
}

小结:

同一个名字,永远是同一个颜色。再也不用担心"今天红,明天绿,后天变彩虹"啦!


⚡ 队列机制&超时兜底:让每次变脸都井井有条

说到这里,聪明的你可能会问:"那如果用户手速如飞,疯狂切换联系人,头像会不会乱套?"

别怕,我们有队列机制来兜底!

为什么不直接取消上一个请求?

  • Chrome 拿 favicon 是浏览器异步的,没法中途取消。

  • Chrome 有时候响应慢,如果不控制,慢请求可能会覆盖新头像,出现"穿越"现象!

所以我们采用队列机制

每次有新任务(比如 url 变了),就把任务塞进队列。如果当前没人处理队列,就启动一个"处理员",顺序执行队列里的每个任务,保证同一时刻只处理一个。这样无论遇到多激烈的"变脸"场景,头像组件都能稳稳当当、井井有条!

Chrome favicon 也不会"死等"!

更机智的是,我们不会一直傻等 Chrome

毕竟头像不能一直空着等"慢快递",如果等了 2秒还没回来,组件就会自动放弃 Chrome,直接执行第三套策略------用首字母顶上! 之所以只给 2 秒超时,是因为从浏览器本地拿 favicon 资源,本质是本地 IO 操作,不需要走网络,一般来说响应非常快。如果 2 秒还没拿到,说明浏览器响应异常或资源不存在,直接兜底更合理。

这样用户永远不会看到"加载中"的尴尬

javascript 复制代码
async function loadChromeFavicon(timeout = 2000) {
  if (!props.chromeImgUrl) {
    showDefaultIcon()
    return
  }

  const faviconUrl = faviconURL(props.chromeImgUrl)
  try {
    // 超时 Promise
    const timeoutPromise = new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error('favicon timeout')), timeout)
    )
    // fetch+超时"竞速"
    const response = await Promise.race([
      fetch(faviconUrl),
      timeoutPromise
    ])
    if (!response.ok) throw new Error('Failed to fetch favicon')

    const arrayBuffer = await response.arrayBuffer()
    const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
    const hashHex = Array.from(new Uint8Array(hashBuffer))
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('')

    if (defaultImageHashes.has(hashHex)) {
      throw new Error('Default favicon detected')
    }

    currentSrc.value = faviconUrl
    shouldShowImage.value = true
  } catch (error) {
    // 只在非预期错误时打印日志
    if (!(error instanceof Error && (error.message === 'Failed to fetch favicon' || error.message === 'favicon timeout'))) {
      console.error('Unexpected error while loading favicon:', error)
    }
    showDefaultIcon()
  }
}
队列机制核心代码:
javascript 复制代码
let initQueue = []
let isProcessingQueue = false

async function init() {
  // 新任务入队
  const currentInitTask = async () => {
    await loadUrlImage(props.url)
  }
  initQueue.push(currentInitTask)

  // 如果已经有处理员在干活,就不重复启动
  if (isProcessingQueue) return

  isProcessingQueue = true
  handleTask()
}

async function handleTask() {
  while (initQueue.length > 0) {
    try {
      const task = initQueue.shift()
      await task()
    } catch (error) {
      console.log(error)
    }
  }
  isProcessingQueue = false
}

// favicon 超时兜底
async function loadChromeFaviconWithTimeout(url, timeout = 10000) {
  return Promise.race([
    loadChromeFavicon(url),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('timeout')), timeout)
    )
  ])
}

一句话总结:

与其让慢吞吞的 Chrome 请求到处乱撞,不如让它乖乖排队;

与其死等 Chrome 响应,不如有超时兜底,保证用户永远看到最新、最靠谱的头像!

这才是前端的温柔与秩序~


🤡 代码片段赏析

图片加载失败的终极兜底
scss 复制代码
async function handleError() {
  if (props.chromeImgUrl && !isDevelopment) {
    await loadChromeFaviconWithTimeout(props.url)
  } else {
    showDefaultIcon()
  }
}
Chrome favicon 获取方式
csharp 复制代码
function faviconURL(webUrl) {
  const url = new URL(chrome.runtime.getURL('/_favicon/'))
  url.searchParams.set('pageUrl', webUrl)
  url.searchParams.set('size', '64')
  return url.toString()
}
哈希判重,识别"地球"
javascript 复制代码
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
const hashHex = Array.from(new Uint8Array(hashBuffer))
  .map((b) => b.toString(16).padStart(2, '0'))
  .join('')
if (defaultImageHashes.has(hashHex)) {
  throw new Error('Default favicon detected')
}

完整代码:

vue组件实现

vue 复制代码
<template>
  <img v-if="shouldShowImage" ref="imgRef" class="img" :src="currentSrc" :alt="alt" @error="handleError" />
  <div
    v-else
    class="custom-icon"
    :style="{
      background: normalBg,
      color: 'white'
    }"
  >
    {{ getText }}
  </div>
</template>

<script setup lang="ts">
import getAvatarBgColor from '@/util/getColorFromString'
import { computed, ref, watch } from 'vue'
import { useImageCacheStore } from '@/store/imageCache'

const props = defineProps({
  url: {
    type: String,
    default: ''
  },
  alt: {
    type: String,
    required: true
  },
  normalImgText: {
    type: String,
    required: true
  },
  chromeImgUrl: {
    type: String,
    required: true
  }
})

const getText = computed(() => {
  if (!props.normalImgText) return
  return String.fromCodePoint(props.normalImgText.codePointAt(0)).toLocaleUpperCase()
})

const isDevelopment = import.meta.env.MODE === 'development'

const imgRef = ref(null)
const currentSrc = ref('')
const shouldShowImage = ref(false)
const normalBg = ref('')

let initQueue: (() => Promise<void>)[] = []
let isProcessingQueue = false

const defaultImageHashes = new Set([
  'b30d09971d9432210b53e9bad586a6592e893d06656748c21192052c9602bf74',
  'e5c321f52e7ee479ff191fa75c6b6fd91ee41393b5ca62b6a7d4f1a2b784cd5b'
])

const imageCache = useImageCacheStore()

async function loadImageWithTimeout(url: string, timeout = 10000): Promise<void> {
  // 检查缓存
  if (imageCache.hasImageCache(url)) {
    return Promise.resolve()
  }

  return new Promise((resolve, reject) => {
    const img = new Image()
    let timer: number | null = null

    const cleanUp = () => {
      if (timer !== null) {
        clearTimeout(timer)
      }
      img.onload = null
      img.onerror = null
    }

    img.onload = () => {
      cleanUp()
      imageCache.setImageCache(url) // 将图片URL添加到pinia缓存中
      resolve()
    }

    img.onerror = () => {
      cleanUp()
      reject(new Error('Image load error'))
    }

    timer = setTimeout(() => {
      cleanUp()
      reject(new Error('Image load timeout'))
    }, timeout) as unknown as number

    img.src = url
  })
}

/**
 * 异步加载 Chrome 浏览器图标
 *
 * @param timeout 超时时间,默认为 3000 毫秒
 * @returns 无返回值
 */
async function loadChromeFavicon(timeout = 3000) {
  if (!props.chromeImgUrl) {
    showDefaultIcon()
    return
  }

  const faviconUrl = faviconURL(props.chromeImgUrl)
  try {
    // 超时 Promise
    const timeoutPromise = new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error('favicon timeout')), timeout)
    )
    // fetch+超时"竞速"
    const response = await Promise.race([
      fetch(faviconUrl),
      timeoutPromise
    ])
    if (!response.ok) throw new Error('Failed to fetch favicon')

    const arrayBuffer = await response.arrayBuffer()
    const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)
    const hashHex = Array.from(new Uint8Array(hashBuffer))
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('')

    if (defaultImageHashes.has(hashHex)) {
      throw new Error('Default favicon detected')
    }

    currentSrc.value = faviconUrl
    shouldShowImage.value = true
  } catch (error) {
    // 只在非预期错误时打印日志
    if (!(error instanceof Error && (error.message === 'Failed to fetch favicon' || error.message === 'favicon timeout'))) {
      console.error('Unexpected error while loading favicon:', error)
    }
    showDefaultIcon()
  }
}

/**
 * 根据网站url获取对应浏览器缓存的网站图标的URL
 *
 * @param webUrl 要获取favicon的网页URL
 * @returns 返回指定网页的favicon URL
 */
function faviconURL(webUrl: string): string {
  const url = new URL(chrome.runtime.getURL('/_favicon/'))
  url.searchParams.set('pageUrl', webUrl)
  url.searchParams.set('size', '64')
  return url.toString()
}

/**
 * 处理错误情况的异步函数
 *
 * @async
 */
async function handleError() {
  if (props.chromeImgUrl && !isDevelopment) {
    await loadChromeFavicon()
  } else {
    showDefaultIcon()
  }
}

/**
 * 显示默认图标
 *
 * @returns 无返回值
 */
function showDefaultIcon() {
  shouldShowImage.value = false
  if (!normalBg.value) {
    normalBg.value = getAvatarBgColor(props.alt)
  }
}

/**
 * 异步加载图片资源
 *
 * @param url 图片资源的URL
 */
async function loadUrlImage(url: string) {
  if (url && url !== 'normal') {
    try {
      await loadImageWithTimeout(url)
      currentSrc.value = url
      shouldShowImage.value = true
      return
    } catch (error) {
      console.error('Failed to load image:', error)
    }
  }
  await handleFallbackImage()
}

/**
 * 处理备用图片的函数
 *
 * @returns {Promise<void>} 返回一个Promise对象,无返回值
 */
async function handleFallbackImage() {
  if (props.chromeImgUrl && !isDevelopment) {
    await loadChromeFavicon()
  } else {
    showDefaultIcon()
  }
}

/**
 * 初始化函数
 *
 * 该函数负责初始化过程,包括加载图片URL并处理初始化队列。
 */
async function init() {
  const currentInitTask = async () => {
    // 加载图片URL
    await loadUrlImage(props.url)
  }

  // 将当前初始化任务添加到初始化队列中
  initQueue.push(currentInitTask)

  // 如果队列正在处理中,则直接返回
  if (isProcessingQueue) {
    // 如果队列正在处理中,直接返回
    return
  }

  // 设置队列正在处理状态
  isProcessingQueue = true
  handleTask()
}

/**
 * 处理任务队列
 *
 * 循环处理队列中的任务,直到队列为空
 */
async function handleTask() {
  // 循环处理队列中的任务,直到队列为空
  while (initQueue.length > 0) {
    try {
      // 取出队头任务: 图片资源请求task
      const task = initQueue.shift()
      await task()
    } catch (error) {
      console.log(error)
    }
  }
  isProcessingQueue = false
}

watch(
  () => props.url,
  () => {
    init()
  },
  { immediate: true }
)
</script>

<style scoped>
.img {
  object-fit: cover;
  user-select: none;
  width: 100%;
  height: 100%;
}
.custom-icon {
  user-select: none;
  width: 100%;
  height: 100%;
  flex-shrink: 0;
  border-radius: 8px;
  font-weight: 600;
  font-size: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
}
</style>

🥳 总结

这个组件的思路其实很简单:

  • 能用图片就用图片

  • 不能用就找 favicon

  • 还不行就用首字母撑场面

  • 颜色算法保证风格统一,队列机制保证状态稳定,超时机制保证永不卡壳

  • 哈希算法让兜底逻辑更智能

你要是还在为破图发愁,不妨把这个思路抄回去,分分钟让你的前端页面颜值爆表!


前端的世界需要更多"兜底"与温柔,别让用户看到尴尬的叉叉啦!


相关推荐
全宝36 分钟前
🖲️一行代码实现鼠标换肤
前端·css·html
小小小小宇1 小时前
前端模拟一个setTimeout
前端
萌萌哒草头将军1 小时前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js
芝士加2 小时前
Playwright vs MidScene:自动化工具“双雄”谁更适合你?
前端·javascript
Carlos_sam3 小时前
OpenLayers:封装一个自定义罗盘控件
前端·javascript
前端南玖3 小时前
深入Vue3响应式:手写实现reactive与ref
前端·javascript·vue.js
wordbaby3 小时前
React Router 双重加载器机制:服务端 loader 与客户端 clientLoader 完整解析
前端·react.js
itslife4 小时前
Fiber 架构
前端·react.js
3Katrina4 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
hubber4 小时前
一次 SPA 架构下的性能优化实践
前端