小程序瀑布流:我用 170 行代码干掉了布局抖动

组件已开源:GitHubnpm 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,预留高度,零抖动

左:旧版 widthFix + 假高度 | 右:新版 sk-image-waterfall,零抖动

组件逻辑层 JS 一共 167 行,没有任何依赖。这篇文章聊聊里面几个关键决策,以及我为什么这么选。

抖动的根源,以及一个反直觉的解法

先想清楚为什么会抖:卡片高度依赖图片的真实尺寸,而真实尺寸要等图片下载完才知道。加载前高度是 0(或者一个占位值),加载后变成真实高度——这个差值就是抖动。

常规思路有两条:

  1. 让接口把图片宽高一起吐出来,前端按比例预先算好高度。这是最优解,但很多时候你管不了接口——我用的壁纸接口和 NASA 开放 API 都不给尺寸。
  2. 等所有图片加载完再一次性渲染。列表长了以后,用户盯着白屏等十几秒,等于没做。

我选了第三条路,说出来有点反直觉:不关心图片真实比例,由组件自己决定卡片高度

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 逻辑替换掉预设档位,就能做到既不裁切也不抖动,这是留给有条件的同学的扩展点。

代码在 GitHubnpm 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
扫码体验:
下载

posted @ 2026-07-25 16:20  舒克无良  阅读(9)  评论(0)    收藏  举报