如果等待整个瀑布流重新布局完成,用户会看到明显的"从顶部滚动到目标位置"的过程,体验很糟糕。
思考一个更好的解决方案,采用了智能预定位策略,虚拟滚动 + 预渲染目标区域,避免肉眼可见的滚动过程:
1. 立即定位策略
在内容渲染之前就直接滚动到目标位置
使用绝对定位快速重现目标区域的布局
避免等待完整的
Masonry
重建
2. 分阶段渲染
// 阶段1:立即设置容器高度和滚动位置
containerRef.current.style.minHeight = `${containerHeight}px`;
window.scrollTo(0, scrollY);
// 阶段2:只渲染可见区域 ± 几个元素
visibleRange: {
start: Math.max(0, (firstVisible?.index || 0) - 3),
end: Math.min(items.length - 1, (firstVisible?.index || 0) + 15)
}
// 阶段3:渐进式加载其余内容
3. 无闪烁切换
恢复时使用绝对定位,完全按照保存的坐标放置元素
等用户稳定后再切换回正常的 Masonry 模式
整个过程用户感知不到任何跳动
4. 性能优化
预渲染的图片使用
loading="eager"
使用
will-change
和transform: translateZ(0)
启用硬件加速分批渲染避免一次性处理大量DOM
使用效果,用户返回时的体验:
0ms: 页面立即出现在正确的滚动位置
50ms: 目标区域内容精确呈现
200ms: 其他内容静默加载完成
整个过程: 用户完全感知不到页面重建
这种方式即使有上千个元素也不会有明显的滚动过程,因为绕过了传统的"等待布局完成再滚动"的模式。
示例代码:
// hooks/useVirtualMasonryRestore.js
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/router';
export const useVirtualMasonryRestore = () => {
const router = useRouter();
const [isRestoring, setIsRestoring] = useState(false);
const [restoreData, setRestoreData] = useState(null);
const stateCache = useRef(new Map());
const itemPositions = useRef(new Map());
const containerRef = useRef(null);
// 保存详细的布局信息
const saveLayoutState = useCallback((items) => {
if (!containerRef.current) return;
const positions = new Map();
const masonryItems = containerRef.current.querySelectorAll('[data-masonry-item]');
masonryItems.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const scrollTop = window.scrollY;
positions.set(item.dataset.id, {
index,
width: rect.width,
height: rect.height,
left: rect.left,
top: rect.top + scrollTop, // 绝对位置
visible: rect.top < window.innerHeight && rect.bottom > 0
});
});
const key = router.asPath;
const scrollY = window.scrollY;
const viewportHeight = window.innerHeight;
// 找到当前可见区域的第一个和最后一个元素
const visibleItems = Array.from(positions.values())
.filter(item => item.visible)
.sort((a, b) => a.top - b.top);
const firstVisible = visibleItems[0];
const lastVisible = visibleItems[visibleItems.length - 1];
stateCache.current.set(key, {
items,
positions,
scrollY,
viewportHeight,
firstVisible,
lastVisible,
timestamp: Date.now(),
// 计算需要预渲染的范围
renderRange: {
start: Math.max(0, firstVisible?.index - 5 || 0),
end: Math.min(items.length - 1, (lastVisible?.index || 0) + 10)
}
});
itemPositions.current = positions;
}, [router.asPath]);
// 预计算目标位置的布局
const calculateTargetLayout = useCallback((targetState, containerWidth) => {
const { items, positions, renderRange } = targetState;
const columns = containerWidth > 768 ? 3 : 2;
const columnWidth = containerWidth / columns;
const gap = 16;
// 模拟瀑布流布局算法
const columnHeights = new Array(columns).fill(0);
const newPositions = new Map();
for (let i = 0; i < items.length; i++) {
const item = items[i];
const oldPos = positions.get(item.id);
if (oldPos) {
// 找到最短的列
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
const x = shortestColumnIndex * columnWidth;
const y = columnHeights[shortestColumnIndex];
newPositions.set(item.id, {
...oldPos,
left: x,
top: y,
index: i
});
columnHeights[shortestColumnIndex] += oldPos.height + gap;
}
}
return newPositions;
}, []);
// 智能恢复:直接定位到目标位置
const performSmartRestore = useCallback(async () => {
const state = stateCache.current.get(router.asPath);
if (!state || !containerRef.current) {
setIsRestoring(false);
return;
}
const { scrollY, firstVisible, items, positions } = state;
// 1. 立即设置容器高度,防止页面跳动
const containerHeight = Math.max(...Array.from(positions.values()).map(p => p.top + p.height));
containerRef.current.style.minHeight = `${containerHeight}px`;
// 2. 立即滚动到目标位置(在内容渲染前)
window.scrollTo(0, scrollY);
// 3. 设置恢复数据,触发目标区域渲染
setRestoreData({
targetScrollY: scrollY,
targetItems: items,
visibleRange: {
start: Math.max(0, (firstVisible?.index || 0) - 3),
end: Math.min(items.length - 1, (firstVisible?.index || 0) + 15)
},
allPositions: positions
});
// 4. 分批渲染其他内容
setTimeout(() => {
setRestoreData(prev => prev ? {
...prev,
visibleRange: {
start: 0,
end: items.length - 1
}
} : null);
// 5. 微调滚动位置
setTimeout(() => {
if (Math.abs(window.scrollY - scrollY) > 10) {
window.scrollTo(0, scrollY);
}
setIsRestoring(false);
containerRef.current.style.minHeight = 'auto';
}, 200);
}, 100);
}, [router.asPath]);
// 路由变化处理
useEffect(() => {
const handleRouteChangeStart = () => {
if (containerRef.current) {
const items = Array.from(containerRef.current.querySelectorAll('[data-masonry-item]'))
.map(el => ({ id: el.dataset.id, ...JSON.parse(el.dataset.item || '{}') }));
saveLayoutState(items);
}
};
const handleRouteChangeComplete = (url) => {
const state = stateCache.current.get(url);
if (state) {
setIsRestoring(true);
// 延迟一点点,让组件先挂载
setTimeout(performSmartRestore, 50);
}
};
router.events.on('routeChangeStart', handleRouteChangeStart);
router.events.on('routeChangeComplete', handleRouteChangeComplete);
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
};
}, [router, saveLayoutState, performSmartRestore]);
return {
containerRef,
isRestoring,
restoreData,
saveLayoutState,
clearRestoreData: () => setRestoreData(null)
};
};
// components/SmartMasonryGrid.js
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/router';
import { useVirtualMasonryRestore } from '../hooks/useVirtualMasonryRestore';
const SmartMasonryGrid = ({ items, onItemClick, columns = 2 }) => {
const router = useRouter();
const { containerRef, isRestoring, restoreData, saveLayoutState, clearRestoreData } = useVirtualMasonryRestore();
const [masonryInstance, setMasonryInstance] = useState(null);
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
const observerRef = useRef(null);
// 根据恢复数据决定渲染范围
const renderItems = useMemo(() => {
if (restoreData && restoreData.visibleRange) {
const { start, end } = restoreData.visibleRange;
return items.slice(start, end + 1).map((item, index) => ({
...item,
originalIndex: start + index
}));
}
return items.map((item, index) => ({ ...item, originalIndex: index }));
}, [items, restoreData]);
// 使用绝对定位模式进行恢复
const renderWithAbsolutePosition = useMemo(() => {
return isRestoring && restoreData && restoreData.allPositions;
}, [isRestoring, restoreData]);
// 初始化 Masonry(仅在非恢复模式下)
const initMasonry = useCallback(async () => {
if (renderWithAbsolutePosition || !containerRef.current) return;
try {
const Masonry = (await import('masonry-layout')).default;
const imagesLoaded = (await import('imagesloaded')).default;
imagesLoaded(containerRef.current, () => {
const masonry = new Masonry(containerRef.current, {
itemSelector: '.masonry-item',
columnWidth: '.masonry-sizer',
percentPosition: true,
gutter: 16
});
setMasonryInstance(masonry);
masonry.on('layoutComplete', () => {
if (!isRestoring) {
setTimeout(() => saveLayoutState(items), 100);
}
});
});
} catch (error) {
console.error('Failed to initialize Masonry:', error);
}
}, [renderWithAbsolutePosition, items, isRestoring, saveLayoutState]);
// 监听容器尺寸变化
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setContainerDimensions({ width, height });
});
resizeObserver.observe(containerRef.current);
observerRef.current = resizeObserver;
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
// 处理点击事件
const handleItemClick = useCallback((item, event) => {
saveLayoutState(items);
if (onItemClick) {
onItemClick(item, event);
}
}, [items, saveLayoutState, onItemClick]);
// 初始化 Masonry
useEffect(() => {
if (!renderWithAbsolutePosition && renderItems.length > 0) {
initMasonry();
}
}, [renderWithAbsolutePosition, renderItems.length, initMasonry]);
// 恢复完成后切换到正常模式
useEffect(() => {
if (!isRestoring && restoreData) {
setTimeout(() => {
clearRestoreData();
// 重新初始化 Masonry
setTimeout(initMasonry, 100);
}, 200);
}
}, [isRestoring, restoreData, clearRestoreData, initMasonry]);
// 清理
useEffect(() => {
return () => {
if (masonryInstance) {
masonryInstance.destroy();
}
};
}, [masonryInstance]);
return (
<div
ref={containerRef}
className="masonry-container"
style={{
position: 'relative',
opacity: isRestoring ? 0.9 : 1,
transition: isRestoring ? 'none' : 'opacity 0.3s ease'
}}
>
{/* Masonry sizer - 仅在正常模式下显示 */}
{!renderWithAbsolutePosition && (
<div
className="masonry-sizer"
style={{ width: `${100 / columns}%` }}
/>
)}
{renderItems.map((item, index) => {
const position = renderWithAbsolutePosition
? restoreData.allPositions.get(item.id)
: null;
return (
<div
key={item.id}
data-id={item.id}
data-masonry-item
data-item={JSON.stringify(item)}
className="masonry-item"
style={position ? {
// 绝对定位模式 - 直接使用保存的位置
position: 'absolute',
left: `${position.left}px`,
top: `${position.top}px`,
width: `${position.width}px`,
zIndex: 1
} : {
// 正常 Masonry 模式
width: `${100 / columns}%`,
marginBottom: '16px'
}}
onClick={(e) => handleItemClick(item, e)}
>
<div className="masonry-item-content">
{item.image && (
<img
src={item.image}
alt={item.title}
loading={position ? "eager" : "lazy"} // 恢复模式下立即加载
style={{
width: '100%',
height: 'auto',
borderRadius: '8px'
}}
/>
)}
<div className="item-info">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</div>
</div>
);
})}
</div>
);
};
export default SmartMasonryGrid;
// components/MasonryPage.js - 页面组件示例
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import SmartMasonryGrid from './SmartMasonryGrid';
const MasonryPage = ({ initialData = [] }) => {
const router = useRouter();
const [items, setItems] = useState(initialData);
const [loading, setLoading] = useState(false);
// 如果没有初始数据,获取数据
useEffect(() => {
if (initialData.length === 0) {
fetchData();
}
}, [initialData.length]);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch('/api/articles');
const data = await response.json();
setItems(data);
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setLoading(false);
}
};
const handleItemClick = (item) => {
router.push(`/article/${item.id}`);
};
return (
<div className="page-container">
<header className="page-header">
<h1>智能瀑布流</h1>
</header>
{loading && (
<div className="loading-overlay">
<div className="loading-spinner">加载中...</div>
</div>
)}
<SmartMasonryGrid
items={items}
onItemClick={handleItemClick}
columns={window.innerWidth > 768 ? 3 : 2}
/>
</div>
);
};
export default MasonryPage;
// 高级优化:虚拟滚动版本(可选)
// hooks/useVirtualMasonry.js
import { useState, useEffect, useRef, useCallback } from 'react';
export const useVirtualMasonry = (items, itemHeight = 300, containerHeight = 800) => {
const [visibleItems, setVisibleItems] = useState([]);
const [scrollTop, setScrollTop] = useState(0);
const scrollElementRef = useRef(null);
const updateVisibleItems = useCallback(() => {
if (!scrollElementRef.current) return;
const container = scrollElementRef.current;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
// 计算可见范围
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + 2
);
const visible = [];
for (let i = Math.max(0, startIndex - 2); i <= endIndex; i++) {
if (items[i]) {
visible.push({
...items[i],
index: i,
top: i * itemHeight
});
}
}
setVisibleItems(visible);
setScrollTop(scrollTop);
}, [items, itemHeight]);
useEffect(() => {
const container = scrollElementRef.current;
if (!container) return;
const handleScroll = () => {
requestAnimationFrame(updateVisibleItems);
};
container.addEventListener('scroll', handleScroll);
updateVisibleItems(); // 初始计算
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, [updateVisibleItems]);
const scrollToIndex = useCallback((index) => {
if (scrollElementRef.current) {
scrollElementRef.current.scrollTop = index * itemHeight;
}
}, [itemHeight]);
return {
scrollElementRef,
visibleItems,
scrollTop,
scrollToIndex,
totalHeight: items.length * itemHeight
};
};
// CSS 样式
const advancedStyles = `
.masonry-container {
position: relative;
margin: 0 auto;
padding: 16px;
min-height: 100vh;
}
.masonry-item {
padding: 8px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
will-change: transform;
}
.masonry-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.masonry-item-content {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
will-change: transform;
}
.page-container {
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
background: white;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-spinner {
padding: 20px 40px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
/* 性能优化:减少重绘 */
.masonry-item img {
backface-visibility: hidden;
transform: translateZ(0);
}