图片相关性能优化笔记

前端图片展示的性能优化

图片资源在web页面中属于非常常见的类型,其使用的方式也相对多样化,你可能在不同的场景下遇到性能问题,其解决的方案也有可能不相同。

图片在页面中使用场景可能有以下这些

  • 图片列表,对于一些图片类网站,如百度搜索图片的图片列表,其可能有上千张上万张图片
  • 单纯的img展示,不像图片列表有那么多,其可能是在页面中某一个位置做商品展示,或者文章插图等
  • 大背景,通过css的background设置背景图片
  • 页面中的icon,一些icon有时候可能用图片实现。有可能是直接使用svg直接嵌入,也有可能是css background展示
  • 页面中特殊形状,有一些效果需要用到图片来做呈现,如loading效果
  • 鼠标样式,有些鼠标的样式是可以用图片定制化效果的
  • 3D场景中的贴图
  • 网站logo,用于在tab标签上展示

当然还有很多种,以上更多的是从技术角度的陈列,如果从业务角度来列举其会更多,ChatGPT就列举了不下20种。

那就以上的这些场景会遇到哪些性能问题?

图片的性能问题直观的呈现就是图片迟迟加载不出来。没有及时加载出来会让用户不能及时的体验完整的页面,也有可能导致页面变形。迟迟加载不出来我归结为两个方向

  • 请求太多,导致图片请求被迫等待
  • 图片资源太大,导致请求耗时

接下来来就按照这两个大方向进行阐述不同的优化方案

请求太多

请求太多造成慢主要是因为浏览器存在限制,对同一个源的请求的数在同一时间不会超过某一个数量限制,如果某一堆请求相对耗时,又刚好和图片是在同一个源,那可能就会出现阻塞的情况。这种情况有一些通用的解决方案

  • 将资源放到不同的源上,可以做一个分类并放置到不同的源上托管
  • 开启http2,多路复用

但针对图片还有其他场景对应的优化方式

图片懒加载

如果页面上的图片资源本身优先级不高,或者有一些图片暂时不会在可视区域内出现。这个时候就可以考虑将图片的加载适当的处理延后去加载。从而让页面解析过程中的请求变少从而达到页面整体快速展示的优化效果

什么是图片懒加载

在浏览器的工作模式中,如果在解析html的时候,如果遇到了img标签且设置了src,浏览器会立即开始加载该图片资源,如果一次性将图片全部加载回来,会影响到页面首次加载的时间。所以我们需要通过一些手段来优化,避免首次加载的时候产生不必要的资源加载。将加载图片的时间延后,在需要加载的时候再加载,即为图片的懒加载。

如何实现

1、可以依赖页面或者父元素的滚动事件,滚动到图片的位置时在开始加载。

因为img标签如果设置了src就会加载,所以我们将img标签中的src属性先置空或者给一个默认的地址。将真实的地址放到img标签或者div标签中的data-src 属性中,等监听滚动到图片位置时在将data-src的值填写到对应的img标签的src属性中

javascript 复制代码
function lazyLoad() {
    const lazyImages = document.querySelectorAll('.lazyload');
    
    lazyImages.forEach((img) => {
        const imgTop = img.getBoundingClientRect().top;
        const windowHeight = window.innerHeight;
        if (imgTop < windowHeight) {
            img.src = img.dataset.src;
            img.classList.remove('lazyload');
        }
    });
}

window.addEventListener('scroll', lazyLoad);

2、如果不依赖滚动事件,也可以使用IntersectionObserver,用于监听指定元素和父级是否重叠

javascript 复制代码
const lazyImages = document.querySelectorAll('.lazyload');

const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            img.classList.remove('lazyload');
            observer.unobserve(img);
        }
    });
}, { threshold: 0.5 });

lazyImages.forEach((img) => {
    observer.observe(img);
});

3、img标签本身就已经支持了懒加载的配置

html 复制代码
<img src="image.png" loading="lazy" />

合并图片

一些页面中的小图片有时候是可以适当的进行合并之后再使用的,这样在加载他们的时候就不用每一个都去发起一次请求了。

但是这可能带来一些使用上的难度,所以需要一一讲一下

img标签中使用

以一个icon组件为例,你需要让其能在用img标签承载,那么就需要通过style去控制img展示的区域

1、object-fit + object.position

tsx 复制代码
interface Props {
    iconType: IconType
}

const COLUMN_COUNT = 7;
const ROW_COUNT = 3;
const TOTAL_WIDTH = 702;
const TOTAL_HEIGHT = 296;
const width = TOTAL_WIDTH / 7;
const height = TOTAL_HEIGHT / 3;
function getIconPosition(props: Props) {
    const sequenceNumber = Number(props.iconType);
    const row = Math.floor(sequenceNumber / COLUMN_COUNT);
    const column = sequenceNumber % COLUMN_COUNT;
    const top = Math.floor(row / ROW_COUNT * TOTAL_HEIGHT);
    const left = Math.floor(column / COLUMN_COUNT * TOTAL_WIDTH);
    return { top, left };
}

export function ImageIcon(props: Props) {
    const iconStyle = useMemo(() => {
        const { top, left } = getIconPosition(props);
        return {
            objectFit: 'none',
            objectPosition: `top -${top}px left -${left}px`,
            height: '100%',
            width: '100%',
        }
    }, [props.iconType]);

    return (<div className={styles.imgWrapper} style={{height, width}}>
        <img src={iconImg} alt="icon" style={iconStyle}/>
    </div>
    );

}

说明:

  • 通过object-fit:none 来指明不需要变形来适应外框大小
  • object-position 通过指定left和top值进行偏移设置,所以你需要知道雪碧图原始的大小才能正确计算
  • 偏移的位置是通过图标的序列号计算的
  • 包裹的div元素需要设置overflow:hidden

2、也可以使用position直接实现

tsx 复制代码
export function PositionIcon(props: Props) {
    const clipPathStyle = useMemo(() => {
        const {left, top} = getIconPosition(props);
        return {
            width: 'fit-content',
            height: 'fit-content',
            position: 'absolute',
            top: -top,
            left: -left,
            objectFit: 'none'
        }
    }, [props.iconType])
    return <div className={styles.imgWrapper} style={{height, width, position: 'relative'}}>
    <img src={iconImg} style={clipPathStyle} alt="image-icon" />
    </div>
}

background 中使用

css的background同样可以做到裁剪的效果

tsx 复制代码
export function BackgroundIcon(props: Props) {
    const backgroundStyle = useMemo(() => {
        const { top, left } = getIconPosition(props);
        return {
            height,
            width,
            background: `url(${iconImg})`,
            backgroundPosition: `top -${top}px left -${left}px`,
        }
    }, [props.iconType]);
    return <div className={styles.imgWrapper} style={backgroundStyle}> </div>
}

说明

  • 原理和使用object-position是一致的。但是用的background+background-position

替换掉图片资源

事实上页面中的图片资源有些是可以不通过去远程加载就可以实现的,一般有以下几种方案

  • css 制作形状替换
  • 图片直接以base64的方式设置src
  • inline svg

css制作特殊形状

一些特殊形状其实完全是可以用css去实现的,随着css属性不断的丰富,你可以有不同的选择去实现不同的形状

  • 使用border去实现圆形,梯形,三角形
  • 使用clip-path去实现其他特殊形状

当然不止这些,配合css其他属性,如transform,box-shadow,background-color等,你可以实现各种各样的形状。

而且也有现成的css图标库可以使用,如css.gg。可以直接查看每一个图标对应的css

base64替换src

图片的加载如果不想浏览器单独的去请求一次,可以考虑在img标签中直接嵌入base64格式。

一般src指定的值是一个常规的url,浏览器解析到这个地方就会尝试发起请求,但是src同样支持直接给源数据的方式,即base64格式的字符串,浏览器会读取源数据并解析成特定类型的文件供img标签展示。

示例

tsx 复制代码
export function Base64Icon() {
    return <div>
        <img src={'data:image/png;base64,' + getBase64()} alt="smallIcon"/>
    </div>
}

function getBase64() {
    return `
    iVBORw0KGgoAAAANSUhEUgAAACMAAAAQCAYAAACcN8ZaAAAB6klEQVR4Xu2UzUsb
    QRjG/S8qpbW1lXoT8dCDSCvtzdazCAr+GQ3Wr4NtKaj1YKsHv/8CDxJNtIeC+fCW
    RAPeLC1qQF3RfWc3ye4+vrOLGJesbsKCFx94GJiZ99kf78xODSqQeZAFjTZBHXoB
    dbiB/RLqyCtos90oxuZh/k/BUo+v9x/tOWvZSEmKt2rcE7fJyEah9j9x/Pkp1IFn
    DFbveKCOwRpBE+3QFnqQX/uKYmYV5r8UUNDcUWXlCZNJp5GIx7GdTELTnDDzeB+F
    P9PQV/qhLfdB/PwI+v7a6dLgcxvwIlSLi0+PnJFNk+8ZhqDpup0lM3cyGdfXHHnC
    xGMxrIXDiKyvg4jcy7asPME6O+Dj24GxG2bQGQYNQVti0KkO0Jdm7tQbbqluZ8gs
    mSmByskTRhbI4o1o9BrGLJbYYBrzZtGVeM0iBdbJX5iHWZ6wQCrZWTIzmUi4K2z5
    gxGC8wyIuS7QeBvox9s73M7H8w6CR7HYCxuGbocpGkZlMDTWypf3sXNZ/Zj3Sng/
    MLlcrjIY8esD34Mm0LcWf+a9YroTfmCkfMEICSOVVwH9vDLLGpYICkZRFOj8a+r5
    QnXmWuX0NBiY35ubgbhqmNJ3JkhX9c6kUynEtrbswiAtM9P8upeTJ8x96AHGS5ex
    BvEXmdFyqAAAAABJRU5ErkJggg==
    `
}

浏览器如何解析

当在遇到data类型的url的时候,浏览器是去直接读取数据并解析。可以通过上面的例子可以看到,我们给定的src值除了base64的字符串,还有很多标志信息。其整体的格式如下

txt 复制代码
data:[MIME type];[encode format],[content]
  • data 标识当前url是一个data类型的url
  • MIME type是指浏览器能识别的内容格式,这个一般在http的content-type请求头中作为参数
  • encode format 指定内容的编码方式,上面的例子就是指定的base64编码。

配合打包工具落地

如果你选择将部分图片文件data url的方式直接嵌入避免额外请求,当前的打包工具中已经有很好的支持,即你可以在打包阶段,直接将图片资源转成base64字符串,放置在代码中。

例如webpack5中就有相关的配置可以完成

javascript 复制代码
export default {
    input: 'index.js',
    output: {
        filename: '[name][contenthash:8].js',
        path: import.meta.dirname + '/dist',
    },
    modules: {
        rules: [
            {
                test: /\.(png)|(jpg)/,
                type: 'asset',
                parser: {
                    dataUrlCondition: {
                        maxSize: 10240, // 在10KB以内的图片文件都会使用dataURL的方式打包
                    }
                },
                generator: {
                    filename: 'asserts/[name][hash:8].[ext]'
                }
            }
        ]
    }
}

注意:base64的格式最终会比源文件大,所以一定要在必要的情况下才使用data url的方式。设置最大文件大小就是一个很好的实践

Inline svg

如果使用的是svg图片,完全可以将svg直接嵌入到dom文档流中,以减少http请求。svg本身是xml格式的,浏览器在解析的过程中是能理解

示例:

那如果你有一个svg文件,如何将其嵌入到dom文档中呢。以react+webpack为例

以innerHtml的方式直接嵌入其中

typescript 复制代码
import arrowContent from '@asserts/downArrow.svg';

export function InlineSvgIcon() {
    return <span style={{ height: '20px', width: '20px', display: 'inline-block' }}
    dangerouslySetInnerHTML={{ __html: arrowContent }}>
    </span>
}

以上代码的前提是import能从文件直接读取svg的文本内容,所以你还需要配置webpack能将svg文件读取为字符串,这里用到的是svg-inline-loader

javascript 复制代码
export default {
    input: 'index.js',
    output: {
        filename: '[name][contenthash:8].js',
        path: import.meta.dirname + '/dist',
    },
    modules: {
        rules: [
            {
                test: /\.svg$/,
                use: 'svg-inline-loader'
            }
        ]
    }
}

注意:inline svg的方式同样存在会使打包的体积变大,所以需要权衡使用。但是其一般来讲比base64数据小,所以如果你的svg图片需要做inline操作,直接通过innerHtml的方式插入会比使用data uri的方式更佳

资源太大

如果一个图片本身是很大的,那遇到性能问题几率就更高。这个方向的优化也是分场景的,简单的处理自然是对图片进行压缩,但是有可能该网站就是需要这么大体积以展示清晰度非常高的图片

大致的优化策略有如下几种

  • 使用合适的图片类型。每种图片的存储方式不一致,同样的图片不同格式体积可能就不一样
  • 不同设备使用不同尺寸的图片
  • 先加载模糊图,慢慢完善原始图

使用合适图片类型

不同的图片类型其编码格式,压缩类型等都一些差异,其呈现的内容可能是一样的,但是其文件大小可能会不一样。

也正是因为其压缩方式等不一致,所以其适用场景也会有所差异,如果较小体积的文件已经满足了场景需要,但是却用了其他高保真的大文件,那必然带来不必要的性能损耗。所以选择合适类型的图片是你优先应该考虑的方向

类型基础

要区分不同类型的图片,首先要有一些大致基础概念。

我们的屏幕能展示不同的文本和图像,因为它知道在屏幕中具体哪一个像素点展示什么颜色,一个个带颜色的像素点集中在一起,那就呈现出文本或者图像。所以理论上一个文件只要记录所有像素点的位置和颜色信息,那么就完全可以将这个文件最终渲染到页面上。

而包含单个像素的颜色位置等信息的数据我们称其为'bitMap'点位图。那只要我记录下点位图数据,那么就可以把这个数据发给其他终端,渲染出相同的图像。

但是要传输的话不可能直接将位图数据传输,因为体积太大,体积过大传输过程中就有可能出现更大的丢包风险

那怎么样能高效的记录所有位图数据,并且能通过这些记录在终端又准确的解析成位图数据供渲染使用?所以就出现了各种压缩方式,也就出现了不同的文件格式。不同的压缩算法对于不同的压缩比,所以文件大小也存在差异。压缩的数据最终在终端会被解压出来,解压的结果可能完全符合原始图片的样子,也有可能存在部分失真,所以压缩也存在有损和无损的区别,有损压缩因为丢弃一部分数据,所以文件体积会更小一些。

除了存在压缩方式的不同,每种格式图片对图片颜色的描述方式也有所不同。一般使用直接色索引色两种描述色彩的方式记录。直接色是指通过rgba的方式直接记录点位的颜色,可以表示2的32次方种颜色。与其对比的是索引色,其提供固定数量颜色,记录的时候记录索引即可,通常有256种颜色。通常使用索引色的描述会使文件更小

以下是对不同文件格式使用的压缩方式和色彩描述简要总结

1、jpg

采用有损压缩的方式,颜色信息采用直接色记录。所以其特点是原始图像的部分信息会选择性丢失,但是色彩会更丰富一些。

2、png

采用无损压缩,记录颜色信息 png8使用索引色、png24采用直接色。当出现无损+直接色的时候那就是高保真,文件体积也最大。

3、Webp

同时支持有损和无损压缩的、使用直接色的。但是其有损压缩和无损压缩的算法都jpg和png的优秀,所以文件相对较小。

4、GIF

无损压缩,采用索引色,但是其索引色较少。可以用来展示一些动图效果

5、svg

svg和其他不同,其不是对bitMap数据进行压缩,其记录的是图片中的的路径,颜色等信息。具体在设备上怎么展示是通过实时计算得到的,所以才会有你缩放svg图片不存在变形的效果。所以svg本身体积就会相对小一些,但是展示时计算的成本往往比bitMap的要高

类型选择

有了以上的基础,应该对选择都已经有了选择的理论依据

  • 高保真场景,应该选择支持无损压缩+直接色的文件格式,那就应该是png24,webp,但webp文件更小,所以尽量选择webp
  • 非高保真,选择有损压缩 or 索引色 jpg或webp都可,webp更佳
  • 缩放交互常见,svg

更详细的解读请参考文章:图片格式那么多,哪种更适合你?

使用正确尺寸的图片

对于非高保真的图片使用场景,图片的展示往往就是一个示意。而在不同大小的设备上,这个示意有时候我们需要用大图去展示,也有可能一个小图就解决了。

设想一下你有一个纯展示的图片,width设置为100%。原始图可能是1024 * 800的大小。但是用户在一个720 * 480 的设备上查看。拿回来的图是尺寸超过了设备,所以在最终展示的时候实质上还做了缩放处理。其实使用小号的图就能满足需求了,况且还做了缩放处理。所以这是不必要的损耗

我们现在讨论的就是如何处理这种问题,使得每个设备按照特定的规则去加载适合自己的图片,从而减少不必要的带宽损耗带来的性能影响。

picture元素

picture元素内部可以定义媒体查询相关的语句,定义不同的source,最终选择符合限制的source来作为img标签的的src属性来展示图片。

以下是示例

typescript 复制代码
export function CardMediaIcon() {
    return <picture>
        <source media="(max-width: 600px)" srcSet="small.png" />
        <source media="(max-width: 1000px)" srcSet="medium.png" />
        <source media="(min-width: 1200px)" srcSet="big.png" />
        <img src="medium.png" alt="card icon"/>
    </picture>
}

css媒体查询

当你的图片是以背景图的方式呈现的,即使用css的background属性定义的,那么你可以通过媒体查询的方式在不同设备上使用不同尺寸的图片。

css 复制代码
@media screen and (min-width: 600px) and (max-width: 800px) {
    .img-bg {
        background: url('medium.png')
    }
}

@media screen and (min-width: 800px) {
    .img-bg {
        background: url('big.png')
    }
}

渐进式加载高保真大图

如果你的图片应用场景就是需要高保真的展示,那么不能一股脑的在压缩图片上下功夫,压缩的图片显然不能满足需求。也就是说你的大图文件不得不加载。

当然这种情况也有一些通用的解决方案

  • 使用CDN承载图片
  • 使用loading提示告知用户正在加载
  • 传输过程的压缩,例如开启gzip,使得传输内容尽可能的小

当然你还可以在通用方案的基础上进行其他优化,渐进式图片展示

渐进式展示图片

你可能在很多网站上都看到过类似的效果,加载一个图片,先看到一张模糊的轮廓图片,然后再展示,或者它会从上到下一点一点的展示。实质上这种类型的图片就是渐进式图片

一方面其自身的编码格式让这种效果成为可能,另一方面是浏览器自身对这种格式的支持,使其可以拿到部分数据就可以进行展示。浏览器展示一个渐进式图片的流程

  • 发起请求,并准备开始收集数据
  • 第一部分数据到达,通过报文信息识别到是渐进式图片,开始扫描数据并展示第一部分数据,用户可以看到局部或者整体轮廓模糊的版本
  • 后续数据陆续到达,浏览器持续扫描到达的数据,图像慢慢变清晰
  • 数据接收完毕,扫描接收,显示完整图片

以下演示一张高清图加载的过程对比。如果将网速调慢过程会更佳直观

模糊完整轮廓

完整图像

渐进式图片的编码

渐进式图片的格式可以是png,gif,jpeg,可以选择性的让编码过程按照渐进式编码的方式。

1、交错编码的渐进式图片

对于png和gif的渐进式编码相对好理解一些。一个非渐进式的图片文件,他记录图片的方式,是对图片对应的点阵图从上到下,从左到右的记录。

那如果将文件拆分成多块,让文件记录的顺序是依次记录所有区块的一部分信息,那当我在按顺序读取文件的时候,就能构建全部区块的部分信息了,一直读取每个区块的内容就更清晰。

这种编码的方式就被称为交错编码,会将图片拆分成多个区块,然后每扫描一次记录所有区块的部分信息。

参考下图形象的理解

2、jpeg低频高频拆解

jpeg的编码方式相对复杂,其记录点阵的顺序也不是从上到下、从左到右。其将图片拆分成若干个8 * 8像素区块,每个区块内记录的顺序是按z子形排列,颜色的编码再是使用rgb,而是采用YCbCr的编码方式。

渐进式jpeg是在基础的jpeg编码上做了优化,其将每个区块中的像素分为高频,和低频,扫描的时候先扫描低频部分,然后再扫描高频部分。

当浏览器拿到jepg文件的开头部分数据时,其拿到的就是所有区块中低频部分,而低频部分就可以完全构建整张图的轮廓。然后继续拿高频部分数据,来补充图像的细节,最终完整的展示

构建你的渐进式大图

在真实的项目中落地,最简单的办法当然就是告诉你的图片提供方你需要一个渐进式大图,设计师在保存图片的时候选择相应的格式给到你就可以了。

但如果你只有其他类型的图片,那么就可能需要自己去转了

1、直接使用cli

shell 复制代码
npx sharp-cli -i ./img.jpg -o ./img.jpeg -p

当然也存在其他方式,你可以通过工具比如PS重新保存一次存成指定格式,也可以用代码实现一套转换方式。

更多方式请参考:渐进式加载

为什么不通过打包工具做?

使用打包工具做,意味则你的图片资源可能需要在代码库一并存在,例如放到public文件夹下。这不是一种好的实践,大文件应该优先放到CDN或者其他远程静态资源托管平台,放在代码库中会让拉取代码的时间变长,你的流水线时间可能也跟着变长。

总结

1、图片相关的性能优化大致分为两个方向去思考:请求过多,资源太大

2、而针对这两个方向的优化都有一些通用的解决方案,通用方案不仅仅可以用在图片资源上也适用于其他资源,这些优化方式分别有

  • 是用cdn加速
  • 开启http2
  • 使用不同的源托管资源
  • 开启传输过程的压缩,如开启gizp压缩

以上是通用方案所以没有细讲具体落地方案

3、而针对图片的优化

请求太多类优化包含如下几种方式

  • 图片懒加载。
  • 合并图片
  • inline 图片,包括base64方式去inline,也包括svg资源的inline

资源太大类优化方式

  • 选择适合的类型
  • 按设备加载不同尺寸
  • 渐进式图片加载

在文中也给了很多基础知识的介绍,这些基础能更清楚这些优化方案背后的基本逻辑。

参考链接:

相关推荐
腾讯TNTWeb前端团队2 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰5 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪5 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪6 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy6 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom7 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom7 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom7 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom7 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom7 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试