懒渲染技术在前端性能优化中的实战应用与踩坑总结
项目初期的技术选型
最近做的那个电商项目,商品列表页面展示2000+商品,页面卡得一批,滚动条拖动都明显卡顿。一开始就是简单的React组件渲染,结果用户一滚动页面就卡死几秒,产品经理天天来催我优化。
项目组讨论了几个方案:虚拟滚动、分页加载、懒渲染。考虑到用户习惯是一次性看到所有商品(虽然数据量大),最终选择了懒渲染。主要是想让用户体验好一点,同时避免频繁的网络请求。
说实话开始没想到这个方案有多复杂,以为就是简单的intersection observer监听可视区域,后面发现事情没那么简单。
基本实现思路
懒渲染的核心就是只渲染当前可视区域内的元素,加上上下预加载一点点缓冲区域。原理简单,实现起来各种边界情况需要考虑。
import React, { useState, useRef, useCallback } from 'react';
const LazyRenderList = ({ data, itemHeight = 80, buffer = 5 }) => {
const containerRef = useRef(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
const calculateVisibleRange = useCallback(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const startIndex = Math.floor(scrollTop / itemHeight) - buffer;
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer;
setVisibleRange({
start: Math.max(0, startIndex),
end: Math.min(data.length, endIndex)
});
}, [data.length, itemHeight, buffer]);
// 监听滚动事件
const handleScroll = useCallback(() => {
calculateVisibleRange();
}, [calculateVisibleRange]);
return (
<div
ref={containerRef}
className="lazy-container"
onScroll={handleScroll}
style={{ height: '500px', overflowY: 'auto', position: 'relative' }}
>
<div
style={{
height: ${data.length * itemHeight}px,
position: 'relative'
}}
>
{data.slice(visibleRange.start, visibleRange.end).map((item, index) => (
<div
key={item.id}
className="list-item"
style={{
position: 'absolute',
top: ${(visibleRange.start + index) * itemHeight}px,
height: ${itemHeight}px,
width: '100%'
}}
>
{/* 渲染具体商品 */}
</div>
))}
</div>
</div>
);
};
最大的坑:滚动性能问题
第一版实现后发现问题很大,滚动过程中还是卡顿。主要是因为scroll事件触发太频繁,每次滚动都在计算可见区域范围,然后重新render。浏览器根本扛不住。
折腾了半天发现需要节流,但是普通的节流函数会导致滚动不流畅。后来用了requestAnimationFrame来优化:
const throttleScrollHandler = useCallback(() => {
let ticking = false;
return () => {
if (!ticking) {
requestAnimationFrame(() => {
calculateVisibleRange();
ticking = false;
});
ticking = true;
}
};
}, [calculateVisibleRange]);
// 使用
const throttledScroll = throttleScrollHandler();
return (
<div
ref={containerRef}
onScroll={throttledScroll}
>
{/* 其他内容 */}
</div>
);
这样确实好了不少,但是还有个小问题:当快速滚动到列表底部时,会出现短暂的空白区域。原因是可视区域变化太快,计算跟不上。这个问题后来通过增加buffer值缓解了,从5个增加到10个。
内存泄漏问题差点翻车
测试环境发现内存一直在涨,滚动时间长了页面直接卡死。查了半天发现是event listener没有及时清理。之前用的是原生的addEventListener,组件卸载时忘了removeEventListener。
改成useEffect的返回函数来清理:
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleContainerScroll = throttledScroll;
container.addEventListener('scroll', handleContainerScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleContainerScroll);
};
}, [throttledScroll]);
这里passive: true也很重要,可以让浏览器提前知道这个事件处理器不会调用preventDefault,提升滚动性能。这个细节我第一次做移动端优化时踩过坑,这次想起来加了。
不同设备兼容性的处理
开发阶段是在Chrome调试的,到了Safari和移动端Android上发现行为不太一致。特别是Safari上,某些情况下IntersectionObserver的表现跟预期不一样。
后来统一使用onscroll事件监听,放弃了一部分IntersectionObserver的优化。虽然性能稍微差一点点,但兼容性更好,代码也更可控。
移动端的touchmove事件也需要单独处理,因为移动端的滚动机制跟PC端不一样。这里需要注意preventDefault的问题,不然可能影响页面的正常手势操作。
数据更新时的边界处理
项目中商品数据是动态更新的,比如搜索筛选会改变列表数据。这时候如果当前可视区域内的商品被删除了,会出现位置错乱的问题。
解决方案是在数据变更时重新计算整个列表的高度,并重置可视区域范围:
useEffect(() => {
// 数据发生变化时重置可视区域
setVisibleRange(prev => ({
start: 0,
end: Math.min(data.length, prev.end) // 保持end不超过新数据长度
}));
}, [data.length]);
这个逻辑看起来简单,但是实际处理时需要考虑各种情况:新增数据、删除数据、排序变化等等。每个场景的处理方式都不太一样。
最终效果和剩余问题
优化完成后,页面滚动确实流畅了很多。2000+商品列表滚动时FPS基本能保持在50以上,用户体验提升了至少3倍。
不过还是有个小瑕疵:快速滚动到底部时偶尔会有轻微的闪烁,这是因为数据渲染和位置计算的时间差造成的。理论上可以通过预渲染更多元素来解决,但是会消耗更多内存。权衡之后觉得这个小问题可以接受。
另外就是首屏渲染时间比之前略长,因为需要额外计算可视区域范围。不过这个影响不大,用户感知不明显。
整体来说这个懒渲染方案达到了预期目标:页面响应快了,内存占用降了,滚动体验也好了。虽然实现过程有点曲折,但结果还算满意。
回过头来看
这次懒渲染的实现让我对性能优化有了更深的理解。有时候看似简单的技术方案,实际落地时会遇到各种意想不到的问题。
特别是一些看似微不足道的细节,比如节流函数的实现方式、事件监听器的清理、不同浏览器的兼容性处理,这些往往决定了最终的用户体验。
现在回头看,如果项目一开始就规划好性能优化策略,可能能避免一些后期的重构工作。不过这也算是积累经验了吧,下次遇到类似需求就有现成的轮子可以用。
以上是我这次懒渲染实现的完整踩坑过程,包括核心代码实现和遇到的各种问题。虽然方案不是完美的,但在实际项目中效果不错。有更好实现方式的朋友欢迎交流。

暂无评论