Web 图片体积优化与懒加载

tao
发布于2025-08-22 | 更新于2025-08-22

我最近为某产品介绍网页图片添加了 avif 和 webp 格式支持,相较于原有 png 图片在保持清晰度基本一致的情况下,图片文件大小有了显著的减小,加载速度也因此变得更快,其中 avif 表现更好。

这中间我转换图片格式使用到的工具是由 google 开发的 squoosh,他有网页版和命令行版可供使用,网页版的地址是 https://squoosh.app,命令行版已经不再继续维护,因此需要切换低版本 node.js 才能正常使用,node.js 14.15.0 经过测试是可用的,执行 npm i -g @squoosh/cli 安装,接着就可用以下命令转换图片格式:

squoosh-cli --oxipng '{"quality": 75}' ".\assets\img\home-page\life.png"
squoosh-cli --webp '{"quality": 100}' ".\assets\img\home-page\life.png"
squoosh-cli --avif '{"quality": 100}' ".\assets\img\home-page\life.png"

使用 <picture> 标签指定图片格式优先级,保证一定的兼容性。

<picture>
  <source srcset="./assets/img/home-page/life.avif" type="image/avif" />
  <source srcset="./assets/img/home-page/life.webp" type="image/webp" />
  <img src="./assets/img/home-page/life.png" />
</picture>

现代浏览器中,在 <img> 标签中加上 loading="lazy" 属性即可实现图片的懒加载。

<picture>
  <source srcset="./assets/img/home-page/life.avif" type="image/avif" />
  <source srcset="./assets/img/home-page/life.webp" type="image/webp" />
  <img src="./assets/img/home-page/life.png" loading="lazy" />
</picture>

这种实现懒加载的方法从实现难度上来讲是很容易的,可以满足一般的需求,但是对于旧版浏览器来讲兼容性有限,对于何时加载图片这个进入视口的时机也不能完全掌控,大概是距进入视口 1000px 左右就开始加载了,不同浏览器实现不是很一致,对于背景图片 background-image 的懒加载也是完全不支持的。

以下是在 Vue 中利用自定义指令实现的图片懒加载,也就是 JS 实现懒加载的方法:

const inBrowser = typeof window !== 'undefined' && window !== null
const hasIntersectionObserver = checkIntersectionObserver()

function checkIntersectionObserver() {
  if (
    inBrowser &&
    'IntersectionObserver' in window &&
    'IntersectionObserverEntry' in window &&
    'intersectionRatio' in window.IntersectionObserverEntry.prototype
  ) {
    // Minimal polyfill for Edge 15's lack of `isIntersecting`
    // See: https://github.com/w3c/IntersectionObserver/issues/211
    if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) {
      Object.defineProperty(window.IntersectionObserverEntry.prototype, 'isIntersecting', {
        get() {
          return this.intersectionRatio > 0
        }
      })
    }
    return true
  }
  return false
}

function resolveAssetPath(path) {
  if (path.startsWith('@/')) {
    return new URL(path.replace('@/', '/src/'), import.meta.url).href
  }
  return path
}

function getResponsiveImg(path) {
  const html = document.documentElement

  if (html.classList.contains('avif')) {
    return resolveAssetPath(`${path}.avif`)
  }

  if (html.classList.contains('webp')) {
    return resolveAssetPath(`${path}.webp`)
  }

  return resolveAssetPath(`${path}.png`)
}

export default {
  mounted(el, binding) {
    const opts = (binding.value && binding.value.options) || {}

    const rootMargin = opts.rootMargin ?? '200px'
    const threshold = opts.threshold ?? 0

    const defaultLoadingImg = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='

    if (el.tagName.toLowerCase() === 'img') {
      el.setAttribute('src', defaultLoadingImg)
    } else {
      const img = el.querySelector('img')
      if (img) {
        el.style.display = 'inline-block'
        img.setAttribute('src', defaultLoadingImg)
      } else {
        if (binding.arg === 'background') {
          el.style.backgroundImage = `url(${defaultLoadingImg})`
        }
      }
    }

    const load = () => {
      if (el.tagName.toLowerCase() === 'img') {
        const src = el.dataset.src
        if (src) {
          el.setAttribute('src', resolveAssetPath(src))
        }
      } else {
        const sources = el.querySelectorAll('source')
        sources.forEach(source => {
          const srcset = source.dataset.srcset
          if (srcset) {
            source.setAttribute('srcset', resolveAssetPath(srcset))
          }
        })
        const img = el.querySelector('img')
        if (img) {
          const src = img.dataset.src
          if (src) {
            img.setAttribute('src', resolveAssetPath(src))
          }
        } else {
          if (binding.arg === 'background') {
            const bg = el.dataset.src
            if (bg) el.style.backgroundImage = `url(${resolveAssetPath(bg)})`
          }
        }
      }
    }

    if (!hasIntersectionObserver) {
      load()
      return
    }

    const observer = new IntersectionObserver(
      (entries, obs) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            load()
            obs.unobserve(entry.target)
          }
        })
      },
      { rootMargin, threshold }
    )

    observer.observe(el)

    el._lazyObserver = observer
  },
  unmounted(el) {
    if (el._lazyObserver) {
      el._lazyObserver.disconnect()
      delete el._lazyObserver
    }
  }
}

下面是使用方法:

<!-- 第一种 -->
<picture v-lazy>
  <source data-srcset="@/assets/img/home-page/life.avif" type="image/avif" />
  <source data-srcset="@/assets/img/home-page/life.webp" type="image/webp" />
  <img class="filter-animate" data-src="@/assets/img/home-page/life.png" />
</picture>

<!-- 第二种 -->
<div class="bg-head" v-lazy:background :data-src="getResponsiveImg('@/assets/img/home-page/life')"></div>

<!-- 第三种 -->
<img v-lazy :data-src="item.img" alt="" />

用 JS 实现需要更多代码支持,但也解决了 loading="lazy" 无法直接解决的问题,在实际应用中可依情况而定懒加载方案。