小程序瀑布流:我用 170 行代码干掉了布局抖动
组件已开源:GitHub |
npm i sk-image-waterfall,MIT 协议,欢迎白嫖。
起因:一个会「跳舞」的列表
前段时间我在维护一个壁纸小程序,核心页面就是一个图片瀑布流。旧版实现现在回头看问题很典型:接口不返回图片尺寸,分列时就用 Math.random() 造一个「假高度」来决定图片归左列还是右列,真实渲染则交给 widthFix 自适应:
// 旧版分列逻辑:假高度只管分列,不管渲染
const fakeH = 600 + Math.random() * 500
if (leftH <= rightH) {
leftList.push({ img: url, height: fakeH })
leftH += fakeH
} else {
rightList.push({ img: url, height: fakeH })
rightH += fakeH
}
<t-image src="{{item.img}}" mode="widthFix" lazy-load />
真机一跑,问题就来了。widthFix 意味着卡片高度要等图片下载完才能确定,而图片是一张一张回来的,每回来一张,卡片高度就突变一次,整个列表跟着往下窜。手指正要点某张图,它「唰」地滑走了,点到了下面那张。更糟的是分列用的假高度和渲染出的真实高度是两码事,左右列的「平衡」完全是纸面上的。用户的体感就是四个字:这页面在抖。
Web 圈管这个叫 CLS(Cumulative Layout Shift),Google 把它列进了 Core Web Vitals
眼见为实,同一个页面新旧两版的实测录屏放在一起对比。左边是旧版,进场和加载更多时图片不断把列表顶开;右边是改了几版之后抽成通用组件的新版——进场即稳,骨架屏占位、图片淡入,布局全程不动
左:旧版 widthFix + 假高度 | 右:新版 sk-image-waterfall,零抖动
组件逻辑层 JS 一共 167 行,没有任何依赖。这篇文章聊聊里面几个关键决策,以及我为什么这么选。
抖动的根源,以及一个反直觉的解法
先想清楚为什么会抖:卡片高度依赖图片的真实尺寸,而真实尺寸要等图片下载完才知道。加载前高度是 0(或者一个占位值),加载后变成真实高度——这个差值就是抖动。
常规思路有两条:
- 让接口把图片宽高一起吐出来,前端按比例预先算好高度。这是最优解,但很多时候你管不了接口——我用的壁纸接口和 NASA 开放 API 都不给尺寸。
- 等所有图片加载完再一次性渲染。列表长了以后,用户盯着白屏等十几秒,等于没做。
我选了第三条路,说出来有点反直觉:不关心图片真实比例,由组件自己决定卡片高度。
const DEFAULT_RATIOS = [0.72, 0.86, 1.0, 1.16, 1.3]
// 第 idx 张图的预留比例,由序号唯一确定
const ratio = ratios[idx % ratios.length]
cell._h = Math.round(colWidth * ratio)
每个卡片的高度在数据进来那一刻就定死了:第 0 张 0.72、第 1 张 0.86、第 2 张正方形……循环往复。图片用 mode="aspectFill" 填进这个预留框里。
这么做的代价很明确:图片会被轻微裁切。但换来的是——卡片高度在图片加载前后完全一致,抖动物理上不可能发生。对于缩略图流这种场景(用户点进去才看大图),裁一点边完全可以接受,小红书、Pinterest 的信息流其实也都在裁。
这里有个容易踩的细节:比例序列必须是确定性的。旧版里 Math.random() 的教训还在眼前,可我在新组件里最初还是顺手用了随机取比例,结果下拉刷新后同一张图换了个高度,列表整体错位,看起来像 bug。改成按序号取模之后,同样的数据永远渲染出同样的布局,问题消失。
骨架屏:加载中也得体面
高度定死了,图片没回来之前框里是空的,总得放点东西。转圈的 loading 太廉价,我用了三层叠加的骨架屏:底色打底、一道斜向流光扫过、中心一圈微光。全部用 CSS 画,没有图片资源:
.wf-sk {
background-color: var(--wf-skeleton-base, #edf0f5);
background-image: linear-gradient(100deg,
rgba(255,255,255,0) 32%,
var(--wf-skeleton-shine, rgba(255,255,255,.55)) 50%,
rgba(255,255,255,0) 68%);
background-size: 200% 100%;
animation: wf-shimmer 1.3s ease-in-out infinite;
}
图片 bindload 触发后,骨架淡出、图片淡入,两个过渡都只动 opacity,走 GPU 合成,长列表滚动不掉帧。
实际效果可以看这张截图,下半屏的几个卡片还处于骨架状态,位置和尺寸已经和最终完全一致,图片回来只是「显影」,不是「撑开」:
分列策略:最矮列优先,值不值?
多列瀑布流绕不开一个问题:新来的卡片放哪一列?两种常见做法:
- 取模轮流:第 i 张放第
i % n列,O(1),无脑快; - 最矮列优先:每次找当前最矮的列放进去,多一层遍历。
最矮列优先每个元素要多扫一遍所有列,直觉上「慢」。到底慢多少、换来了什么?我写了个基准脚本跑了一下(Node 22,每轮 200 次循环、15 轮取中位数,尽量压掉 JIT 抖动):
| 数据量 | 列数 | 最矮列耗时 | 轮流耗时 | 最矮列列高差 | 轮流列高差 |
|---|---|---|---|---|---|
| 200 | 2 | 0.0011ms | 0.0009ms | 194rpx | 0rpx |
| 1000 | 2 | 0.0043ms | 0.0028ms | 194rpx | 0rpx |
| 5000 | 2 | 0.0217ms | 0.0124ms | 194rpx | 0rpx |
| 5000 | 3 | 0.0280ms | 0.0134ms | 302rpx | 457rpx |
先说耗时:轮流分配确实一直更快,符合复杂度直觉。但看绝对值——5000 条数据、也就是刷 250 页之后,最矮列优先也才 0.02ms 出头,两者的差距不到百分之一帧。找最矮列是 O(n×列数),列数就 2、3 列,谈不上性能负担。
有意思的是列高差那一栏。固定比例序列下两列轮流分配反而是 0——因为比例循环恰好被均匀拆开了,这是巧合。换成更接近真实接口的随机宽高比(0.6~1.5 随机,100 轮取平均),差距立刻拉开:
| 数据量 | 最矮列平均列高差 | 轮流平均列高差 |
|---|---|---|
| 50 | 232rpx | 549rpx |
| 200 | 207rpx | 1034rpx |
| 1000 | 219rpx | 2253rpx |
规律很清楚:轮流分配的列高差随数据量线性恶化,1000 条时两列底部能差出两三屏;而最矮列优先始终稳定在 200rpx 左右——也就是最后一行参差一点点,滚到底部不会出现一列还有内容、另一列早就空了的尴尬。
结论:这层遍历花得非常值。
加载更多 vs 切换分类:一个 token 解决状态歧义
组件只接收一个 list,但父页面改 list 的意图其实有两种:触底加载了下一页(该增量追加),或者用户切了分类(该整体重建并回顶)。只盯着数组长度猜是猜不准的——新分类恰好比旧分类数据多,你猜是追加还是重建?
我的解法是把意图显式化,加一个 resetToken:
observers: {
'list, resetToken': function (list, token) {
if (token !== this._token) {
this._token = token
this._rebuild(list) // token 变了 → 重建 + 回顶
} else if (list.length > this._count) {
this._append(list.slice(this._count)) // 只处理新增部分
}
},
}
父页面的约定简单到不会用错:加载更多就 concat,token 不动;切分类就换数据同时 token + 1。切换时新旧列表是原子替换的,不会出现先清空、白一下、再填充的闪烁。
追加路径上还有一个小程序特有的考量。_append 里我最终还是 setData({ cols }) 传了全量列数据,而不是用 cols[0].list[12] 这种路径更新逐条推。原因是权衡过数据量:
| 累计条数 | cols 全量序列化体积 |
|---|---|
| 20 条(1 页) | 5.3 KB |
| 100 条(5 页) | 26.5 KB |
| 200 条(10 页) | 53.7 KB |
| 500 条(25 页) | 135.1 KB |
微信官方建议单次 setData 别超过 256KB。500 条时 135KB,还在安全区,但曲线是线性的——这是这个组件目前诚实的边界:它适合几百条量级的信息流,如果你的场景是无限刷几千条,需要再加虚拟列表(只保留视口附近的节点)才能压住。对绝大多数「分类 + 分页」的业务,这个边界够用了。
图片加载状态的更新倒是用了路径 setData,一次只碰一个布尔值,开销可以不计:
this.setData({ [`cols[${ci}].list[${index}]._loaded`]: true })
失败也要有退路
线上图片挂掉是常态不是意外。组件在 binderror 里做了两级降级:先尝试换到备用字段(比如接口同时给了原图和缩略图,原图 404 就回退缩略图);备用也没有,就直接把骨架屏停掉——宁可露出底色,也不让流光在那儿永远扫下去,假装还在加载比加载失败更伤信任。
onError(e) {
if (cell._fallback && cell._src !== cell._fallback) {
this.setData({ [`cols[${ci}].list[${index}]._src`]: cell._fallback })
} else {
this.setData({ [`cols[${ci}].list[${index}]._loaded`]: true })
}
}
接入:五个属性起步
壁纸站重构完之后,这套组件又原封不动地用到了另一个 NASA 图像库上,接入后的样子:
页面侧的代码基本就是声明式配置:
<image-waterfall
class="flow"
list="{{list}}"
reset-token="{{token}}"
image-key="url"
title-key="name"
bind:itemtap="onItemTap"
bind:loadmore="onLoadMore"
>
<view wx:if="{{loading}}" class="tip">加载中…</view>
</image-waterfall>
上面那个深色骨架屏不是改组件源码改出来的。CSS 自定义属性可以穿透小程序的组件边界,所以主题化只需要在父级写几个变量:
.flow {
--wf-card-bg: #0f1526;
--wf-skeleton-base: #121a2e;
--wf-skeleton-glow: rgba(59, 130, 246, .22);
--wf-title-color: #cbd5e1;
}
同一个组件,前面浅色的壁纸站和这个深色的 NASA 站,改的只有这几行变量。
最后
复盘下来,这个组件里没有什么高深的算法,值钱的是几个取舍:
- 用「确定性预留高度」换零抖动,代价是 aspectFill 轻微裁切——缩略图流场景稳赚;
- 最矮列优先多一层遍历,实测 5000 条才 0.02ms,换来列高差不随数据量恶化;
- 用
resetToken把「追加」和「重建」两种意图显式分开,比猜数组长度可靠得多; - 承认边界:几百条以内很稳,几千条请上虚拟列表。
如果接口能给真实宽高,把每项的比例传进 ratios 逻辑替换掉预设档位,就能做到既不裁切也不抖动,这是留给有条件的同学的扩展点。
代码在 GitHub,npm i sk-image-waterfall 构建 npm 后即用。遇到问题欢迎提 issue。
附:性能数据可视化
上文两组基准数据的图表版(ECharts 绘制,数据来自 waterfall-bench.mjs)。
耗时曲线印证了前面的结论:取模轮流确实始终更快(虚线),但两条线的绝对差距在 5000 条时也不过 0.01ms——这点开销买不来任何用户可感知的卡顿:
而列高差是肉眼可见的差距。取模轮流(橙色)随数据量线性恶化,1000 条时已经差出 2253rpx(约三屏);最矮列优先(蓝色)始终压在 200rpx 上下:

一句话读图:耗时上两种策略是"都快"和"更快"的区别,列高上是"整齐"和"瘸腿"的区别——所以选最矮列优先。
案例地址:https://ktboy.github.io/sh-design/components/sk-image-waterfall
扫码体验:


浙公网安备 33010602011771号