1101 字
6 分钟
实现 Tumbex 的瀑布流效果
Tumbex 的效果(NSFW):https://www.tumbex.com/morgansq480x480.tumblr/posts
拆解
特性可以总结成以下几点:
- 项目分成 N 列,加载时会往最短的那列添加项目
- 支持 responsive,通过 breakpoint 去控制大小屏显示的列数
- 容器动态高度,会随着项目的尺寸去计算
- 支持窗口尺寸随时变化
- 无限加载
通过 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);
说一下变量和流程吧。
变量:
container
:瀑布流容器breakpoints
:尺寸断点,控制在不同断点下显示多少列gap
:项目之间的间隔currentColumns
:当前显示的列数columnWidth
:单列的宽度columnHeights
:每一列的高度数组resizeObserver
:尺寸变化的观察者实例
流程:
- 构造函数中初始化变量
- 先对当前的环境进行
breakpoint
的确认,确定列数,然后进行摆放 - 之后会注册一个
resizeObserver
去监听容器的变化,因为容器不一定是body
,所以不能简单用resize
事件,变化时调用handleResize
(实际上就是回到步骤 2)handleResize
会先根据新尺寸找到断点,更新总列数,把columnHeights
清零,之后通过reflowAllItems
重新摆放项目- 所以这一步在整体中开销比较大,可以稍微加上
debounce
优化,但由于用的是transform
并不会反复进行 layout,属于是开销大但又没那么大 - 摆放完成后通过
updateContainerHeight
重新计算容器的总高度
addItem
是对外暴露的添加项目的方法,他会负责找到项目内的img
并等待图片加载,因为摆放需要得到项目的高度,所以不得不等待一切异步的内容
效果预览
实现 Tumbex 的瀑布流效果
https://blog.erio.work/posts/实现tumbex的瀑布流效果/