菜单导航

开发故事
实现瀑布流,Pinterest的技术策略
开发故事

实现瀑布流,Pinterest的技术策略

野盐
2025-05-24

在开发这个网站实现首页瀑布流的时候采用的是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 体验更好?

  1. 无布局重排:所有元素位置预先确定

  2. 快速缓存:SessionStorage 比复杂的虚拟滚动更直接

  3. 渐进增强:即使 JS 失败,HTML 结构也是完整的

  4. 硬件加速:使用 transform 和 absolute 定位

这种方案的核心思想是避免重新布局计算,直接使用保存的位置信息快速重建页面,这比等待 Masonry 重新计算要快得多。

结合Pinterest的技术策略,一个理想的实现方案可能包括:

  1. 使用Next.js的SSR能力预渲染关键内容

  2. 实现预计算的绝对定位布局,替代传统Masonry

  3. 优化图片加载,使用占位符和预定义尺寸

  4. 手动控制滚动恢复,结合最后查看的文章ID定位

  5. 利用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 };

版权声明

本文为「野盐」原创内容,图片个人摄影 / 手绘 / AIGC,后期 PhotoMator / Procreate,版权归「野盐」所有。未经授权,禁止用于商业用途,禁止抹除水印,禁止转发至小红书等平台。转载请注明出处与链接并保留本声明。

...

评论 (0)

评论将以随机ID显示
野盐

野盐

@wildsalt.me

推荐阅读

Automate reddit video creation with n8n待验证
开发故事

Automate reddit video creation with n8n待验证

野盐 | 2025-06-11

"用n8n自动化工具将Reddit帖子转化为视频的完整工作流定义,包含从内容抓取到视频渲染的每个技术细节"

10
解决 AI 编程助手多轮对话后幻觉和代码乱改问题的策略
开发故事

解决 AI 编程助手多轮对话后幻觉和代码乱改问题的策略

野盐 | 2025-06-06

AI代码助手不是魔法师,而是需要精确引导的学徒——当它开始‘自由发挥’,你需要更严格的对话管理和边界设定。

10
Claude失控,所有源代码被其删除。FUCK!
开发故事

Claude失控,所有源代码被其删除。FUCK!

野盐 | 2025-06-04

技术简化变成粗暴删除:AI的‘最优解’,给我带来一次代码被删的愤怒与反思

7