Skip to content
0

图片性能优化

这里谈论的图片性能优化主要是指图片的体积解码

以常见的 PNG JPG WEBP AVIF HEIF 格式的图片举例

性能对比

上面是一张普通的 png 图片,我们使用 imagemagick 对图片进行转换

$ brew install imagemagick
magick me.png me.jpg
magick me.png me.webp
magick me.png me.avif
magick me.png me.heif

得到的文件大小如下:

文件名me.pngme.jpgme.webpme.avifme.heif
文件大小172K128K28K16K28K

从上面的测试来看,avif、webp、heif 的体积性能要明显好于 png 和 jpg

并且根据一些参考的数据表明图片的解码耗时 HEIF > AVIF > WEBP

而在兼容性方面:

所以我们可以在页面中尽可能的使用 webp 格式或者 avif 格式。jpg 和 png 则作为兜底

<img /> 优化

原生的 img 标签不支持传入多个图片链接来给浏览器选择最合适的图片,但是我们可以使用 picture 标签来实现

<picture>
  <source srcset="me.webp" type="image/webp" />
  <source srcset="me.avif" type="image/avif" />
  <img src="me.png" />
</picture>

原生的 picture 标签需要传入多个图片资源,通过 source 标签承载,使用起来比较繁琐,通常我们会封装成一个组件来使用,例如下面的 <Picture /> 组件

封装成组件需要解决一个问题:

在项目中,我们一般只会准备其中一种格式的图片,通常是 jpg 或者 png。所以需要一个工具帮我们生成 webp 格式和 avif 格式

我们可以使用 vite-plugin-image-presets(底层基于 sharp

<script setup lang="ts">
import Picture from './Picture.vue';
import examplePic from './components/example.jpg?preset=modern';
// 经过插件的转换,得到的 examplePic 是一个数组
</script>

<template>
  <Picture :src="examplePic"></Picture>
</template>
import { defineConfig } from 'vite'
import imagePresets, { widthPreset } from 'vite-plugin-image-presets'

export default defineConfig({
  plugins: [
    imagePresets({
     modern: formatPreset({
       formats: {
         avif: {},
         webp: {},
         original: {},
       },
       loading: 'lazy',
     }),
   }),
  ],
})
<script setup lang="ts">
import { computed, onMounted, ref, isVue2 } from 'vue-demi';

export type SourceOption = {
  type: string;
  srcset: string;
};
export type ImgOption = {
  src: string;
} & SimpleImgHTMLAttributes;

// TODO: 封装 provider 来应对不同的接口
/** vite-imagetools 风格的 picture 数据格式 */
export type ImageToolsPictureOption = {
  fallback: {
    src: string;
    w?: number;
  } & SimpleImgHTMLAttributes;
  // avif: [{src: 'xxx.avif'}], webp: [{src: xx.webp}]
  sources: {
    [key: string]: {
      src: string;
      w?: number;
    }[];
  };
};


/** image-prest 风格的 picture 数据格式 */
export type ImagePresetPictureOption = [...SourceOption[], ImgOption];

export type Numberish = number | string;
// todo 以后只支持 vue3 的时候就可以换成vue提供的类型了
export interface SimpleImgHTMLAttributes {
  alt?: string;
  crossorigin?: 'anonymous' | 'use-credentials' | '';
  decoding?: 'async' | 'auto' | 'sync';
  height?: Numberish;
  sizes?: string;
  src?: string;
  srcset?: string;
  usemap?: string;
  width?: Numberish;
  type?: string;
}

export type PictureOption = ImagePresetPictureOption | ImageToolsPictureOption;

// 这里的属性其实也有点奇怪...理论上大部分只需要放在最后一个就可以了
// 其实跟生产端不太一样
interface PictureProp {
  src: PictureOption;
  // color 会展示一个渐变色块的 loading 效果,加上 fade-in 的加载成功的渐变
  placeholder?: 'empty' | 'color';
}

// https://codepedia.info/detect-browser-in-javascript
function getBrowserName() {
  if (typeof navigator === 'undefined') {
    return 'other';
  }
  const agent = navigator.userAgent.toLowerCase();
  switch (
    true // case agent.indexOf("edge") > -1: return "MS Edge";
  ) {
    // case agent.indexOf("edg/") > -1: return "Edge ( chromium based)";
    // case agent.indexOf("opr") > -1 && !!window.opr: return "Opera";
    // case agent.indexOf("chrome") > -1 && !!window.chrome!: return "Chrome";
    case agent.includes('chrome'):
      return 'Chrome';
    // case agent.indexOf("trident") > -1: return "MS IE";
    case agent.includes('firefox'):
      return 'Mozilla Firefox';
    case agent.includes('safari'):
      return 'Safari';
    default:
      return 'other';
  }
}

const props = withDefaults(defineProps<PictureProp>(), {
  placeholder: 'empty',
});
function isNotNil<T>(x: T): x is NonNullable<T> {
  return x != null;
}

function assertNotNil<T>(v: T, message?: string): asserts v is NonNullable<T> {
  if (!isNotNil(v)) {
    throw new Error(message ?? 'Must not be null or undefined');
  }
}
const allSources = computed(() => props.src);
const sources = computed<{srcset?: string; type?: string;}[]>(() =>
  'fallback' in allSources.value
    ? Object.entries(allSources.value.sources ?? {}).map(([key, val]) => {
      return {
        type: `image/${key}`,
        srcset: val[0]?.src,
      }
    })
    : allSources.value.slice(0, -1),
);
const lastSource = computed(() => {
  const res =
    'fallback' in allSources.value
      ? allSources.value.fallback
      : allSources.value[allSources.value.length - 1];
  assertNotNil(res);
  return res as ImgOption;
});

const bgColors = ['#A7D2CB', '#874C62', '#C98474', '#F2D388'];
const lightenColors = ['#dcedea', '#d4b2bf', '#e9cec7', '#faedcf'];

const bgIndex = Math.floor(Math.random() * bgColors.length);
const bgColor = bgColors[bgIndex];
const bgColorLight = lightenColors[bgIndex];
const loaded = ref(false);

const safariSrc = ref();
const isSafari = getBrowserName() === 'Safari';
// 测试过url变化时加载的图片符合预期
onMounted(() => {
  safariSrc.value = lastSource.value.src;
});

const emit = defineEmits<{
  (event: 'load', ev: Event): void;
}>();

function handleLoad(ev: Event) {
  emit('load', ev);
  loaded.value = true;
}
</script>

<script lang="ts">
export default {
  inheritAttrs: false,
};
</script>

<template>
  <picture
    class="image-container"
    :class="{ loaded: loaded, 'placeholder-player': placeholder === 'color' }"
  >
    <source v-for="(attrs, index) in sources" :key="index" v-bind="attrs" />
    <img
      v-bind="{ ...lastSource, ...$attrs }"
      :src="isSafari ? safariSrc : lastSource.src"
      :srcset="isSafari ? safariSrc : lastSource.src"
      v-on="
        // @ts-ignore
        isVue2 ? $listeners : {}
      "
      @load="handleLoad"
    />
  </picture>
</template>

<style scoped>
.placeholder-player {
  animation: placeholder ease-in-out 2s infinite;
}
.image-container img {
  width: 100%;
}
@keyframes placeholder {
  0% {
    background-color: v-bind(bgColor);
  }
  50% {
    background-color: v-bind(bgColorLight);
  }
  100% {
    background-color: v-bind(bgColor);
  }
}
@keyframes fadeIn {
  0% {
    opacity: 0%;
  }
  100% {
    opacity: 100%;
  }
}
.placeholder-player.loaded {
  animation: none;
}
.placeholder-player.loaded img {
  animation: fadeIn linear 0.5s;
}
</style>

在编码阶段:

图片通过 import xx from 'xx.png?preset=modern 的形式引入,作为 <Picture /> 组件的 src 参数

在构建阶段:

自动转换生成 webp 和 avif 格式的图片

在渲染阶段:

浏览器自动处理原生 picture 标签,选择最合适的图片资源加载

background-image 优化

图片的另一种使用方式是作为背景,即使用 CSS 的 background-image 属性来设置图片。

这种方式下,也有对应的方法让浏览器使用最合适的图片,目前主流浏览器都支持了,但距正式投入使用还需要等待一段时间。

查看:https://caniuse.com/?search=image-set

.demo {
  background-image: url("image1.jpg"),
  background-image: image-set(
    url("image1.avif") type("image/avif"),
    url("image2.webp") type("image/webp"),
  );
}

所以目前阶段,如果想要实现 background-image 的图片优化,最后自己实现一套。

首先,我们准备好多种图片的格式,具体写法如下(如果不想每张图片的扩展格式都自己写,当然也可以通过插件或者是 CSS 预处理器去自动生成)

.image {
  background-image: url('./image.png')
}
.webp .image {
  background-image: url('./image.png.webp')
}
.avif .image {
  background-image: url('./image.png.avif')
}

然后思路就是,判断浏览器支持的图片格式,然后添加上对应的 class 类名。那么什么时候应该添加这 avif | webp 类名?

通常有以下几种方式:

直接让浏览器加载 webp 图片和 avif 图片,监听加载事件,如果成功了,就代表支持,然后我们在 <html> 标签上添加对应的 class 类名。

TIP

同时可以将这些信息注入 Cookie 中,提供 JS 判断,做其他的事情

核心代码:

function checkImageFormat(format) {
    const formats = {
        webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
        avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A='
    };

    return new Promise((resolve, reject) => {
        if (formats[format]) {
            const image = new Image();
            image.onload = function () {
                resolve(true);
            };
            image.onerror = function () {
                resolve(false);
            };
            image.src = formats[format];
        } else {
            reject(new Error(`${format} format is not supported`));
        }
    });
}
完整代码
(function () {
    'use strict';

    function setCookie(cookieName, cookieValue, daysToExpire) {
        document.documentElement.classList.add(cookieName);
        
        (function (name, value, days) {
            let expires = '';
            if (days) {
                const date = new Date();
                date.setTime(date.getTime() + 24 * days * 60 * 60 * 1000);
                expires = `; expires=${date.toUTCString()}`;
            }
            document.cookie = `${name}=${value || ''}${expires}; path=/`;
        })('support_'.concat(cookieName), 'true', daysToExpire);
    }

    function checkImageFormat(format) {
        const formats = {
            webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
            avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A='
        };

        return new Promise((resolve, reject) => {
            if (formats[format]) {
                const image = new Image();
                image.onload = function () {
                    resolve(true);
                };
                image.onerror = function () {
                    resolve(false);
                };
                image.src = formats[format];
            } else {
                reject(new Error(`${format} format is not supported`));
            }
        });
    }

    function markDetectionReady() {
        document.documentElement.classList.add('detect-ready');
        performance.mark('detect_end');
    }

    try {
        performance.mark('detect_start');

        Promise.race([
            Promise.all([
                checkImageFormat('webp').then((supported) => {
                    if (supported) {
                        console.log('浏览器支持 WebP 格式');
                        setCookie('webp');
                    } else {
                        console.log('浏览器不支持 WebP 格式');
                    }
                }).catch((error) => {
                    console.error(error);
                }),
                checkImageFormat('avif').then((supported) => {
                    if (supported) {
                        console.log('浏览器支持 AVIF 格式');
                        setCookie('avif');
                    } else {
                        console.log('浏览器不支持 AVIF 格式');
                    }
                }).catch((error) => {
                    console.error(error);
                })
            ]),
            new Promise((resolve, reject) => {
                setTimeout(() => {
                    reject(new Error('Timeout'));
                }, 1000);
            })
        ]).then(() => {
            markDetectionReady();
        }).catch(() => {
            markDetectionReady();
        });
    } catch (error) {
        markDetectionReady();
    }
})();

压缩版本:

(function() { "use strict"; function A(A2) { document.documentElement.classList.add(A2), function(A3, e2, n2) { var t = ""; if (n2) { var o = /* @__PURE__ */ new Date(); o.setTime(o.getTime() + 24 * n2 * 60 * 60 * 60 * 1e3), t = "; expires=" + o.toUTCString(); } document.cookie = A3 + "=" + (e2 || "") + t + "; path=/"; }("support_".concat(A2), "true", 365); } function e(A2) { var e2 = { webp: "data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", avif: "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=" }; return new Promise(function(n2, t) { if (e2[A2]) { var o = new Image(); o.onload = function() { n2(true); }, o.onerror = function() { n2(false); }, o.src = e2[A2]; } else t(new Error(A2 + " format is not supported")); }); } function n() { document.documentElement.classList.add("detect-ready"), performance.mark("detect_end"); } try { performance.mark("detect_start"), Promise.race([Promise.all([e("webp").then(function(e2) { e2 ? (console.log("\u6D4F\u89C8\u5668\u652F\u6301 WebP \u683C\u5F0F"), A("webp")) : console.log("\u6D4F\u89C8\u5668\u4E0D\u652F\u6301 WebP \u683C\u5F0F"); }).catch(function(A2) { return console.error(A2); }), e("avif").then(function(e2) { e2 ? (console.log("\u6D4F\u89C8\u5668\u652F\u6301 AVIF \u683C\u5F0F"), A("avif")) : console.log("\u6D4F\u89C8\u5668\u4E0D\u652F\u6301 AVIF \u683C\u5F0F"); }).catch(function(A2) { return console.error(A2); })]), new Promise(function(A2, e2) { return setTimeout(function() { return e2(new Error("Timeout")); }, 1e3); })]).then(function() { n(); }).catch(function() { n(); }); } catch (t) { n(); } })();

Released under the MIT License.