图片性能优化
这里谈论的图片性能优化主要是指图片的体积和解码
以常见的 PNG JPG WEBP AVIF HEIF 格式的图片举例
性能对比
上面是一张普通的 png 图片,我们使用 imagemagick 对图片进行转换
$ brew install imagemagickmagick me.png me.jpg
magick me.png me.webp
magick me.png me.avif
magick me.png me.heif得到的文件大小如下:
| 文件名 | me.png | me.jpg | me.webp | me.avif | me.heif |
|---|---|---|---|---|---|
| 文件大小 | 172K | 128K | 28K | 16K | 28K |
从上面的测试来看,avif、webp、heif 的体积性能要明显好于 png 和 jpg
并且根据一些参考的数据表明图片的解码耗时 HEIF > AVIF > WEBP
而在兼容性方面:
- https://caniuse.com/?search=heif (几乎不可用)
- https://caniuse.com/?search=avif (还算可用)
- https://caniuse.com/?search=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(); } })();