1101 字
6 分钟
实现 Tumbex 的瀑布流效果

Tumbex 的效果(NSFW):https://www.tumbex.com/morgansq480x480.tumblr/posts

拆解#

特性可以总结成以下几点:

  1. 项目分成 N 列,加载时会往最短的那列添加项目
  2. 支持 responsive,通过 breakpoint 去控制大小屏显示的列数
  3. 容器动态高度,会随着项目的尺寸去计算
  4. 支持窗口尺寸随时变化
  5. 无限加载

通过 F12 检查,可以发现他是通过 transform 实现的,而不是 grid

简单来说,每个项目都是 absolute 定位的,通过 translate 去计算自己的位置,去确定新的项目应该加到哪一列上。

responsive 是通过媒体查询 @media 和百分比宽度实现的,最小显示一列,最大显示九列,再大的屏幕上项目宽度也只会是 11.11111%,此时对于过宽的项目,其中的图片会横向显示成多张(内容的显示是另一个主题了)。

实现#

基于以上的分析和拆解,可以写出一套 waterfall 类:

interface Breakpoint {
  minWidth: number;
  columns: number;
}

class Waterfall {
  private container: HTMLElement;
  private breakpoints: Breakpoint[];
  private gap: number;
  private currentColumns!: number;
  private columnWidth!: number;
  private columnHeights: number[] = [];
  private resizeObserver: ResizeObserver;

  constructor(container: HTMLElement, breakpoints: Breakpoint[] = [], gap: number = 10) {
    this.container = container;
    this.breakpoints = this.normalizeBreakpoints(breakpoints);
    this.gap = gap;
    this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
    this.init();
  }

  private normalizeBreakpoints(breakpoints: Breakpoint[]): Breakpoint[] {
    // 默认断点
    const defaultBreakpoints = [
      { minWidth: 0, columns: 1 },
      { minWidth: 768, columns: 2 },
      { minWidth: 1024, columns: 3 }
    ];
    
    return [...defaultBreakpoints, ...breakpoints]
      .sort((a, b) => a.minWidth - b.minWidth)
      .filter((v, i, arr) => 
        arr.findIndex(t => t.minWidth === v.minWidth) === i
      );
  }

  private init(): void {
    this.container.style.position = 'relative';
    this.resizeObserver.observe(this.container);
    this.updateColumns();
    this.calculateLayout();
  }

  // 处理窗口尺寸变化
  private handleResize(entries: ResizeObserverEntry[]): void {
    entries.forEach(entry => {
      if (entry.contentRect.width !== this.container.offsetWidth) {
        this.updateColumns();
        this.calculateLayout();
      }
    });
  }

  // 更新列数
  private updateColumns(): void {
    const containerWidth = this.container.offsetWidth;
    const validBreakpoints = this.breakpoints.filter(bp => bp.minWidth <= containerWidth);
    const newColumns = validBreakpoints.reduce((prev, curr) => 
      curr.minWidth > prev.minWidth ? curr : prev
    ).columns;

    if (newColumns !== this.currentColumns) {
      this.currentColumns = newColumns;
      this.columnHeights = new Array(this.currentColumns).fill(0);
    }
  }
  
  private calculateLayout(): void {
    this.calculateColumnWidth();
    this.reflowAllItems();
  }

  private calculateColumnWidth(): void {
    const containerWidth = this.container.offsetWidth;
    this.columnWidth = (containerWidth - (this.currentColumns - 1) * this.gap) / this.currentColumns;
  }

  public addItem(item: HTMLElement): void {
    item.style.position = 'absolute';
    item.style.width = `${this.columnWidth}px`;

    const images = item.getElementsByTagName('img');
    if (images.length > 0) {
      this.waitForImages(item, images).then(() => this.placeItem(item));
    } else {
      this.placeItem(item);
    }
  }

  // 等待图片加载
  private async waitForImages(item: HTMLElement, images: HTMLCollectionOf<HTMLImageElement>): Promise<void> {
    return new Promise(resolve => {
      let loaded = 0;
      const checkLoad = () => ++loaded === images.length && resolve();

      Array.from(images).forEach(img => {
        if (img.complete) checkLoad();
        else img.addEventListener('load', checkLoad);
      });
    });
  }

  private placeItem(item: HTMLElement): void {
    const minHeight = Math.min(...this.columnHeights);
    const columnIndex = this.columnHeights.indexOf(minHeight);
    const top = minHeight + (minHeight === 0 ? 0 : this.gap);
    const left = columnIndex * (this.columnWidth + this.gap);

    item.style.transform = `translate3d(${left}px, ${top}px, 0)`;
    this.container.appendChild(item);
    
    this.columnHeights[columnIndex] = top + item.offsetHeight;
    this.updateContainerHeight();
  }

  private reflowAllItems(): void {
    this.columnHeights = new Array(this.currentColumns).fill(0);
    const items = Array.from(this.container.children) as HTMLElement[];
    
    items.forEach(item => {
      item.style.width = `${this.columnWidth}px`;
      const itemHeight = item.offsetHeight;
      
      const minHeight = Math.min(...this.columnHeights);
      const columnIndex = this.columnHeights.indexOf(minHeight);
      const top = minHeight + (minHeight === 0 ? 0 : this.gap);
      const left = columnIndex * (this.columnWidth + this.gap);

      item.style.transform = `translate3d(${left}px, ${top}px, 0)`;
      this.columnHeights[columnIndex] = top + itemHeight;
    });
    
    this.updateContainerHeight();
  }

  private updateContainerHeight(): void {
    const maxHeight = Math.max(...this.columnHeights);
    this.container.style.minHeight = `${maxHeight}px`;
  }

  public destroy(): void {
    this.resizeObserver.unobserve(this.container);
  }
}

// 使用示例
const container = document.getElementById('waterfall-container') as HTMLElement;

// 自定义断点配置(可选)
const customBreakpoints = [
  { minWidth: 0, columns: 1 },    // < 600px: 1列
  { minWidth: 600, columns: 2 },  // ≥600px: 2列
  { minWidth: 900, columns: 3 },  // ≥900px: 3列
  { minWidth: 1200, columns: 4 }  // ≥1200px: 4列
];

const waterfall = new Waterfall(container, customBreakpoints, 15);

说一下变量和流程吧。

变量:

  1. container:瀑布流容器
  2. breakpoints:尺寸断点,控制在不同断点下显示多少列
  3. gap:项目之间的间隔
  4. currentColumns:当前显示的列数
  5. columnWidth:单列的宽度
  6. columnHeights:每一列的高度数组
  7. resizeObserver:尺寸变化的观察者实例

流程:

  1. 构造函数中初始化变量
  2. 先对当前的环境进行 breakpoint 的确认,确定列数,然后进行摆放
  3. 之后会注册一个 resizeObserver 去监听容器的变化,因为容器不一定是 body,所以不能简单用 resize 事件,变化时调用 handleResize(实际上就是回到步骤 2)
    1. handleResize 会先根据新尺寸找到断点,更新总列数,把 columnHeights 清零,之后通过 reflowAllItems 重新摆放项目
    2. 所以这一步在整体中开销比较大,可以稍微加上 debounce 优化,但由于用的是 transform 并不会反复进行 layout,属于是开销大但又没那么大
    3. 摆放完成后通过 updateContainerHeight 重新计算容器的总高度
  4. addItem 是对外暴露的添加项目的方法,他会负责找到项目内的 img 并等待图片加载,因为摆放需要得到项目的高度,所以不得不等待一切异步的内容

效果预览#

实现 Tumbex 的瀑布流效果
https://blog.erio.work/posts/实现tumbex的瀑布流效果/
作者
Dupfioire
发布于
2025-04-11
许可协议
CC BY-NC-SA 4.0