双列瀑布流与图片尺寸治理:以小红书为例,把「加载完再跳位」连根拔掉
瀑布流的体验问题九成源于「图片高度未知」。这篇从小红书信息流场景出发,讲占位高度公式、贪心分列、上传时治理与懒加载的完整方案。
问题现场:三种「跳」
用过小红书的人可能都注意过这些细节——好的信息流「不跳」。具体来说,粗糙的实现有三种跳法:
- 加载跳:卡片图片没下载完时高度是 0,图一张张到位,下方内容被一次次顶下去,你刚要点的卡片跑了。
- 长短跳:简单按左右交替分列,遇到连续几张长图全落进同一列,两列长度差出一屏,视觉重心歪掉。
- 滚动跳:信息流滑深了之后掉帧、卡顿,节点数随滑动条线性增长,最后整个页面像在泥里拖。
三种跳,根子是同一个:卡片的高度在图片加载完成之前是未知的。而高度未知,占位、分列、回收全都做不了。所以「图片尺寸治理」不是细节优化,是瀑布流的地基。
根因追溯:高度信息是在哪一步丢掉的
一张图的高度,什么时候是已知的?
- 上传时:用户选完图,文件里就带着宽高——已知
- 服务端接收时:解析文件头即可拿到宽高——已知
- 列表接口返回时:只要服务端存了,就能一起返回——已知
- 浏览器下载完成时:
img.onload之后才知道——到这里才第一次「获得」本可以白拿的信息
问题就在于:前三个环节信息都在,却被丢掉了;前端非等到最后一个环节(下载完)才去「重新发现」它,然后被迫用布局抖动来消化这个发现。
治理思路因此一句话可以概括:信息最早在哪一步已知,就在哪一步存储。宽高在上传时解析入库,列表接口随笔记一起返回,前端拿到的每条数据天然带着 width 和 height。
核心公式:占位高度 = 列宽 × 高 ÷ 宽
拿到服务端返回的宽高后,前端在图片下载前就能算出它的展示高度:
// 双列布局:列宽 = (容器宽 - 列间距) / 2
const COLUMN_GAP = 8;
const columnWidth = (containerWidth - COLUMN_GAP) / 2;
// 占位高度:按宽高比等比撑开,图片加载前后尺寸完全一致
function placeholderHeight(width: number, height: number): number {
return Math.round((columnWidth * height) / width);
}渲染时先用这个高度撑出占位块(纯色,与图片主色接近最佳),图片加载完成后原位替换,零位移。用户从头到尾点中的就是他看到的那张卡。
占位块有两个讲究:
- 纯色,不用转圈动画。loading 菊花会让页面「显得」更慢;一块安静的色砖反而让布局先稳下来。
- 失败时占位保留。图片挂了就地在占位区显示统一失败图标,卡片不塌、下方不跳——这是「治理」和「裸奔」的分水岭。
分列算法:新卡片进「更矮的那一列」
左右交替分列(1、3、5 进左,2、4、6 进右)是最直觉的实现,也是最容易失衡的:遇到「长、短、长、短」的序列倒还均衡,遇到「长、长、长」就全堆一列。
正确做法是维护两列各自的累计高度,新卡片进矮的一列:
class Waterfall {
private left: HTMLElement[] = [];
private right: HTMLElement[] = [];
private leftHeight = 0;
private rightHeight = 0;
add(card: HTMLElement, height: number) {
if (this.leftHeight <= this.rightHeight) {
this.left.push(card);
this.leftHeight += height + GAP;
} else {
this.right.push(card);
this.rightHeight += height + GAP;
}
}
}一次高度比较,O(1) 成本,换来两列长度始终接近。这个算法能成立的前提,正是上一节的公式——卡片高度必须可预先计算,否则「更矮的一列」根本无从比较。
上传时治理:把问题消灭在上传那一刻
尺寸治理的主战场其实在客户端之外。以小红书的发布链路为例,服务端要做四件事:
- 解析宽高入库:接收图片时读文件头,拿到真实宽高,随笔记元数据落库。列表接口原样返回。
- 合法性校验:宽高为 0、宽高比超过阈值(比如 1:10 的「火腿肠」)的图,要么拒绝、要么服务端裁剪。极端比例不算错数据,但能毁掉版式。
- 生成缩略图:上传时按目标规格(列表列宽的 2 倍,照顾高清屏)生成缩略图,原图保留给详情页。列表永远用缩略图——单张原图两三兆,首屏十张就是二三十兆流量。
- 历史数据回扫:上线宽高字段前的老笔记怎么办?写脚本回扫补齐。断言标准很简单:补齐后老内容不再跳。
第 3 条值得展开:缩略图必须上传时生成一次,而不是请求时实时缩放。实时缩放等于把计算成本按请求次数反复支付,还拖慢首屏;上传时生成是「一次付费,终身使用」。
懒加载与节点回收:让长列表滑得动
尺寸治理的最后一个红利是「可回收」。v-for 全量渲染的信息流,节点数随数据量线性增长——滑到几百条时 DOM 节点几千个,不掉帧才怪。但如果每张卡片的高度都精确可知,就可以:
- 懒加载:只请求视口附近(加上一屏缓冲)的图片,避免打开首页就发起几十个请求互相抢带宽。注意预加载不是免费的——它抢的是当前正要用的带宽。
- 节点回收:只保留视口附近 ±N 屏的 DOM 节点,滑远的移除、滑近的按数据重建。高度可精确计算是回收能成立的前提:不然节点一移除,滚动高度就对不上了。
配合骨架屏使用时,骨架要和真实结构一致——双列瀑布流就画双列卡片轮廓,数据到达后平滑替换,避免「骨架在中间、内容在两边」的二次跳。
常见质疑与回应
「CSS columns 不是更简单吗?」
columns 的均衡是浏览器按文档流自动平衡的,你无法指定「这张进更矮的那列」,也没法配合节点回收。它能做出瀑布流的形,做不出可控的瀑布流。
「等高 Grid 一行一行排,高度天然确定,不香吗?」 香,但它不是瀑布流——长图会被裁得只剩中间一条,短图周围留白。小红书的信息流,图就是内容本身,裁图等于裁内容。
「前端拿到高度也能算,为什么非得服务端给?」 前端能算,但得先下载。服务端给的是「下载前就算」,这是零跳位和加载完再跳位的区别。况且原图两兆起步,为了算个高度先下一遍原图,本末倒置。
「老数据没宽高,先前端兜底行不行?」 可以临时兜底(固定比例占位 + 加载后修正),但修正那一刻还是会跳。兜底是争取时间的,回扫补齐才是根治。
小结
把整条链路串起来,治理的层次其实很清晰:
| 环节 | 动作 | 解决的问题 |
|---|---|---|
| 上传时 | 解析宽高入库 + 合法性校验 + 生成缩略图 | 高度信息不丢失、极端比例有防御、流量有节约 |
| 列表接口 | 返回宽高与缩略图地址 | 前端下载前即可计算布局 |
| 前端渲染 | 占位公式 + 贪心分列 + 纯色占位 | 加载零位移、两列均衡 |
| 前端滚动 | 懒加载 + 节点回收 + 一致骨架屏 | 长列表不卡、节点数有界 |
瀑布流是个「看起来全靠 CSS、实际上全靠数据」的组件。图片尺寸治理的本质,是把「下载时才知道的事」提前到「上传时就决定好」——前端能做的优化,上限常常在链路更上游的地方。