在开发这个网站实现首页瀑布流的时候采用的是Masonry.js的布局方案,发现有很多糟糕的用户体验问题,便仔细再研究了一下Pinterest确实在这方面做得非常好。使用了几种技术组合来实现无缝的位置恢复:
Pinterest 的核心技术方案
1. 浏览器原生的滚动恢复 + SPA 增强
// Pinterest 利用浏览器的 History API 和滚动恢复
history.scrollRestoration = 'manual'; // 手动控制
2. 服务端渲染 + 客户端激活(Hydration)
Pinterest 使用 SSR,返回时页面内容已经在 HTML 中,无需等待 JavaScript 重新渲染:
<!-- 服务端直接返回完整的瀑布流 HTML -->
<div class="pin-container">
<div class="pin" style="position: absolute; left: 0px; top: 0px;">...</div>
<div class="pin" style="position: absolute; left: 205px; top: 0px;">...</div>
<!-- 所有可见的 Pin 都已经有了确定的位置 -->
</div>
3. 预计算的绝对定位
Pinterest 不使用传统的 Masonry.js,而是:
服务端预计算每个元素的精确位置
直接使用
position: absolute
布局容器有固定的计算高度
4. 渐进式图片加载 + 占位符
.pin-image {
background: linear-gradient(45deg, #f0f0f0 25%, transparent 25%);
background-size: 20px 20px;
}
5. 智能缓存策略
Pinterest 使用多层缓存:
浏览器缓存(bfcache)
Service Worker 缓存
内存中的虚拟 DOM 状态
让我创建一个模仿 Pinterest 方案的实现:
Pinterest 的关键技术要点
1. 预计算 + 绝对定位
Pinterest 不依赖客户端的 Masonry 布局计算,而是:
服务端/客户端预先计算每个元素的精确位置
使用
position: absolute
直接定位容器有确定的高度,避免布局抖动
2. SessionStorage + 内存双重缓存
// 路由跳转时保存完整状态
sessionStorage.setItem('waterfall_state', JSON.stringify({
items, positions, scrollY, containerHeight
}));
3. 智能图片加载
使用渐变背景作为占位符
loading="lazy"
但可见区域立即加载图片加载失败有降级处理
4. 原生滚动恢复增强
// 利用浏览器原生能力 + 手动精确控制
history.scrollRestoration = 'manual';
5. 响应式布局预计算
根据屏幕宽度预先确定列数和布局,避免重新计算。
为什么 Pinterest 体验更好?
无布局重排:所有元素位置预先确定
快速缓存:SessionStorage 比复杂的虚拟滚动更直接
渐进增强:即使 JS 失败,HTML 结构也是完整的
硬件加速:使用 transform 和 absolute 定位
这种方案的核心思想是避免重新布局计算,直接使用保存的位置信息快速重建页面,这比等待 Masonry 重新计算要快得多。
结合Pinterest的技术策略,一个理想的实现方案可能包括:
使用Next.js的SSR能力预渲染关键内容
实现预计算的绝对定位布局,替代传统Masonry
优化图片加载,使用占位符和预定义尺寸
手动控制滚动恢复,结合最后查看的文章ID定位
利用Service Worker缓存页面状态和资源
这样的方案比单纯依赖Masonry.js
更为稳定,能提供更好的用户体验,特别是在移动端返回场景中。
不过,这种完整方案的实现复杂度较高,需要对现有代码进行较大改动。我可能需要一个中间方案,可以先实现滚动位置记忆和绝对定位布局,这两项能解决大部分问题。暂时未优化,先记录一下。
尚未实际应用的代码示例:
// utils/layoutCalculator.js - 预计算布局位置
export class WaterfallLayoutCalculator {
constructor(containerWidth, columns = 2, gap = 16) {
this.containerWidth = containerWidth;
this.columns = columns;
this.gap = gap;
this.columnWidth = (containerWidth - gap * (columns - 1)) / columns;
this.columnHeights = new Array(columns).fill(0);
}
calculateItemPosition(item, index) {
// 根据图片比例计算实际高度
const aspectRatio = item.height / item.width;
const itemHeight = this.columnWidth * aspectRatio + 60; // +60 for text content
// 找到最短的列
const shortestColumnIndex = this.columnHeights.indexOf(Math.min(...this.columnHeights));
const position = {
left: shortestColumnIndex * (this.columnWidth + this.gap),
top: this.columnHeights[shortestColumnIndex],
width: this.columnWidth,
height: itemHeight,
column: shortestColumnIndex,
index
};
// 更新列高度
this.columnHeights[shortestColumnIndex] += itemHeight + this.gap;
return position;
}
calculateAllPositions(items) {
this.columnHeights = new Array(this.columns).fill(0);
const positions = new Map();
items.forEach((item, index) => {
const position = this.calculateItemPosition(item, index);
positions.set(item.id, position);
});
return {
positions,
containerHeight: Math.max(...this.columnHeights)
};
}
static getResponsiveColumns(width) {
if (width < 480) return 2;
if (width < 768) return 3;
if (width < 1200) return 4;
return 5;
}
}
// hooks/usePinterestRestore.js
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/router';
import { WaterfallLayoutCalculator } from '../utils/layoutCalculator';
export const usePinterestRestore = () => {
const router = useRouter();
const [isHydrating, setIsHydrating] = useState(true);
const sessionCache = useRef(new Map());
const layoutCalculator = useRef(null);
const containerRef = useRef(null);
// 保存页面状态到 sessionStorage(模拟服务端状态)
const savePageState = useCallback((items, scrollY) => {
if (typeof window === 'undefined') return;
const key = router.asPath;
const containerWidth = containerRef.current?.clientWidth || window.innerWidth;
const columns = WaterfallLayoutCalculator.getResponsiveColumns(containerWidth);
// 计算布局
layoutCalculator.current = new WaterfallLayoutCalculator(containerWidth, columns);
const { positions, containerHeight } = layoutCalculator.current.calculateAllPositions(items);
const state = {
items,
positions: Array.from(positions.entries()),
containerHeight,
scrollY: scrollY || window.scrollY,
containerWidth,
columns,
timestamp: Date.now(),
url: key
};
// 同时保存到内存和 sessionStorage
sessionCache.current.set(key, state);
try {
sessionStorage.setItem(`waterfall_${key}`, JSON.stringify(state));
} catch (e) {
console.warn('SessionStorage save failed:', e);
}
}, [router.asPath]);
// 恢复页面状态
const restorePageState = useCallback(() => {
const key = router.asPath;
// 先从内存缓存获取
let state = sessionCache.current.get(key);
// 如果内存中没有,从 sessionStorage 获取
if (!state && typeof window !== 'undefined') {
try {
const stored = sessionStorage.getItem(`waterfall_${key}`);
if (stored) {
state = JSON.parse(stored);
// 检查是否过期(30分钟)
if (Date.now() - state.timestamp > 30 * 60 * 1000) {
sessionStorage.removeItem(`waterfall_${key}`);
state = null;
} else {
// 恢复到内存缓存
sessionCache.current.set(key, state);
}
}
} catch (e) {
console.warn('SessionStorage restore failed:', e);
}
}
return state;
}, [router.asPath]);
// 处理路由变化
useEffect(() => {
const handleRouteChangeStart = (url) => {
if (containerRef.current) {
const items = Array.from(containerRef.current.querySelectorAll('[data-pin-id]'))
.map(el => JSON.parse(el.dataset.pinData || '{}'));
savePageState(items, window.scrollY);
}
};
const handleRouteChangeComplete = () => {
// 延迟一帧,确保组件已挂载
requestAnimationFrame(() => {
setIsHydrating(false);
});
};
router.events.on('routeChangeStart', handleRouteChangeStart);
router.events.on('routeChangeComplete', handleRouteChangeComplete);
// 初始页面加载完成
const timer = setTimeout(() => setIsHydrating(false), 100);
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
clearTimeout(timer);
};
}, [router, savePageState]);
return {
containerRef,
isHydrating,
savePageState,
restorePageState
};
};
// components/PinterestWaterfall.js
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useRouter } from 'next/router';
import { usePinterestRestore } from '../hooks/usePinterestRestore';
import { WaterfallLayoutCalculator } from '../utils/layoutCalculator';
const PinterestWaterfall = ({ items = [], onItemClick }) => {
const router = useRouter();
const { containerRef, isHydrating, savePageState, restorePageState } = usePinterestRestore();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [layoutPositions, setLayoutPositions] = useState(new Map());
const [isRestored, setIsRestored] = useState(false);
const resizeObserver = useRef(null);
// 计算布局
const calculateLayout = useCallback((containerWidth, itemsToLayout) => {
const columns = WaterfallLayoutCalculator.getResponsiveColumns(containerWidth);
const calculator = new WaterfallLayoutCalculator(containerWidth, columns);
return calculator.calculateAllPositions(itemsToLayout);
}, []);
// 恢复或计算布局
useEffect(() => {
if (!containerRef.current || items.length === 0) return;
const containerWidth = containerRef.current.clientWidth;
// 尝试恢复状态
const savedState = restorePageState();
if (savedState && !isRestored) {
// 恢复保存的布局
const positionsMap = new Map(savedState.positions);
setLayoutPositions(positionsMap);
setDimensions({
width: containerWidth,
height: savedState.containerHeight
});
// 恢复滚动位置
setTimeout(() => {
window.scrollTo(0, savedState.scrollY);
setIsRestored(true);
}, 50);
} else {
// 计算新布局
const { positions, containerHeight } = calculateLayout(containerWidth, items);
setLayoutPositions(positions);
setDimensions({ width: containerWidth, height: containerHeight });
}
}, [items, restorePageState, calculateLayout, isRestored]);
// 监听容器尺寸变化
useEffect(() => {
if (!containerRef.current) return;
resizeObserver.current = new ResizeObserver(entries => {
const { width } = entries[0].contentRect;
if (Math.abs(width - dimensions.width) > 10) {
// 重新计算布局
const { positions, containerHeight } = calculateLayout(width, items);
setLayoutPositions(positions);
setDimensions({ width, height: containerHeight });
}
});
resizeObserver.current.observe(containerRef.current);
return () => {
if (resizeObserver.current) {
resizeObserver.current.disconnect();
}
};
}, [dimensions.width, calculateLayout, items]);
// 处理点击事件
const handleItemClick = useCallback((item, event) => {
// 保存当前状态
savePageState(items, window.scrollY);
if (onItemClick) {
onItemClick(item, event);
}
}, [items, savePageState, onItemClick]);
// 定期保存状态
useEffect(() => {
if (isHydrating || items.length === 0) return;
const handleScroll = () => {
// 节流保存
if (window.scrollTimeout) {
clearTimeout(window.scrollTimeout);
}
window.scrollTimeout = setTimeout(() => {
savePageState(items, window.scrollY);
}, 300);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (window.scrollTimeout) {
clearTimeout(window.scrollTimeout);
}
};
}, [isHydrating, items, savePageState]);
return (
<div
ref={containerRef}
className="pinterest-container"
style={{
height: `${dimensions.height}px`,
position: 'relative',
opacity: isHydrating ? 0.7 : 1,
transition: 'opacity 0.3s ease'
}}
>
{items.map((item) => {
const position = layoutPositions.get(item.id);
if (!position) return null;
return (
<PinCard
key={item.id}
item={item}
position={position}
onClick={handleItemClick}
/>
);
})}
</div>
);
};
// components/PinCard.js
const PinCard = ({ item, position, onClick }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
return (
<div
className="pin-card"
data-pin-id={item.id}
data-pin-data={JSON.stringify(item)}
style={{
position: 'absolute',
left: `${position.left}px`,
top: `${position.top}px`,
width: `${position.width}px`,
cursor: 'pointer',
transition: 'transform 0.2s ease'
}}
onClick={(e) => onClick(item, e)}
>
<div className="pin-content">
<div
className="pin-image-container"
style={{
width: '100%',
paddingBottom: `${(item.height / item.width) * 100}%`,
position: 'relative',
background: imageLoaded ? 'transparent' : 'linear-gradient(45deg, #f0f0f0 25%, transparent 25%)',
backgroundSize: '20px 20px',
borderRadius: '16px',
overflow: 'hidden'
}}
>
{!imageError && (
<img
src={item.image}
alt={item.title}
className="pin-image"
loading="lazy"
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease'
}}
/>
)}
</div>
<div className="pin-info">
<h3 className="pin-title">{item.title}</h3>
{item.description && (
<p className="pin-description">{item.description}</p>
)}
</div>
</div>
</div>
);
};
// pages/waterfall.js - 使用示例
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import PinterestWaterfall from '../components/PinterestWaterfall';
const WaterfallPage = ({ initialItems = [] }) => {
const router = useRouter();
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const handleItemClick = (item) => {
router.push(`/pin/${item.id}`);
};
const loadMoreItems = async () => {
if (loading) return;
setLoading(true);
try {
const response = await fetch(`/api/items?offset=${items.length}`);
const newItems = await response.json();
setItems(prev => [...prev, ...newItems]);
} catch (error) {
console.error('Failed to load more items:', error);
} finally {
setLoading(false);
}
};
// 无限滚动
useEffect(() => {
const handleScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) {
loadMoreItems();
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [items.length, loading]);
return (
<div className="waterfall-page">
<header className="page-header">
<h1>瀑布流展示</h1>
</header>
<PinterestWaterfall
items={items}
onItemClick={handleItemClick}
/>
{loading && (
<div className="loading-indicator">
<div className="spinner">加载更多...</div>
</div>
)}
</div>
);
};
export default WaterfallPage;
export async function getServerSideProps(context) {
try {
// 服务端预渲染初始数据
const response = await fetch(`${process.env.API_BASE_URL}/api/items?limit=20`);
const initialItems = await response.json();
return {
props: {
initialItems
}
};
} catch (error) {
return {
props: {
initialItems: []
}
};
}
}
// CSS 样式
const pinterestStyles = `
.pinterest-container {
padding: 16px;
background: #ffffff;
}
.pin-card {
margin-bottom: 16px;
break-inside: avoid;
transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.pin-card:hover {
transform: translateY(-4px);
}
.pin-content {
background: white;
border-radius: 16px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: box-shadow 0.2s ease;
}
.pin-card:hover .pin-content {
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.pin-image {
width: 100%;
height: auto;
display: block;
}
.pin-info {
padding: 12px;
}
.pin-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
color: #333;
line-height: 1.3;
}
.pin-description {
font-size: 12px;
color: #767676;
margin: 0;
line-height: 1.4;
}
.waterfall-page {
min-height: 100vh;
background: #ffffff;
}
.page-header {
padding: 20px;
text-align: center;
background: white;
border-bottom: 1px solid #efefef;
position: sticky;
top: 0;
z-index: 100;
}
.loading-indicator {
padding: 40px;
text-align: center;
}
.spinner {
display: inline-block;
padding: 12px 24px;
background: #f0f0f0;
border-radius: 24px;
color: #666;
font-size: 14px;
}
@media (max-width: 768px) {
.pinterest-container {
padding: 8px;
}
.pin-card {
margin-bottom: 8px;
}
}`;
export { PinterestWaterfall, PinCard, pinterestStyles };