无限加载的商品瀑布流是电商最场景的最常用、最重要的组件,因此有一个高性能的瀑布流组件就变得无比重要
使用 Grid 布局,赢在起跑线
Grid 布局实现响应式非常简单,纯 CSS 实现,不依赖 JavaScript,性能优化赢在起点上
1<style>
2 .container {
3 display: grid;
4 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
5 gap: 12px;
6 padding: 12px;
7 }
8</style>
9
10<div class="container">
11 <div class="card">
12 <img src="https://via.placeholder.com/300" alt="商品1">
13 <h2>商品名称 1</h2>
14 <p>商品描述 1</p>
15 </div>
16 <div class="card">
17 <img src="https://via.placeholder.com/300" alt="商品...">
18 <h2>商品名称 ...</h2>
19 <p>商品描述 ...</p>
20 </div>
21
22 <div class="card">
23 <img src="https://via.placeholder.com/300" alt="商品6">
24 <h2>商品名称 6</h2>
25 <p>商品描述 6</p>
26 </div>
27</div>
使用 IntersectionObserver 实现懒加载
传统的懒加载通过监听 scroll 事件判断目标元素是否接近/出现在可视区,发起异步请求、加载更多的卡片。而现代浏览器可以使用浏览器原生的 IntersectionObserver API 监测目标元素是否出现在 Viewport
使用 IntersectionObserver 可以异步执行回调函数,无需频繁监听滚动事件,这样可以显著降低 CPU 和内存使用,从而提升性能
1const target = document.getElementById('targetElement');
2
3
4const observer = new IntersectionObserver((entries) => {
5 entries.forEach(entry => {
6 if (entry.isIntersecting) {
7
8 target.style.backgroundColor = 'lightgreen';
9 console.log('元素已进入 Viewport!');
10 } else {
11
12 target.style.backgroundColor = 'lightblue';
13 console.log('元素已离开 Viewport!');
14 }
15 });
16});
17
18
19observer.observe(target);
因此我们瀑布流的代码可以稍加改进
1import React, { useEffect, useRef, useState } from 'react';
2
3const Waterfall = () => {
4 const [items, setItems] = useState([]);
5 const [loading, setLoading] = useState(false);
6 const observerRef = useRef();
7
8 const fetchItems = async () => {
9 setLoading(true);
10
11 const newItems = Array.from({ length: 10 }, (_, index) => ({
12 src: 'https://via.placeholder.com/300x300',
13 }));
14
15 setTimeout(() => {
16 setItems(prevItems => [...prevItems, ...newItems]);
17 setLoading(false);
18 }, 1000);
19 };
20
21 useEffect(() => {
22 const loadMore = (entries) => {
23 if (entries[0].isIntersecting) {
24 fetchItems();
25 observerRef.current.disconnect();
26 }
27 };
28
29 const observer = new IntersectionObserver(loadMore, {
30 rootMargin: '1000px',
31 });
32
33 const target = document.querySelector('#load-more');
34 if (target) {
35 observer.observe(target);
36 }
37
38 return () => {
39 observer.disconnect();
40 };
41 }, [items]);
42
43
44 useEffect(() => {
45 fetchItems();
46 }, []);
47
48 return (
49 <div className="waterfall-container">
50 {items.map(item => (
51 <div className="item" key={item.id}>
52 <img src={item.src} alt={`Loaded item ${item.id}`} />
53 </div>
54 ))}
55 {loading && <div>加载中...</div>}
56 <div id="load-more" style={{ height: '20px', marginBottom: '20px' }} />
57 </div>
58 );
59};
60
61export default Waterfall;
原生的图片懒加载 loading=“lazy”
我们虽然已经通过 IntersectionObserver 对组件做了初步的懒加载,但还可以更进一步对 Viewport 的图片也做懒加载,传统也是通过监听 scroll 事件实现,现在大部分主流浏览器通过load="lazy"
原生支持了图片懒加载,简单又高效
1<img src="image-to-lazy-load.jpg" loading="lazy">
当对图片设置了这个属性后,浏览器会根据自己的启发式算法决定图片的加载时机。这些算法会考虑多个因素,比如图片即将进入视口的距离,或者用户当前的网络条件等。通常启发式算法的工作方式如下:
- 视口接近度:浏览器会监测页面滚动,检查懒加载图片距离视口的距离。当图片快要出现在视口内时,浏览器会开始加载图片。具体开始加载图片的距离阈值并没有统一的标准,不同的浏览器可能会有不同的实现。
- 网络状况:一些浏览器可能会根据用户的网络状况(例如是否使用数据流量或者Wi-Fi)来决定是否提前加载图片。
- CPU和内存使用情况:如果用户设备的CPU或内存使用率很高,浏览器可能会延迟加载图片,直到资源使用减少。
- 电池状态:对于移动设备,浏览器可能会在电池电量充足时更积极地加载资源。
虽然开发者无法精准控制图片加载的时机,但浏览器原生支持考虑的因素不仅仅是滚动位置,相对而言更加合理。顺便说一句,使用 JavaScript 懒加载本身也有性能开销,可能会影响到页面的 FPS
非首屏图片异步解码
解码图像和视频是计算密集型的操作,可能会占用大量的CPU资源,特别是对于高分辨率或者复杂编码格式的媒体文件,如果主线程被图像或视频的解码操作阻塞,用户在滚动页面或尝试交互时可能会感受到卡顿或延迟
对非首屏图片或视频添加 decoding=“async” 可以允许浏览器在后台处理图片、视频解码,而不阻塞主线程,继续处理和渲染页面的其余部分,这样可以有助于改善页面的加载性能,减少用户感知到的延迟,并提供更加平滑的用户体验
1<img src="image.jpg" decoding="async">
使用 useTransition 保证滚动丝滑
当用户滚动出发瀑布流不断加载时候 React 需要反复渲染商品卡片,这样的长时间计算可能会用户感到浏览器延迟或卡顿
React 18 引入了并发模式(Concurrent Mode)让开发者可以将某些状态更新标记为可中断的,从而允许 React 在必要时推迟这些更新,优先处理其它更为紧急的任务,使用useTransition
即可让开发者可以非阻塞的方式渲染 UI
1import React, { useState, useTransition } from 'react';
2
3const ExampleComponent = () => {
4 const [items, setItems] = useState([]);
5 const [isPending, startTransition] = useTransition();
6
7 const addItem = () => {
8 startTransition(() => {
9
10 setItems(prevItems => [...prevItems, `Item ${prevItems.length + 1}`]);
11 });
12 };
13
14 return (
15 <div>
16 <button onClick={addItem} disabled={isPending}>
17 {isPending ? 'Adding...' : 'Add Item'}
18 </button>
19 <ul>
20 {items.map((item, index) => (
21 <li key={index}>{item}</li>
22 ))}
23 </ul>
24 </div>
25 );
26};
由于瀑布流滚动加载的商品属于预加载,我们可以利用 useTransition 在必要时候推迟 React 渲染,防止用户浏览器卡顿
1
2import React, { useEffect, useRef, useState, useTransition } from 'react';
3import './App.css';
4
5
6const fetchItems = (count) => {
7 return new Promise((resolve) => {
8 setTimeout(() => {
9 resolve(
10 Array.from({ length: count }, (_, index) => {
11 const height = Math.floor(Math.random() * (300 - 100 + 1)) + 100;
12 return {
13 height,
14 src: `https://via.placeholder.com/200x${height}`,
15 };
16 })
17 );
18 }, 1000);
19 });
20};
21
22const Waterfall = () => {
23 const [items, setItems] = useState([]);
24 const [isPending, startTransition] = useTransition();
25 const observerRef = useRef();
26
27 const loadItems = () => {
28 startTransition(() => {
29 fetchItems(10).then((newItems) => {
30 setItems((prev) => [...prev, ...newItems]);
31 });
32 });
33 };
34
35 useEffect(() => {
36 const observer = new IntersectionObserver(
37 (entries) => {
38 if (entries[0].isIntersecting) {
39 loadItems();
40 observer.disconnect();
41 }
42 },
43 { rootMargin: '1000px' }
44 );
45
46 if (observerRef.current) {
47 observer.observe(observerRef.current);
48 }
49
50 return () => {
51 observer.disconnect();
52 };
53 }, [items]);
54
55
56 useEffect(() => {
57 loadItems();
58 }, []);
59
60 return (
61 <div className="waterfall-container">
62 {items.map((item, index) => (
63 <div className="item" key={index}>
64 <img
65 src={item.src}
66 loading="lazy"
67 decoding="async"
68 alt={`Loaded item ${index + 1}`}
69 style={{ height: item.height + 'px' }}
70 />
71 <h2>商品名称 {index + 1}</h2>
72 <p>商品描述 {index + 1}</p>
73 </div>
74 ))}
75 {isPending && <div>加载中...</div>}
76 <div id="load-more" ref={observerRef} />
77 </div>
78 );
79};
80
81export default Waterfall;
延迟浏览器渲染 Viewport 之外元素
content-visibility 是 CSS 属性,允许浏览器跳过不在屏幕上的元素的渲染工作,直到用户滚动到它们的位置。通过跳过不可见内容的渲染,content-visibility 可以显著减少页面的初始加载时间,并降低内存的使用,从而改善用户体验。配合 contain-intrinsic-size 属性可以对容器进行渲染前的占位
1<style>
2 .image-gallery {
3 content-visibility: auto;
4 contain-intrinsic-size: 340px 340px;
5 }
6</style>
7
8<div class="image-gallery">
9 <img src="image1.jpg" alt="描述1">
10 <img src="image2.jpg" alt="描述2">
11
12</div>
demo 中的 CSS 可以稍加改进
1.waterfall-container {
2 display: grid;
3 grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
4 gap: 10px;
5 padding: 10px;
6}
7
8.item {
9 content-visibility: auto;
10 padding: 10px;
11 background-color: #f2f3f7;
12 border-radius: 5px;
13}
14
15.item img {
16 width: 100%;
17}
压缩率更高的图片格式 AVIF
大部分 Web 开发者对 WebP 格式非常熟悉了,但可能对 AVIF 格式还没有开始应用。AVIF 是一种基于 AV1 视频编码的新图像格式,用于将AV1压缩的图片或图片序列存储为HEIF文件格式。相对于JPEG,WEBP 这类图片格式来说,它的压缩率更高,并且画面细节更好,AVIF vs JPEG 大小节省约 50%,AVIF vs WebP 大小节省约 20%。主流浏览器的支持情况非常不错
浏览器在图片请求时候会在 Accept 头部信息中声明支持的图片格式,可以利用这个在 CDN 识别,使用相同的图片地址,返回不同格式的图片内容
避免前端加载 1px 透明图判断浏览器是否支持特定图片格式,然后修改图片 URL 来获取对应格式图片。这样的处理方式有两个弊端
- 发起图片请求依赖前端格式判断的异步过程,请求时机被推迟
- 使用新格式的图片包括后期的调整等,需要修改前端代码
小结
瀑布流可以使用以下手段进行性能优化
- 使用纯 CSS 实现 Grid 布局优化响应式性能
- 使用浏览器原生的 IntersectionObserver API 实现懒加载
- 使用浏览器原生的 loading=“lazy” 实现图片懒加载
- 使用 decoding=“async” 对图片进行异步解码
- 使用 useTransition 非阻塞渲染商品卡片
- 使用 content-visibility 延迟 Viewport 之外的元素渲染
- 使用压缩率更高的图片格式 AVIF
这样的瀑布流才足够懒!