1. 为什么移动端的瀑布流这么难做这两年做移动端项目,几乎避不开瀑布流。电商首页的信息流、内容社区的笔记墙、甚至是工具类App的模板市场,都清一色地采用这种错落有致的双列或多列布局。在做某跨平台资讯应用的改版时,我在UNIAPP环境里重新实现了这套列表,同时要求覆盖APP、微信小程序和H5三个端。过去这方面的经验多半停留在单端开发,真正在UNIAPP里做一次三端统一,还是踩了不少坑,也沉淀下一些有意思的思路。先说结论:UNIAPP里的瀑布流,问题不在于“能不能写出来”,而在于“如何让它在三个端都稳定高效地运行”。语法层面的差异其实不大,真正决定方案差异的,是各端运行时对渲染、事件、图片加载的处理机制完全不同。微信小程序没有真实的DOM,H5天然依赖DOM,APP端又夹在两者之间,所以很多DOM时代的“成熟方案”在UNIAPP里只能重写。解决这个问题的核心思路只有一个——不要试图去操作DOM,而是把数据分成若干列,在逻辑层维护好每一列的数据结构,由框架负责把“列数组”渲染成视图。这个思路一确定,后续的页面布局、性能调优、跨端兼容都围绕它展开。2. 一种主流实现的逻辑拆解2.1 双列数组的“笨办法”为什么最好用常见实现瀑布流的方式有两种。一种是拿到所有数据,计算每个元素的高度,然后用绝对定位+动态设置 top 值来铺排;另一种是维护多个列数组,新数据来了按某种策略分流到其中一列,然后页面渲染两个或三个列表。我选的是第二种,这也是实战中测试下来最稳定、最容易跨端的一种方案。第一种方案确实视觉效果最精准,因为可以精确控制每个卡片的落点。但它的问题非常突出:在微信小程序里,absolutely 定位依赖精确的像素计算,而小程序里拿不到精确的渲染高度,只能通过图片loading后拿到宽高比去推算。任何一点字体、间距、适配偏差,都会造成后边的卡片整体错位。维护成本高,而且一旦数据分页加载,前面一列的卡片高度变了,后边所有卡片都要重算,性能也很糟。第二种方案牺牲一点“准确落位”的自由度,但换来的是稳定性和极低的冲突概率。核心逻辑只有两行:先比较当前两个分组的高度总和,哪边短,新数据就插到哪边。这也是我最终在方案选型里写进文档的推荐做法。2.2 为什么要“按图片比例动态分流”如果只按顺序轮询分配,每一列的高度会随着卡片内容的长度差异逐渐拉开差距,最直观的结果是:左边特别长,右边特别短,视觉效果退化成了双列拼图而不是瀑布流。所以分流的依据必须动态化。每来一条新数据,都应该先计算它的预估高度,再拼到当前高度较低的那一列。预估高度怎么算?核心是拿到图片的宽高比。因为瀑布流卡片的内容除了标题和描述文字,高度占比最大的就是图。图片加载成功后,用实际高度 = 容器宽度 / (原始宽度 / 原始高度),就能比较准确地判断这张卡片会把列拉长多少。这个思路在UNIAPP里落地其实方便很多,因为uni.getImageInfo在三个端都有完整实现,不用自己写平台判断。只要拿到图片的宽高,剩下的就是简单算术问题。3. 从零搭建一个可复用的瀑布流页面3.1 数据结构的规划比写渲染代码更重要很多第一次接触瀑布流的人,往往一上来就埋头写模板,结果写到一半发现数据没法渲染。原因是模板结构没有围绕“列”来组织。这里提供一个常规且可行的思路。页面数据不用做复杂嵌套,直接用两个平铺的数组字段:data() { return { leftList: [], // 左侧列数据 rightList: [] // 右侧列数据 } }模板方面也不用循环嵌套来搞两层列表,直接在布局里放置两个滚动容器或两列view,分别绑定leftList和rightList即可。这里有一个细节,很多初次尝试的人很容易忽略:列表里的每一项,不只是原始的业务数据,还要带上一个图片的宽高比字段,因为后面排序要用到。所以在数据进入leftList和rightList之前,就要把它加工成一棵新结构:function formatCardData(item, imgRatio) { return { ...item, imgRatio: imgRatio, estimatedHeight: imgRatio ? 200 / imgRatio : 200 } }这里的200是布局卡片中图片区域的预设宽度,你也可以从uni.getSystemInfoSync().windowWidth里去计算实际值。3.2 核心分流函数,能简化就不要复杂化用了很久,最终我留下的分流函数大概长这样:function pushToShorterColumn(leftArr, rightArr, newItem) { const leftHeight = leftArr.reduce((sum, item) => sum + item.estimatedHeight, 0); const rightHeight = rightArr.reduce((sum, item) => sum + item.estimatedHeight, 0); if (leftHeight <= rightHeight) { leftArr.push(newItem); } else { rightArr.push(newItem); } }这已经足够用了。不必纠结它是否最高效,因为在业务场景里,一次分页加载二十条数据量是绝对的小数值,reduce的计算开销可以忽略不计。比较微妙的地方在于“预估高度怎么算”。建议不要只看图片的高度,而是加上文字部分的基础高度。很多文章里能看到的做法是图片高度乘以一个固定系数,实测下来文字行数、字体大小、上下间距、卡片内边距都会影响尺寸,全靠系数兜底还是不够准。更稳一点的做法,是把卡片的整体结构和样式固定下来,这样文字区域高度基本也可预判。3.3 结合滚动加载的完整交互流程瀑布流从来不是一次性的数据铺陈,而是伴随滚动持续追加内容的交互模型。所以在瀑布流实现里,加载更多机制和分列数组是绑在一起的。流程可以概括成五步:页面初次加载(onLoad),请求第一页数据,所有图片异步获取宽高比。图片宽高比全部拿到之后,才正式走pushToShorterColumn逻辑,分列渲染。用户滚动到接近页面底部时,触发onReachBottom(或scorll-view的触底事件)。执行第二页请求,新数据再次进入分流函数。数据追加完成,更新leftList和rightList,表格视图自动增长。这里有一个值得注意的细节:第二、三页的数据如果图片加载不出来,宽高比怎么处理?我通常的做法是给一个默认值1.2(接近常规竖图的比例),这样即使某条图文加载慢,也不会让卡片位置产生肉眼可见的偏移。function fetchPageData(pageNum) { const data = request(`/api/list?page=${pageNum}`); const listWithRatio = data.map(item => { const ratio = getImageRatio(item.coverUrl); return formatCardData(item, ratio); }); listWithRatio.forEach(item => pushToShorterColumn(this.leftList, this.rightList, item)); }4. 跨端兼容,UNIAPP里真正考验功力的地方4.1 微信小程序里“不能用DOM”怎么破瀑布流的最初版本,想模拟网页端常见的做法:拿到列表项的所有DOM,遍历读取每项高度,然后填充到偏移变量里。但跑在小程序里这一步直接失败了,原因很简单:document.querySelectorAll在小程序环境里压根不存在。即便换成uni.createSelectorQuery(),也只能拿到指定节点的固定信息,无法在数据动态变化后自动跟踪。UNIAPP官方早就给了一套“利用组件自身布局特性”的解法:不要获取任何布局信息,直接用flex布局的flex-direction: row+align-items: flex-start,让两列独立。这一步很关键:使用flex的align-items: flex-start可以避免两列被默认拉伸成等高,这是实现视觉上“参差感”的基础。4.2 滚动容器差异:页滚动与局部滚动另一个在初期技术上吃了一惊的差异点是滚动容器。在H5端,我们习惯用页面级滚动,因为性能好,window.scroll自然触发底部加载事件。但在App端,偶尔会遇到页面级滚动不够顺滑的情况,尤其是在复杂内容较多时。此时可以用scroll-view局部滚动兜底。<scroll-view scroll-y="true" style="height: calc(100vh - 44px)" @scrolltolower="loadMore"> <view class="waterfall-container"> <view class="waterfall-column" v-for="(item, index) in leftList" :key="index"> <!-- 左侧卡片 --> </view> <view class="waterfall-column" v-for="(item, index) in rightList" :key="index"> <!-- 右侧卡片 --> </view> </view> </scroll-view>这里需要注意的是scroll-view设置高度的方式。如果直接写height: 100vh,在小程序端会包含底部导航栏的空间,滚动底部会被遮挡。我通常的做法是把它放在页面内,并让父容器占满全屏,scroll-view的高度选择flex: 1这类自适应方案,而不是写死值。4.3 各端图片缓存与加载时序的微妙不同图片加载是瀑布流里最容易被忽略但影响最大的因素。在微信小程序里,图片加载失败有个问题:onerror能触发,但图片不会自动重试。在H5端,通常浏览器本身就有较强的缓存策略,图片基本不会重复请求;App端则会受本地缓存和内存压力影响,出现快速滑动时图片闪烁的情况。为了应对这种差异,一定要使用统一封装好的图片组件,而不是在模板里直接写<image>。比如增加一个lazy-load属性(小程序支持),在H5端则叠加一个自定义的占位背景色。占位背景色最好从图片主色调中提取,或者统一用浅灰色系,这样瀑布流滚动时,整列的视觉偏差不会太大。直接留白的话,在深色主题下尤其难看。还有一个常常踩坑的细节:小程序里uni.getImageInfo对部分网络图片域名有一些限制,如果图片的域名不在request白名单,uni.getImageInfo会返回失败。这种情况建议在图片URL的拼接层就直接统一校验,提前过滤掉非法外链。5. 性能优化,让瀑布流滚动得像原生一样顺5.1 用“提前分页”避免一次性渲染过多节点瀑布流页面最怕的事项之一,是数据一次性铺满。很多年轻开发者把第一页数据量直接写成50或者100,结果在低端安卓机上页面直接卡到1秒才能响应点击事件。合理的做法是第一页只请求20条左右,这个数量在大多数机型上都能保持滑动的流畅度。不要试图靠一次请求把所有数据拿回来,然后前端不断分页。那样只是把渲染压力变成了加载后的卡顿,该卡还是会卡。比较务实的三端通用策略是两条:控制每页数据量,首页控制在20条以内,后续页同样量级,不要贪多;图片统一开启懒加载,让视口之外的卡片不要触发图片下载。5.2 减少列表项的重复渲染瀑布流卡片往往包含图片、标题、标签甚至点赞操作。如果整列数组有任何一个字段变化,比如点赞数从88变成89,整个卡片就会重新渲染一遍,在App端还会引发额外的原生层通信。所以卡片尽量拆成独立的子组件,并通过props传入数据。这样点赞刷新只会在局部组件内触发diff,不会把整列数据重新计算。另外key的选取也必须谨慎,建议用业务数据里的唯一ID,而不是列表内的索引index。在小程序里,用索引做key会导致数据错乱的风险比H5高很多,因为小程序端的组件树复用机制和H5完全不同。5.3 图片压缩得当,瀑布流成功一半只要是互联网用户,就不会喜欢看到一个瀑布流页面,图片加载一次要两秒。所以在服务端返回图片URL时,就要求图片接口支持按尺寸裁剪或按质量压缩。举例来说,列表里的封面图宽度只要300px,就不要直接拿原图地址去加载。在UNIAPP的三端里,加载这样压缩后的图片,渲染内存的压力能下降百分之三四十。尤其是App端,如果原图太大,非常容易发生内存紧张而白屏。图片地址拼接这类规则建议在项目里做一个单独的工具函数,比如:function getThumbUrl(url, width = 300) { if (url.includes('?x-oss-process')) { return url + `&x-oss-process=image/resize,w_${width}`; } return url + `?x-oss-process=image/resize,w_${width}`; }不同的云存储服务有各自的图片处理参数,这里只为示意,实际项目中最好把图片服务商的相关参数整理成文档,前后端对齐。6. 常见布局问题与排查手册6.1 为什么H5端正常,小程序端列高总是对不齐这个问题本质上不是样式文件的差异,而是两端默认的盒模型行为不同。小程序端的view组件默认是box-sizing: border-box,但H5端的默认行为会受到外层样式影响。解决方式是在全局样式中为所有元素强制设置统一盒模型:view, text, image { box-sizing: border-box; }这个样式在项目创建初期就应该内置到通用样式文件里,能省掉后面一大半的布局对齐问题。6.2 图片加载慢,列高一直跳来跳去当多个图片没有同步拿到宽高比时,估算高度和实际高度之间会有较大反差,视觉表现就是列表元素不停地上下跳。解决思路是加一个“等待数据齐整”的机制:把第一屏的sekumpulan数据里图片全部加载完成后,才执行真正的分发渲染。async function init() { const list = await request('/api/list?page=1'); const result = await Promise.all(list.map(item => prepareItem(item))); result.forEach(item => pushToShorterColumn(this.leftList, this.rightList, item)); }prepareItem里重点做图片宽高获取和比例计算。如果不想等全部完成,也可以分批渲染,但必须保证第一批的列高差距尽量接近,不然初始状态就很丑。6.3 页面底部加一条“加载中”还是“没有更多了”这个设计在普通列表里很直接,但在瀑布流里要格外注意放置位置。如果加载状态放在最底部,在滚动还未到底的时候就很容易提前看到;如果放在两列中间,又可能破坏布局。我的经验是:加载状态作为一个独立组件放在父容器底部,用visibility控制显示/隐藏。这样做三个端统一,不用特别去适配两列的相对高度。6.4 内存猛涨,反复滑动后越来越卡这通常是因为页面卸载时没有释放图片资源。UNIAPP的App端里,如果用了<image>组件加载了大量本地缓存图,建议在页面onUnload生命周期里手动清理掉不用的列表数据,并解除图片的事件绑定:onUnload() { this.leftList = []; this.rightList = []; }固然这是一种很武断的做法,但对防止内存泄漏非常有效。7. 我测出来的几个实用技巧如果把整个瀑布流实现的头发丝都捋一遍,最后剩下的实践技巧其实非常有限,但每一个都很值钱。第一,在拿到设计稿之后,优先确定卡片的结构是“固定文字区域高度”还是“可变文字区域高度”。尽量让文字部分定为一行或两行截断。这样图片比例的预估几乎可以准确到像素级,分列之后的整体平衡度会好非常多。第二,不要迷信“图片原比例+无限高度”的模式。很多好看的设计里图片本身就是正方形或固定比例,瀑布流看起来依然是瀑布,根本不妨碍美观。固定比例的好处是彻底消灭了“加载后跳高”的问题,代码稳定性和用户体验能同时保住。第三,关于滚动触底:小程序里onReachBottom的触发时机和H5端浏览器不完全一致,往往H5端要滑得更深才会触发。所以当项目需要三端体验完全一致时,统一改成scroll-view的做法比较稳妥。当然,这要求页面上没有其他横向滚动的区域干扰。第四,涉及分页加载时,尽量用page参数来跟踪当前页码,而不是在函数里维护局部变量。因为瀑布流页面通常还会包含下拉刷新、位置恢复、回跳定位等额外功能,统一用页面级的响应式数据去管理状态,后续扩展会更顺畅。8. 最后再分享一个实际项目里的收尾细节这个项目后续还做了一个收尾处理——分页加载失败时,要把已经加载过的上一页数据保留,同时给用户一个底部“加载失败,点击重试”的按钮。在瀑布流的场景里,失败重试如果设计得不合理,很容易出现用户往下滑了很远,结果上一页的数据被清空,只看到一片空白。这个反例在我早期做某资讯类项目时遇到过,当时用户反馈“刷着刷着内容就没了”,体验很受打击。所以处理方式应该是在loadMore失败时,不重置leftList和rightList,而是更新一个独立的loadError状态。重试时,基于当前已有效数据继续追加,不去重新初始化。在实际开发里,瀑布流和普通列表虽然是同源的,但细节的坑真的是做了才懂。希望这篇内容能帮你少走一些弯路,让移动端的瀑布流成为一套稳定、流畅、高复用性的基础组件。
1. 为什么移动端的瀑布流这么难做这两年做移动端项目,几乎避不开瀑布流。电商首页的信息流、内容社区的笔记墙、甚至是工具类App的模板市场,都清一色地采用这种错落有致的双列或多列布局。在做某跨平台资讯应用的改版时,我在UNIAPP环境里重新实现了这套列表,同时要求覆盖APP、微信小程序和H5三个端。过去这方面的经验多半停留在单端开发,真正在UNIAPP里做一次三端统一,还是踩了不少坑,也沉淀下一些有意思的思路。先说结论:UNIAPP里的瀑布流,问题不在于“能不能写出来”,而在于“如何让它在三个端都稳定高效地运行”。语法层面的差异其实不大,真正决定方案差异的,是各端运行时对渲染、事件、图片加载的处理机制完全不同。微信小程序没有真实的DOM,H5天然依赖DOM,APP端又夹在两者之间,所以很多DOM时代的“成熟方案”在UNIAPP里只能重写。解决这个问题的核心思路只有一个——不要试图去操作DOM,而是把数据分成若干列,在逻辑层维护好每一列的数据结构,由框架负责把“列数组”渲染成视图。这个思路一确定,后续的页面布局、性能调优、跨端兼容都围绕它展开。2. 一种主流实现的逻辑拆解2.1 双列数组的“笨办法”为什么最好用常见实现瀑布流的方式有两种。一种是拿到所有数据,计算每个元素的高度,然后用绝对定位+动态设置 top 值来铺排;另一种是维护多个列数组,新数据来了按某种策略分流到其中一列,然后页面渲染两个或三个列表。我选的是第二种,这也是实战中测试下来最稳定、最容易跨端的一种方案。第一种方案确实视觉效果最精准,因为可以精确控制每个卡片的落点。但它的问题非常突出:在微信小程序里,absolutely 定位依赖精确的像素计算,而小程序里拿不到精确的渲染高度,只能通过图片loading后拿到宽高比去推算。任何一点字体、间距、适配偏差,都会造成后边的卡片整体错位。维护成本高,而且一旦数据分页加载,前面一列的卡片高度变了,后边所有卡片都要重算,性能也很糟。第二种方案牺牲一点“准确落位”的自由度,但换来的是稳定性和极低的冲突概率。核心逻辑只有两行:先比较当前两个分组的高度总和,哪边短,新数据就插到哪边。这也是我最终在方案选型里写进文档的推荐做法。2.2 为什么要“按图片比例动态分流”如果只按顺序轮询分配,每一列的高度会随着卡片内容的长度差异逐渐拉开差距,最直观的结果是:左边特别长,右边特别短,视觉效果退化成了双列拼图而不是瀑布流。所以分流的依据必须动态化。每来一条新数据,都应该先计算它的预估高度,再拼到当前高度较低的那一列。预估高度怎么算?核心是拿到图片的宽高比。因为瀑布流卡片的内容除了标题和描述文字,高度占比最大的就是图。图片加载成功后,用实际高度 = 容器宽度 / (原始宽度 / 原始高度),就能比较准确地判断这张卡片会把列拉长多少。这个思路在UNIAPP里落地其实方便很多,因为uni.getImageInfo在三个端都有完整实现,不用自己写平台判断。只要拿到图片的宽高,剩下的就是简单算术问题。3. 从零搭建一个可复用的瀑布流页面3.1 数据结构的规划比写渲染代码更重要很多第一次接触瀑布流的人,往往一上来就埋头写模板,结果写到一半发现数据没法渲染。原因是模板结构没有围绕“列”来组织。这里提供一个常规且可行的思路。页面数据不用做复杂嵌套,直接用两个平铺的数组字段:data() { return { leftList: [], // 左侧列数据 rightList: [] // 右侧列数据 } }模板方面也不用循环嵌套来搞两层列表,直接在布局里放置两个滚动容器或两列view,分别绑定leftList和rightList即可。这里有一个细节,很多初次尝试的人很容易忽略:列表里的每一项,不只是原始的业务数据,还要带上一个图片的宽高比字段,因为后面排序要用到。所以在数据进入leftList和rightList之前,就要把它加工成一棵新结构:function formatCardData(item, imgRatio) { return { ...item, imgRatio: imgRatio, estimatedHeight: imgRatio ? 200 / imgRatio : 200 } }这里的200是布局卡片中图片区域的预设宽度,你也可以从uni.getSystemInfoSync().windowWidth里去计算实际值。3.2 核心分流函数,能简化就不要复杂化用了很久,最终我留下的分流函数大概长这样:function pushToShorterColumn(leftArr, rightArr, newItem) { const leftHeight = leftArr.reduce((sum, item) => sum + item.estimatedHeight, 0); const rightHeight = rightArr.reduce((sum, item) => sum + item.estimatedHeight, 0); if (leftHeight <= rightHeight) { leftArr.push(newItem); } else { rightArr.push(newItem); } }这已经足够用了。不必纠结它是否最高效,因为在业务场景里,一次分页加载二十条数据量是绝对的小数值,reduce的计算开销可以忽略不计。比较微妙的地方在于“预估高度怎么算”。建议不要只看图片的高度,而是加上文字部分的基础高度。很多文章里能看到的做法是图片高度乘以一个固定系数,实测下来文字行数、字体大小、上下间距、卡片内边距都会影响尺寸,全靠系数兜底还是不够准。更稳一点的做法,是把卡片的整体结构和样式固定下来,这样文字区域高度基本也可预判。3.3 结合滚动加载的完整交互流程瀑布流从来不是一次性的数据铺陈,而是伴随滚动持续追加内容的交互模型。所以在瀑布流实现里,加载更多机制和分列数组是绑在一起的。流程可以概括成五步:页面初次加载(onLoad),请求第一页数据,所有图片异步获取宽高比。图片宽高比全部拿到之后,才正式走pushToShorterColumn逻辑,分列渲染。用户滚动到接近页面底部时,触发onReachBottom(或scorll-view的触底事件)。执行第二页请求,新数据再次进入分流函数。数据追加完成,更新leftList和rightList,表格视图自动增长。这里有一个值得注意的细节:第二、三页的数据如果图片加载不出来,宽高比怎么处理?我通常的做法是给一个默认值1.2(接近常规竖图的比例),这样即使某条图文加载慢,也不会让卡片位置产生肉眼可见的偏移。function fetchPageData(pageNum) { const data = request(`/api/list?page=${pageNum}`); const listWithRatio = data.map(item => { const ratio = getImageRatio(item.coverUrl); return formatCardData(item, ratio); }); listWithRatio.forEach(item => pushToShorterColumn(this.leftList, this.rightList, item)); }4. 跨端兼容,UNIAPP里真正考验功力的地方4.1 微信小程序里“不能用DOM”怎么破瀑布流的最初版本,想模拟网页端常见的做法:拿到列表项的所有DOM,遍历读取每项高度,然后填充到偏移变量里。但跑在小程序里这一步直接失败了,原因很简单:document.querySelectorAll在小程序环境里压根不存在。即便换成uni.createSelectorQuery(),也只能拿到指定节点的固定信息,无法在数据动态变化后自动跟踪。UNIAPP官方早就给了一套“利用组件自身布局特性”的解法:不要获取任何布局信息,直接用flex布局的flex-direction: row+align-items: flex-start,让两列独立。这一步很关键:使用flex的align-items: flex-start可以避免两列被默认拉伸成等高,这是实现视觉上“参差感”的基础。4.2 滚动容器差异:页滚动与局部滚动另一个在初期技术上吃了一惊的差异点是滚动容器。在H5端,我们习惯用页面级滚动,因为性能好,window.scroll自然触发底部加载事件。但在App端,偶尔会遇到页面级滚动不够顺滑的情况,尤其是在复杂内容较多时。此时可以用scroll-view局部滚动兜底。<scroll-view scroll-y="true" style="height: calc(100vh - 44px)" @scrolltolower="loadMore"> <view class="waterfall-container"> <view class="waterfall-column" v-for="(item, index) in leftList" :key="index"> <!-- 左侧卡片 --> </view> <view class="waterfall-column" v-for="(item, index) in rightList" :key="index"> <!-- 右侧卡片 --> </view> </view> </scroll-view>这里需要注意的是scroll-view设置高度的方式。如果直接写height: 100vh,在小程序端会包含底部导航栏的空间,滚动底部会被遮挡。我通常的做法是把它放在页面内,并让父容器占满全屏,scroll-view的高度选择flex: 1这类自适应方案,而不是写死值。4.3 各端图片缓存与加载时序的微妙不同图片加载是瀑布流里最容易被忽略但影响最大的因素。在微信小程序里,图片加载失败有个问题:onerror能触发,但图片不会自动重试。在H5端,通常浏览器本身就有较强的缓存策略,图片基本不会重复请求;App端则会受本地缓存和内存压力影响,出现快速滑动时图片闪烁的情况。为了应对这种差异,一定要使用统一封装好的图片组件,而不是在模板里直接写<image>。比如增加一个lazy-load属性(小程序支持),在H5端则叠加一个自定义的占位背景色。占位背景色最好从图片主色调中提取,或者统一用浅灰色系,这样瀑布流滚动时,整列的视觉偏差不会太大。直接留白的话,在深色主题下尤其难看。还有一个常常踩坑的细节:小程序里uni.getImageInfo对部分网络图片域名有一些限制,如果图片的域名不在request白名单,uni.getImageInfo会返回失败。这种情况建议在图片URL的拼接层就直接统一校验,提前过滤掉非法外链。5. 性能优化,让瀑布流滚动得像原生一样顺5.1 用“提前分页”避免一次性渲染过多节点瀑布流页面最怕的事项之一,是数据一次性铺满。很多年轻开发者把第一页数据量直接写成50或者100,结果在低端安卓机上页面直接卡到1秒才能响应点击事件。合理的做法是第一页只请求20条左右,这个数量在大多数机型上都能保持滑动的流畅度。不要试图靠一次请求把所有数据拿回来,然后前端不断分页。那样只是把渲染压力变成了加载后的卡顿,该卡还是会卡。比较务实的三端通用策略是两条:控制每页数据量,首页控制在20条以内,后续页同样量级,不要贪多;图片统一开启懒加载,让视口之外的卡片不要触发图片下载。5.2 减少列表项的重复渲染瀑布流卡片往往包含图片、标题、标签甚至点赞操作。如果整列数组有任何一个字段变化,比如点赞数从88变成89,整个卡片就会重新渲染一遍,在App端还会引发额外的原生层通信。所以卡片尽量拆成独立的子组件,并通过props传入数据。这样点赞刷新只会在局部组件内触发diff,不会把整列数据重新计算。另外key的选取也必须谨慎,建议用业务数据里的唯一ID,而不是列表内的索引index。在小程序里,用索引做key会导致数据错乱的风险比H5高很多,因为小程序端的组件树复用机制和H5完全不同。5.3 图片压缩得当,瀑布流成功一半只要是互联网用户,就不会喜欢看到一个瀑布流页面,图片加载一次要两秒。所以在服务端返回图片URL时,就要求图片接口支持按尺寸裁剪或按质量压缩。举例来说,列表里的封面图宽度只要300px,就不要直接拿原图地址去加载。在UNIAPP的三端里,加载这样压缩后的图片,渲染内存的压力能下降百分之三四十。尤其是App端,如果原图太大,非常容易发生内存紧张而白屏。图片地址拼接这类规则建议在项目里做一个单独的工具函数,比如:function getThumbUrl(url, width = 300) { if (url.includes('?x-oss-process')) { return url + `&x-oss-process=image/resize,w_${width}`; } return url + `?x-oss-process=image/resize,w_${width}`; }不同的云存储服务有各自的图片处理参数,这里只为示意,实际项目中最好把图片服务商的相关参数整理成文档,前后端对齐。6. 常见布局问题与排查手册6.1 为什么H5端正常,小程序端列高总是对不齐这个问题本质上不是样式文件的差异,而是两端默认的盒模型行为不同。小程序端的view组件默认是box-sizing: border-box,但H5端的默认行为会受到外层样式影响。解决方式是在全局样式中为所有元素强制设置统一盒模型:view, text, image { box-sizing: border-box; }这个样式在项目创建初期就应该内置到通用样式文件里,能省掉后面一大半的布局对齐问题。6.2 图片加载慢,列高一直跳来跳去当多个图片没有同步拿到宽高比时,估算高度和实际高度之间会有较大反差,视觉表现就是列表元素不停地上下跳。解决思路是加一个“等待数据齐整”的机制:把第一屏的sekumpulan数据里图片全部加载完成后,才执行真正的分发渲染。async function init() { const list = await request('/api/list?page=1'); const result = await Promise.all(list.map(item => prepareItem(item))); result.forEach(item => pushToShorterColumn(this.leftList, this.rightList, item)); }prepareItem里重点做图片宽高获取和比例计算。如果不想等全部完成,也可以分批渲染,但必须保证第一批的列高差距尽量接近,不然初始状态就很丑。6.3 页面底部加一条“加载中”还是“没有更多了”这个设计在普通列表里很直接,但在瀑布流里要格外注意放置位置。如果加载状态放在最底部,在滚动还未到底的时候就很容易提前看到;如果放在两列中间,又可能破坏布局。我的经验是:加载状态作为一个独立组件放在父容器底部,用visibility控制显示/隐藏。这样做三个端统一,不用特别去适配两列的相对高度。6.4 内存猛涨,反复滑动后越来越卡这通常是因为页面卸载时没有释放图片资源。UNIAPP的App端里,如果用了<image>组件加载了大量本地缓存图,建议在页面onUnload生命周期里手动清理掉不用的列表数据,并解除图片的事件绑定:onUnload() { this.leftList = []; this.rightList = []; }固然这是一种很武断的做法,但对防止内存泄漏非常有效。7. 我测出来的几个实用技巧如果把整个瀑布流实现的头发丝都捋一遍,最后剩下的实践技巧其实非常有限,但每一个都很值钱。第一,在拿到设计稿之后,优先确定卡片的结构是“固定文字区域高度”还是“可变文字区域高度”。尽量让文字部分定为一行或两行截断。这样图片比例的预估几乎可以准确到像素级,分列之后的整体平衡度会好非常多。第二,不要迷信“图片原比例+无限高度”的模式。很多好看的设计里图片本身就是正方形或固定比例,瀑布流看起来依然是瀑布,根本不妨碍美观。固定比例的好处是彻底消灭了“加载后跳高”的问题,代码稳定性和用户体验能同时保住。第三,关于滚动触底:小程序里onReachBottom的触发时机和H5端浏览器不完全一致,往往H5端要滑得更深才会触发。所以当项目需要三端体验完全一致时,统一改成scroll-view的做法比较稳妥。当然,这要求页面上没有其他横向滚动的区域干扰。第四,涉及分页加载时,尽量用page参数来跟踪当前页码,而不是在函数里维护局部变量。因为瀑布流页面通常还会包含下拉刷新、位置恢复、回跳定位等额外功能,统一用页面级的响应式数据去管理状态,后续扩展会更顺畅。8. 最后再分享一个实际项目里的收尾细节这个项目后续还做了一个收尾处理——分页加载失败时,要把已经加载过的上一页数据保留,同时给用户一个底部“加载失败,点击重试”的按钮。在瀑布流的场景里,失败重试如果设计得不合理,很容易出现用户往下滑了很远,结果上一页的数据被清空,只看到一片空白。这个反例在我早期做某资讯类项目时遇到过,当时用户反馈“刷着刷着内容就没了”,体验很受打击。所以处理方式应该是在loadMore失败时,不重置leftList和rightList,而是更新一个独立的loadError状态。重试时,基于当前已有效数据继续追加,不去重新初始化。在实际开发里,瀑布流和普通列表虽然是同源的,但细节的坑真的是做了才懂。希望这篇内容能帮你少走一些弯路,让移动端的瀑布流成为一套稳定、流畅、高复用性的基础组件。