“👨🏻💻和代码有一个能🏃🏻♀️就行”,看似一句玩笑话但可能已经成为了事实。图片优化作为前端应该必须掌握的一项技能,但是你做三年开发也并不会真正的优化一次。
这几天在掘金看到了我将 2K stars 的 《丑丑头像》,用 next.js 重写了 这篇文章,在评论区有几个的人在讨论说遇到了滚动时卡顿的问题,其实整个页面仅展示 10 张随机生成的头像图片,这看起来不是个好的现象,正好可以尝试做一点优化看看效果怎么样。
原因探索
因为不清楚测量哪些指标可以直指卡顿的原因,所以我还是先对页面进行一次分析:
- 图片请求:每次刷新页面会同时发起 10 次图片资源请求;
- 图片大小:每次响应的图片大小在 100kB ~ 350kB 左右;
- 图片格式:预览和下载均为 SVG 格式;
- 图片要求:支持调整背景色以及支持i透明背景。
制定方案
通过网络请求这块可以看到,造成这次卡顿的主要原因可能有两个:
- 同时请求多: 同时发起过多的网络请求势必对浏览器的性能会造成明显影响,这里我选择利用懒加载(Lazy Loading) 的方式处理,保证视图进入页面 1/4 后才开始加载新的图片资源。
- 图片尺寸大: 每张图片的尺寸偏大,在加载到页面中时同样有卡顿现象,这里我选择将预览和下载分开,保持下载的规则不变,将预览时的图像调整为渐进式 JPEG 格式。
难度升级
目前的页面加载的图片数量为 10,单从数量来看是很少的,所以我选择将图片数量提升到 1000 以上。在图片依次加载完毕后 DOM 中将有大量的不可释放的节点,再次造成卡顿。
解决这个问题的方案我选择虚拟列表,保证 DOM 中不会有大量不可释放的节点。
方案实施
需要编写一个懒加载组件和一个瀑布流布局组件,以及在 Service 端对预览图片动态转换为渐进式 JPEG 格式。
LazyImage 组件:
实现图片懒加载组件的核心是应用 IntersectionObserver API,此提供了一种异步观察目标元素与其祖先元素或顶级文档视口(viewport)交叉状态的方法。
在组件实际编写中我选择直接 react-intersection-observer 代替原生 API,此模块提供了适用于 Reacrt 中用来监控组件状态的钩子 useInView Hoook API,配置可见区域的比例为1/4,当 next/image 组件进去视图1/4后 inView 会切换为 true。
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import Image from "next/image";
import { placeholder } from './placeholder';
export function LazyImage({ src = '' }) {
const [loaded, setLoaded] = useState(false);
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.25,
});
return (
<div ref={ref} style={{ width, height }}>
{inView && <>
<Image src={src} />
</>}
</div>
);
}
MasonryLayout 组件:
MasonryLayout 组件由 MasonryLayout 容器和 CardCell 内容项两部分组成:
-
MasonryLayout 容器: 利用 ResizeObserver API 监听容器尺寸的变化,根据内容项预设的尺寸计算
columnCount和rowCount两个属性,其中容器由 react-window 模块中的VariableSizeGrid提供,这个模块的主要特点就是用于高效渲染大量列表和表格数据。const columnWidth = 342; const rowHeight = 400; const MasonryLayout: React.FC<MasonryLayoutProps> = ({ images }: MasonryLayoutProps) => { const [containerWidth, setContainerWidth] = useState<number>(1200); const [containerHeight, setContainerHeight] = useState<number>(800); const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { const handleResize = () => { if (containerRef.current) { setContainerWidth(containerRef.current.offsetWidth); setContainerHeight(window.innerHeight - 154); } }; const resizeObserver = new ResizeObserver(handleResize); const currentContainer = containerRef.current; if (currentContainer) { resizeObserver.observe(currentContainer); } handleResize(); return () => { if (currentContainer) { resizeObserver.unobserve(currentContainer); } }; }, []); const getColumnCount = useCallback(() => { return Math.floor(containerWidth / columnWidth); }, [containerWidth]); const getRowHeight = useCallback((index: number) => rowHeight, []); const columnCount = getColumnCount(); const rowCount = Math.ceil(images.length / columnCount); return ( <div ref={containerRef} style={{ width: '100%' }}> <Grid columnCount={columnCount} columnWidth={() => columnWidth} height={containerHeight} rowCount={rowCount} rowHeight={getRowHeight} width={containerWidth} itemData={{ images, columnCount, columnWidth }} > {CardCell} </Grid> </div> ); }; -
CardCell 内容项: 这个 Card 组件就是源代码中主要的显示区域,直接当做 CardCell 会发现丢失了每行和没列之间的间距,通过网页审查元素可以看到使用 react-window 模块后,每个 Call 区域都是通过定位的方式实现排列,所以我通过判断 CardCell 的位置为每一个 CardCell 添加了合适的
left和top属性,实现了每项之间的间隔。const CardCell: React.FC<CellProps> = ({ columnIndex, rowIndex, style, data }) => { const { images, columnCount } = data; const imageIndex = rowIndex * columnCount + columnIndex; const image = images[imageIndex]; if (!image) return null; const rowInIndex = imageIndex % columnCount; return ( <Card className="w-[342px] h-[400px]" style={{ ...style, boxSizing: 'border-box', left: `${(columnWidth + gap) * rowInIndex}px`, top: `${(rowHeight + gap) * rowIndex}px` }} key={image.url}> <CardContent className={'p-5'}> <LazyImage src={image.url} alt={'index'} width={300} height={300} /> </CardContent> <CardFooter className={'flex justify-around items-center'}> <Button onClick={() => onDownload(image.url)} variant="outline"> 下载 </Button> </CardFooter> </Card> ); };- 直接迁移无间隔
- 动态添加间隔
渐进式 JPEG:
渐进式JPEG(Progressive JPEG)一种渐进式 JPEG 压缩格式在呈现图像的方式上类似于 GIF(图形互换格式)。在网页浏览器中呈现时,图像会逐层下载,逐渐显现。直到完全呈现,图像逐渐变得清晰。
支持渐进式 JPEG 需要 Service 端支持,sharp 是用于在 Nodejs 中对图片高效加工的模块,仅通过一个选项就可以支持返回渐进式 JPEG 格式。
// 提供渐进式 JPEG 预览, 并降低质量
const jpegBuffer = await sharp(Buffer.from(result))
.jpeg({ progressive: true, quality: 75 })
.toBuffer();
return new Response(jpegBuffer, {
status: 200,
headers: {
'Content-Type': `image/jpeg`,
}
});
遗留问题
每当新的内容项 CardCell 进入视图1/4 时就会发起图片资源的请求,但是由于图片资源加载时间长,你将内容项继续向上滚动移出了视图,新的内容项继续进入视图,继续发起图片资源请求,这样就造成了无法及时加载当前视图中的图片,因为它排到的请求的队尾,我考虑了两种参考方案:
- 分页控制:只有当进入视图的图片资源加载完成后才运行继续加载下一分页的数据;
- 取消请求:拦截图片资源请求,将被移出视图的内容项对应的图片资源请求终止。
目前这个遗留问题在原项目中不存在,因为原项目要求仅展示 10 张图片。
总结:
通过上述优化措施,不仅解决了原有页面的卡顿问题,还提高了页面在大量图片展示情况下的性能。此外,这些技术方案也为其他类似项目提供了有价值的参考。对于前端开发者而言,了解并掌握这些优化技巧是非常重要的,特别是在现代Web应用中,高性能的图片展示已经成为用户良好体验的关键因素之一。