1. 为什么要把“加载更多”从默认位置挪出来
做过列表页的人大概都遇到过这种尴尬:layui 的 flow 模块默认会把“加载更多”按钮和“没有更多了”的提示直接怼在容器底部,样式是它自己那套,位置也是它自己算的。平时做个简单的瀑布流或者信息流,这样确实省事,但一旦页面设计稍微讲究一点,问题就来了——设计师给的稿子里,“加载更多”可能是一个带箭头的文字链,可能是一个圆角描边按钮,也可能压根不是按钮,而是一个“上拉查看历史消息”的横条,甚至要放在列表侧边或者固定在某个自定义卡片里。这时候默认那套 DOM 结构就完全不够用了。
我最近就碰到这么一个需求:一个活动报名名单的滚动列表,产品要求“加载更多”做成一个带图标的自定义标签,放在列表容器内部的固定位置,加载完之后这个标签要原地变成“已经到底啦”的灰色小字,而且整个标签的点击区域、hover 效果、禁用态都得跟站内其他组件保持一致。用 layui 的 flow 默认行为,改起来要么覆盖它的 CSS 覆盖到怀疑人生,要么就得在done回调里手动操作它生成的 DOM,脆弱得很,layui 版本一升级就可能崩。
所以这篇东西就是把这个坑完整讲清楚:flow 流加载的“加载更多”和“没有更多了”到底是怎么生成的、能不能接管、怎么把这两个状态塞进自己的自定义标签里,并且保证分页逻辑、滚动触发、结束判断全都不出错。适合已经用过 layui 基础组件、正在做列表分页优化、或者被默认样式卡住的前端同学。哪怕你用的是别的 UI 库,这套“接管默认 DOM、自己控制状态机”的思路也是通用的。
先把结论摆前面:flow 模块本身没有提供“自定义加载更多模板”的官方配置项,但它留了一个口子——content参数配合done回调,加上对moreText、endText的覆盖,再配合我们自己维护的一个状态标记,就能把默认那套 DOM 完全替换掉。关键在于理解它内部是怎么判断“还有没有下一页”的,这个判断逻辑不搞清楚,自定义标签就会出现“明明还有数据却显示没有更多了”或者“点了一次就再也不触发”的经典 bug。
2. flow 流加载的内部机制拆解
2.1 默认 DOM 是怎么长出来的
要接管,先得知道它原来是怎么干活的。layui 的 flow 模块在初始化时,会在你指定的容器(elem指向的元素)内部追加一个固定结构的包裹层,大致是这样的层级:外层容器里放一个内容区,内容区后面跟一个“加载更多”的触发区。这个触发区在“还有数据”的状态下渲染成可点击的按钮样式,文案取自moreText(默认是“加载更多”);当某一页返回的数据量小于limit时,它认为到底了,把触发区换成endText(默认是“没有更多了”)的纯文本,并且移除点击事件。
这里有个很多人忽略的细节:它判断“到底”的依据不是总条数,而是“本次返回条数 < limit”。也就是说,如果你的接口每页固定返回 10 条,最后一页刚好也是 10 条,那 flow 会认为还有下一页,继续让你点,直到你再请求一次返回 0 条,它才收尾。这个设计对分页接口是合理的,但对“一次性拿全量再前端切片”的场景就会多一次空请求。理解这一点,后面自定义状态机才不会写错。
2.2 滚动加载和点击加载是两条路径
flow 支持两种触发方式:一种是点击“加载更多”按钮,另一种是开启isAuto后的滚动到底自动加载。这两条路径最终都会走到同一个内部加载函数,区别只在于触发源。滚动模式下,它监听的是容器(或窗口)的滚动事件,判断“滚动条接近底部”就触发。这里要注意,isAuto为 true 时,默认那个“加载更多”按钮其实还在 DOM 里,只是被隐藏或者不参与交互了,具体表现跟版本有关。
我实测下来,如果你要完全自定义标签,建议把isAuto关掉,走手动点击。原因很简单:滚动自动加载的触发时机由它内部计算,你自定义的标签如果位置不在它预期的底部,滚动判断的参照系就乱了,容易出现“标签还没露出来就已经自动加载了”或者“滚到底了也不触发”的情况。手动点击虽然多一次交互,但状态完全可控,配合自定义标签的视觉反馈反而更清晰。
2.3 回调里能拿到什么、不能拿到什么
done回调是接管的关键。它会在每次加载完成后被调用,参数里通常包含当前页码、返回的数据等信息(不同小版本字段名略有差异,建议打印出来确认)。你能在这个回调里做两件事:一是把新数据渲染进列表,二是根据“是否还有下一页”来切换自定义标签的状态。但要注意,flow 内部对“到底”的判断和你的业务判断可能不一致,所以不要完全依赖它给的信号,最好自己维护一个hasMore标记,由接口返回的total或者当前页 * limit >= total来决定,这样最稳。
还有一个坑:done回调里如果直接操作 flow 生成的那个默认按钮 DOM,在数据还没到底的时候是有效的,但一旦到底,那个 DOM 会被替换成结束文案,你的选择器就失效了。所以正确做法是从一开始就不依赖它的默认 DOM,而是自己在外层放一个自定义标签,用 CSS 把默认那套隐藏掉,状态切换全部由自己的标记驱动。这样无论 flow 内部怎么变,你的标签都稳如老狗。
3. 自定义标签的完整落地方案
3.1 结构设计:把标签放在容器内的固定位置
先看 HTML 结构。核心思路是:列表容器内部,除了 flow 自己生成的内容区,我们自己再放一个自定义标签元素,用绝对定位或者 flex 布局固定在视觉上想要的位置。默认的“加载更多”区域用 CSS 隐藏掉,眼不见为净。
<div class="list-wrap" id="listWrap"> <!-- flow 会把内容渲染到这里面 --> <div class="flow-content" id="flowContent"></div> <!-- 自定义加载标签,固定在容器底部 --> <div class="custom-more" id="customMore"> <span class="custom-more-text">加载更多</span> <i class="custom-more-icon"></i> </div> </div>对应的样式要点:.list-wrap设position: relative,.custom-more用position: sticky; bottom: 0或者绝对定位固定在底部,背景给个半透明或者纯色,避免和列表内容重叠时看不清。flow 默认生成的那个触发区,用类似.layui-flow-more { display: none !important; }的规则干掉。这里用!important是无奈之举,因为它的内联样式优先级不低,实测不加压不住。
提示:隐藏默认触发区之前,先在浏览器里审查元素确认它的类名,不同 layui 版本类名可能有细微差别,别照抄网上的老类名。
3.2 状态机设计:三种状态一个都不能少
自定义标签本质上是个状态显示器,它至少要有三种状态:可加载(还有数据)、加载中(请求进行中)、已到底(没有更多了)。很多人只做了第一种和第三种,漏了“加载中”,结果用户快速连点,触发多次请求,数据重复渲染,列表里出现一堆重复项,这是流加载最常见的线上事故。
我的做法是维护一个对象:
var flowState = { page: 1, limit: 10, hasMore: true, loading: false };每次点击标签,先判断if (flowState.loading || !flowState.hasMore) return;,通过之后立刻把loading置为 true,标签文案切成“加载中…”,并加一个禁用样式(比如降低透明度、pointer-events: none)。请求回来后在done回调里根据返回数据更新hasMore,把loading复位,再根据hasMore切换标签文案为“加载更多”或“已经到底啦”。这套状态机虽然简单,但能挡掉 90% 的重复请求和状态错乱问题。
3.3 参数计算:limit 和 total 怎么配合
limit的选择直接影响体验和请求次数。列表项高度大概 80px 的话,一屏能看 8 到 10 条,limit设 10 比较合适,首屏加载完刚好填满或者略微超出,用户有内容可看,又不会一次拉太多。如果列表项很矮(比如 40px 的紧凑行),limit可以提到 20,减少点击次数。
判断hasMore有两种常见写法,各有适用场景:
| 判断方式 | 写法 | 适用场景 | 注意点 |
|---|---|---|---|
| 按返回条数 | res.data.length < limit | 接口不返回总数 | 最后一页刚好满页时会多一次空请求 |
| 按总数 | page * limit >= total | 接口返回 total | 最准确,推荐 |
| 按页码 | page >= totalPage | 接口返回总页数 | 同样准确,注意页码从 1 还是 0 开始 |
我一般优先用总数判断,因为最不容易出错。如果接口实在不返回总数,那就用返回条数判断,但要在 UI 上接受“可能多一次空请求才显示到底”这个小瑕疵,或者在空请求返回时直接切到结束态,别让用户看到一次无反应的点击。
4. 实操过程:从零把标签接进 flow
4.1 初始化 flow 并接管渲染
先引入 layui 的 flow 模块,初始化时把elem指向内容区,content指向你的数据接口,done回调里做两件事:渲染数据和更新状态。注意content的写法,它支持直接给 URL 字符串,也支持给一个返回配置的函数,后者更灵活,方便你动态改参数。
layui.use(['flow'], function () { var flow = layui.flow; var $ = layui.$; flow.load({ elem: '#flowContent', isAuto: false, end: '已经到底啦', done: function (page, next) { // page 是当前页码,next 用来告诉 flow 本次加载结束 $.ajax({ url: '/api/list', type: 'get', data: { page: page, limit: flowState.limit }, success: function (res) { var list = res.data.list || []; var total = res.data.total || 0; // 渲染列表项 var html = ''; list.forEach(function (item) { html += '<div class="list-item">' + item.title + '</div>'; }); $('#flowContent').append(html); // 更新状态 flowState.page = page; flowState.hasMore = page * flowState.limit < total; flowState.loading = false; updateMoreTag(); // 通知 flow 本次结束 next(list.length, flowState.hasMore); }, error: function () { flowState.loading = false; updateMoreTag(); layer.msg('加载失败,请重试'); } }); } }); });这里有个关键点:next的第二个参数传hasMore,flow 内部会据此决定是否继续允许加载。虽然我们隐藏了它的默认按钮,但这个信号还是要给对,否则它内部状态可能和我们的标签状态打架。实测下来,传对了之后,即使默认 DOM 被隐藏,滚动监听之类的内部逻辑也不会乱。
4.2 自定义标签的点击与状态切换
标签的点击事件自己绑,不要指望 flow 帮你绑。点击时先做状态守卫,再手动触发一次加载。这里有个技巧:flow 的加载函数没有直接暴露出来,但你可以通过模拟点击它内部按钮的方式触发,不过更干净的做法是把请求逻辑抽成一个独立函数,flow 的done和标签点击都调它,避免两套逻辑。
function updateMoreTag() { var $tag = $('#customMore'); var $text = $tag.find('.custom-more-text'); if (flowState.loading) { $text.text('加载中…'); $tag.addClass('is-loading'); return; } $tag.removeClass('is-loading'); if (flowState.hasMore) { $text.text('加载更多'); $tag.removeClass('is-end').addClass('is-more'); } else { $text.text('已经到底啦'); $tag.removeClass('is-more').addClass('is-end'); } } $('#customMore').on('click', function () { if (flowState.loading || !flowState.hasMore) return; flowState.loading = true; updateMoreTag(); // 触发下一页加载,这里复用 flow 的机制 // 具体触发方式见 4.3 });样式上,.is-loading加个旋转图标或者降低透明度,.is-end把颜色调灰、去掉手型光标,.is-more保持可点击的视觉。这些类名切换比直接改内联样式好维护得多,也方便做过渡动画。
4.3 触发下一页的两种可靠方式
第一种方式,也是最省事的:保留 flow 的默认按钮但隐藏它,点击自定义标签时用代码触发默认按钮的 click。这样加载逻辑完全走 flow 内部,你不用重复写请求。缺点是依赖默认 DOM 的存在,如果哪天 flow 版本把按钮结构改了,选择器要跟着改。
$('#customMore').on('click', function () { if (flowState.loading || !flowState.hasMore) return; flowState.loading = true; updateMoreTag(); $('.layui-flow-more').find('a,button,.layui-flow-more-btn').trigger('click'); });第二种方式,自己维护请求函数,flow 只负责首次加载和渲染容器。后续翻页全部由自己的函数处理,done回调里只做渲染。这种方式解耦更彻底,flow 版本升级基本无感,但要注意首次加载和后续加载的渲染逻辑要统一,别写两套。
我一般选第二种,因为可控性最高。具体做法是把done里的渲染逻辑抽成renderList(list),请求逻辑抽成fetchPage(page),flow 的done调fetchPage,标签点击也调fetchPage,两边共用同一套状态更新。这样无论触发源是什么,行为完全一致。
4.4 首屏加载与空状态处理
首屏加载有个容易翻车的点:如果第一页就返回空数组,flow 会直接显示结束文案,但你的自定义标签如果没同步状态,就会显示“加载更多”却点不动。所以done回调里一定要处理list.length === 0的情况,把hasMore置 false,标签切到结束态。另外,空列表最好给个空状态插画或者提示文案,别让用户对着一片空白加一个“已经到底啦”发呆。
还有一种边界情况:接口报错。报错时loading必须复位,标签要能再次点击重试,不能卡在“加载中”永远转圈。这个在 4.1 的代码里已经处理了,error回调里复位状态并提示。实测这个细节不做,用户网络一抖就得刷新页面,体验很差。
5. 常见问题与排查实录
5.1 标签点了没反应,也不报错
最常见的原因是状态守卫把点击拦掉了。检查flowState.loading是不是卡在 true 没复位,或者hasMore被错误地置成了 false。排查方法很简单,在点击回调第一行console.log(flowState),看两个标记的值。如果loading一直是 true,多半是某次请求的success或error里漏了复位,或者next回调没被正确调用导致 flow 内部挂起。
还有一种可能是选择器没选中默认按钮,trigger('click')打在了空气上。用console.log($('.layui-flow-more').length)确认元素存在,不存在就说明类名不对或者被提前移除了。
5.2 数据重复渲染,列表里出现重复项
这是重复请求的典型症状。要么是用户快速连点没被拦住,要么是滚动和点击两条路径同时触发。前者靠loading标记解决,后者建议关掉isAuto,只保留一种触发方式。另外检查page的递增逻辑,别在请求成功前就自增,否则两次请求可能拿同一页数据。正确顺序是:请求成功 → 更新page→ 更新hasMore。
5.3 明明还有数据却显示“已经到底啦”
八成是hasMore的计算写错了。重点检查page * limit < total里的page是不是从 1 开始,以及total有没有正确从接口里取出来。如果接口返回的字段名是count而不是total,你取到undefined,比较结果就是 false,直接到底。打印一下接口原始返回,确认字段名。还有一种情况是limit和接口实际使用的分页参数不一致,比如你传limit=10但接口按size取参数,实际返回 20 条,判断就乱了。
5.4 自定义标签被列表内容盖住或者盖住内容
这是定位和层级问题。position: sticky的标签在滚动时可能被后面渲染的列表项盖住,给标签加个z-index并且确保它的父容器没有overflow: hidden截断。如果是绝对定位固定在底部,记得给列表容器底部留出标签高度的padding-bottom,否则最后一条数据会被标签挡住看不全。这个 padding 值等于标签高度加一点间距,实测漏了这一步,用户永远看不到最后一条。
5.5 切换页面或重新搜索后状态没重置
单页应用里很常见。用户搜索了新关键词,列表重新加载,但flowState还是上一轮的值,page没归 1,hasMore还是 false,结果新列表加载不出来。解决办法是在重新初始化列表前,手动重置状态对象,并且清空内容区。如果 flow 实例需要重新load,注意先销毁旧的监听,避免重复绑定。
| 问题现象 | 最可能原因 | 快速排查 |
|---|---|---|
| 点击无反应 | loading 卡 true / 选择器失效 | 打印状态对象、确认默认按钮存在 |
| 数据重复 | 重复请求 / page 递增时机错 | 加 loading 守卫、检查 page 更新顺序 |
| 提前显示到底 | hasMore 计算错 / 字段名错 | 打印接口返回、核对分页参数名 |
| 标签被遮挡 | z-index 或 padding 缺失 | 检查层级、补底部内边距 |
| 重新搜索不加载 | 状态未重置 | 初始化前重置 page 和 hasMore |
6. 几个让方案更稳的实操心得
第一,别在done回调里做太多事。回调里只做数据渲染和状态更新,请求逻辑、DOM 操作尽量抽出去。回调越薄,出问题时越好定位。我见过把整个业务渲染塞进done的写法,几百行下去,一个状态 bug 找半天。
第二,自定义标签的文案和样式走配置,别写死。把“加载更多”“加载中”“已经到底啦”三个文案抽成一个配置对象,将来产品要改成“查看更多”“正在加载”“我是有底线的”,改一处就行。样式同理,用类名控制,别在 JS 里拼内联样式。
第三,给标签加一个最小展示时间。接口很快的时候,“加载中”一闪而过,视觉上像闪屏。可以在请求开始时记录时间戳,回调里如果耗时不足 300ms,用setTimeout补足再切状态。这个细节对体验提升很明显,尤其是本地开发时接口秒回,不加的话根本看不到加载态。
第四,移动端注意点击区域的尺寸。自定义标签如果做得比较小巧,点击热区要单独扩大,用伪元素撑开或者加 padding,保证至少 44px 的高度,否则手指点不准,用户会以为没反应。这个在桌面端调试时完全看不出来,真机上才暴露。
第五,保留一个降级方案。万一自定义标签的 JS 没加载或者出错,页面上至少还能看到 flow 默认的加载按钮。做法是默认不隐藏 flow 的触发区,等自定义标签初始化成功后再用 JS 加类隐藏。这样即使脚本挂了,用户还能靠默认按钮继续加载,不至于整个列表卡死。这个思路在线上环境救过我好几次。
这套方案我在几个列表页上跑了大半年,中间 layui 小版本升级过两次,因为核心逻辑没依赖它的内部 DOM 结构(除了隐藏默认按钮那一下),所以基本没受影响。真正需要留意的还是接口分页参数和hasMore判断的一致性,这块只要对齐了,剩下的就是样式和状态的细活。