菜单导航

开发故事
Masonry智能预定位策略
开发故事

Masonry智能预定位策略

野盐
2025-05-23

如果等待整个瀑布流重新布局完成,用户会看到明显的"从顶部滚动到目标位置"的过程,体验很糟糕。

思考一个更好的解决方案,采用了智能预定位策略,虚拟滚动 + 预渲染目标区域,避免肉眼可见的滚动过程:

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-changetransform: translateZ(0) 启用硬件加速

  • 分批渲染避免一次性处理大量DOM

使用效果,用户返回时的体验:

  1. 0ms: 页面立即出现在正确的滚动位置

  2. 50ms: 目标区域内容精确呈现

  3. 200ms: 其他内容静默加载完成

  4. 整个过程: 用户完全感知不到页面重建

这种方式即使有上千个元素也不会有明显的滚动过程,因为绕过了传统的"等待布局完成再滚动"的模式。

示例代码:

// 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);
}

版权声明

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

...

评论 (0)

评论将以随机ID显示
野盐

野盐

@wildsalt.me

推荐阅读

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

Automate reddit video creation with n8n待验证

野盐 | 2025-06-11

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

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

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

野盐 | 2025-06-06

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

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

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

野盐 | 2025-06-04

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

7