资讯详情

资讯详情

layui flow自定义加载更多UI:状态机与滚动加载实践

1. 为什么要把加载更多从默认容器里拽出来做过 layui 列表页的人大概都遇到过这个场景flow模块默认会把加载更多按钮或者没有更多了的提示塞进你指定的那个容器元素里位置、样式、文案基本由它自己说了算。平时用着挺省事可一旦页面设计稍微讲究一点比如按钮要放在列表卡片外面、要跟一个自定义的分页栏并排、或者没有更多了需要显示成一条带图标的分隔线默认行为就开始碍事了。我最初接手的一个后台列表页就是这种情况。设计稿里加载更多是一个圆角描边按钮居中放在列表下方 24px 的位置而没有更多了是一条浅灰色文字加左右横线。flow默认生成的 DOM 结构跟这个设计稿对不上硬用 CSS 去覆盖又很别扭因为默认节点是动态插入的选择器优先级和插入时机都不好控制。折腾了两轮之后我决定换个思路不让flow自己管这块 UI而是把加载更多和没有更多了的渲染权拿回来放到我自己的自定义标签里。这个思路的核心价值在于职责分离flow只负责什么时候该加载下一页这个数据逻辑UI 长什么样、放在哪、文案怎么写全部由页面自己决定。听起来只是挪个位置但实际做下来会牵出一串细节——滚动容器的判定、加载状态的防抖、没有更多时的收尾、以及自定义按钮和flow内部状态如何同步。这些细节如果没处理好轻则按钮点了没反应重则重复请求把后端打挂。这篇内容适合两类人看一类是正在用 layui 做列表页、被默认 UI 卡住的中级前端另一类是想理解第三方组件 UI 与逻辑解耦这套思路的开发者。哪怕你用的不是 layui而是别的滚动加载库里面关于滚动判定、状态同步、防重复请求的经验也是通用的。下面我会从默认行为讲起把自定义标签的完整实现链路拆开包括我踩过的几个坑和最后的稳定方案。2. flow 默认渲染机制与它的边界2.1 默认的 moreText 和 endText 是怎么插进去的flow模块初始化时接收一个配置对象里面有几个关键项elem指定滚动容器url是数据接口done是每次加载完的回调还有moreText和endText分别控制加载更多和没有更多了的文案。默认情况下flow会在容器内部维护一个内部的加载提示节点滚动到底部时触发请求请求过程中显示 loading加载完把新数据追加到列表全部加载完则把提示节点替换成endText的内容。关键在于这个提示节点是flow内部创建的它的位置依附于你传给elem的那个容器。也就是说如果你的列表容器和你想放按钮的位置不是同一个元素默认行为就满足不了你。很多人第一反应是给这个内部节点加个position: absolute挪出去但这样做有两个隐患一是节点是动态创建的你得等它创建后再操作时机不好把握二是flow在加载完成后可能会重建或替换这个节点你加的样式和事件可能丢失。2.2 默认行为在哪些场景下会失效我把常见的失效场景整理成了一张表方便对照自己的项目场景默认行为的问题表现按钮要放在列表容器外部提示节点被限制在elem内部按钮位置无法脱离列表需要自定义按钮结构图标文字默认只支持纯文本moreText无法插入图标或复杂 DOM没有更多了要做成分隔线样式默认是纯文本替换样式与设计稿不符列表容器有overflow: hidden提示节点可能被裁切按钮显示不全需要同时存在多个加载入口默认只有一个内部提示节点无法复用同一份加载逻辑这张表里前三条是我实际遇到的后两条是后来做另一个项目时踩到的。尤其是overflow: hidden那条当时列表容器为了做圆角裁切加了overflow: hidden结果加载更多按钮的下半截直接被切掉了排查了半天才反应过来是容器裁切的问题。2.3 为什么接管 UI比覆盖样式更稳有人会问既然只是样式问题为什么不直接用 CSS 覆盖我的经验是样式覆盖解决的是看起来的问题接管 UI 解决的是结构的问题。当设计稿要求按钮脱离容器、要求没有更多了是独立的分隔线、要求加载状态有自己的动画时这些都不是单纯改颜色和边距能搞定的它们涉及 DOM 结构的变化。而flow默认不给你控制结构的口子你只能通过它暴露的配置项和回调去间接影响。接管 UI 的做法是把flow的moreText和endText设成空或者设成一个占位标记让它在视觉上什么都不显示然后自己在页面里放一个自定义标签比如一个div classload-more-wrap由自己的代码去监听滚动、判断状态、控制这个标签的显示与隐藏。这样flow退化成纯数据加载器UI 完全归你。代价是你得多写一些状态管理代码但换来的是完全的可控性长期维护成本反而更低。3. 自定义标签方案的完整落地链路3.1 先想清楚状态机idle、loading、nomore 三态在动手写代码之前我建议先把状态理清楚。一个滚动加载列表本质上只有三个状态在循环idle空闲还有数据等待用户滚动触发或点击加载更多。loading加载中请求已发出等待响应此时要防止重复触发。nomore没有更多后端返回的数据为空或已到最后一页显示没有更多了不再触发加载。这三个状态决定了自定义标签显示什么内容、按钮是否可点、滚动监听是否还生效。我见过不少实现只用一个布尔值isLoading来管结果没有更多和加载中混在一起出现没有更多了还能点、点了又发请求的尴尬情况。用三态枚举逻辑会清晰很多。var LoadState { IDLE: idle, LOADING: loading, NOMORE: nomore }; var currentState LoadState.IDLE;这段代码看着简单但它是后面所有判断的基础。每次请求前检查currentState LoadState.IDLE才放行请求开始置为LOADING请求结束根据返回数据决定回到IDLE还是进入NOMORE。这个模式我在好几个项目里复用几乎没出过状态错乱的问题。3.2 自定义标签的 DOM 结构与样式约定自定义标签我一般写成这样放在列表容器外面div classlist-container idlistBox !-- 列表项由 flow 或手动渲染 -- /div div classload-more-wrap idloadMoreWrap button typebutton classload-more-btn idloadMoreBtn加载更多/button span classload-more-end idloadMoreEnd styledisplay:none;没有更多了/span /div样式上.load-more-wrap负责居中和间距.load-more-btn是按钮态.load-more-end是收尾态。两个子元素通过display切换而不是动态创建销毁这样避免频繁操作 DOM 带来的性能损耗和事件丢失。这里有个小细节按钮和没有更多了我都用display: none初始隐藏其中一个而不是靠 JS 每次去innerHTML替换原因是替换会丢失绑定在元素上的事件监听而display切换不会。提示如果你的列表容器有overflow: hidden或overflow: auto自定义标签一定要放在容器外面否则同样会被裁切。这是我在第一个项目里踩的坑按钮被切掉一半查了半小时才发现是容器的问题。3.3 滚动监听的判定逻辑与节流处理自定义标签方案里滚动监听得自己写。核心是判断是否滚动到底部附近。我用的判定是容器滚动高度减去滚动距离小于一个阈值比如 100px时触发加载。var THRESHOLD 100; var scrollTimer null; function onScroll() { if (currentState ! LoadState.IDLE) return; var box document.getElementById(listBox); var distance box.scrollHeight - box.scrollTop - box.clientHeight; if (distance THRESHOLD) { loadNextPage(); } } document.getElementById(listBox).addEventListener(scroll, function () { if (scrollTimer) clearTimeout(scrollTimer); scrollTimer setTimeout(onScroll, 100); });这里有两个经验点。第一必须节流。滚动事件触发频率极高不节流的话onScroll一秒能跑几十次虽然状态判断能挡住重复请求但无谓的计算还是浪费。我用 100ms 的setTimeout做简易节流实测足够。第二阈值不要设太小。设成 0 的话用户必须精确滚到底才触发体验很差设成 100px 左右用户快到底时就开始加载衔接更顺滑。这个值可以根据列表项高度调整项高的话适当放大。3.4 按钮点击与滚动触发共用同一套加载函数自定义标签的好处之一是按钮点击和滚动到底可以共用同一个loadNextPage函数不用写两套逻辑。loadNextPage内部先做状态守卫再发请求请求回来更新状态和 UI。function loadNextPage() { if (currentState ! LoadState.IDLE) return; currentState LoadState.LOADING; updateLoadUI(); fetchData(currentPage).then(function (res) { if (res.list res.list.length 0) { renderList(res.list); currentPage; if (res.hasMore) { currentState LoadState.IDLE; } else { currentState LoadState.NOMORE; } } else { currentState LoadState.NOMORE; } updateLoadUI(); }).catch(function () { currentState LoadState.IDLE; updateLoadUI(); }); }updateLoadUI根据currentState切换按钮和没有更多了的显示同时控制按钮的禁用态。请求失败时我选择回到IDLE而不是NOMORE这样用户可以重试而不是被一个网络抖动永久卡死。这个细节很多人会忽略但实际用起来体验差别很大。4. 和 flow 模块协作时的几个关键取舍4.1 是彻底不用 flow还是让它只做数据层这里有个路线选择既然 UI 都自己管了那flow还有没有必要用我的答案是看情况。如果你的加载逻辑很简单——就是请求一个接口、追加列表、判断有没有更多——那完全可以自己写几十行代码的事还少一个依赖。但如果你需要flow提供的其他能力比如它内置的请求封装、done回调的便利、或者项目里其他地方已经统一用flow了那让它退化成数据层是更省事的选择。让flow只做数据层的做法是初始化时把moreText和endText都设成一个空字符串或者一个不显示的占位符然后不依赖它的滚动触发而是自己监听滚动调用flow的加载方法。不过flow并没有直接暴露手动加载下一页的公开方法所以更常见的做法是干脆不用flow的滚动只用它的请求和回调或者直接自己写请求。我在最近两个项目里都是直接自己写请求因为接口返回结构比较特殊flow的默认解析反而碍事。4.2 如果坚持用 flow 的滚动如何屏蔽它的默认 UI有些团队规范要求统一用flow那可以保留它的滚动触发只屏蔽默认 UI。做法是把moreText设成一个零宽字符或者空字符串endText同样处理这样它在视觉上不显示任何东西。然后你自己在页面里放自定义标签通过flow的done回调去同步状态。layui.use([flow], function () { var flow layui.flow; flow.load({ elem: #listBox, mb: 100, end: , moreText: , done: function (page, next) { // 请求数据 fetchData(page).then(function (res) { next(res.list, res.hasMore); // 同步自定义 UI 状态 if (!res.hasMore) { currentState LoadState.NOMORE; } else { currentState LoadState.IDLE; } updateLoadUI(); }); } }); });这种方式的坑在于flow内部的滚动判定和你的自定义按钮点击是两套触发源如果不同步好状态可能出现滚动触发了一次、按钮又触发一次的情况。所以done回调里必须把状态更新到位且loadNextPage里的状态守卫要严格。我实测下来只要状态机写对两套触发源共存是没问题的。4.3 两种路线的对比与选型建议维度完全自研flow 做数据层代码量稍多需自己写请求和滚动较少复用 flow 封装可控性完全可控受 flow 内部逻辑约束依赖无依赖 layui flow适合场景接口特殊、UI 复杂接口标准、团队统一用 layui踩坑点滚动判定、节流双触发源状态同步我的建议是新项目如果 UI 要求高直接自研别为了省几十行代码给自己挖坑老项目如果已经大量用flow那就走第二条路线重点把状态同步做扎实。5. 踩坑实录那些让我返工的细节5.1 没有更多了闪一下又变回按钮这个坑我印象最深。现象是最后一页数据加载完currentState已经置为NOMOREUI 也切到了没有更多了但用户再滚动一下按钮又冒出来了。排查后发现是滚动监听里没有对NOMORE状态做拦截onScroll里只判断了! IDLE就 return但updateLoadUI在某些异步回调里被重复调用把状态又改回去了。根因是状态更新和 UI 更新没有原子化。我后来的做法是把状态变更和 UI 更新封装进一个函数任何地方改状态都必须走这个函数杜绝散落在各处的currentState xxx赋值。这个习惯养成后类似的状态漂移问题基本绝迹。5.2 快速滚动导致的重复请求测试同学反馈网速慢的时候快速上下滚动会发出好几个重复请求。原因是滚动节流的 100ms 内如果用户滚动很快onScroll可能连续触发而第一次请求发出后状态虽然变成了LOADING但请求是异步的在fetchData真正发出前有个微小的时间窗口第二次onScroll进来时状态判断可能还没生效。解决办法是在状态守卫之后、发请求之前同步地把状态置为LOADING而不是等请求发出后再置。也就是把currentState LoadState.LOADING放在fetchData调用的前面且中间不能有await或异步操作。这个顺序调整后重复请求问题彻底消失。5.3 容器高度变化导致的判定失效还有一个隐蔽的坑列表项里有图片图片加载前高度是 0加载后撑开导致scrollHeight变化。如果用户在图片还没加载完时就滚到底判定可能误触发或漏触发。我的处理是给图片加固定宽高比占位或者在图片onload后重新计算一次滚动位置。这个坑不常见但一旦遇到很难查因为现象是有时候能加载有时候不能很迷惑。5.4 移动端滚动容器的兼容问题在移动端滚动容器如果是body或者某个overflow: auto的 divscrollTop的取值和桌面端略有差异尤其是 iOS 上的弹性滚动。我遇到过一次在 iOS 上滚到底部判定不触发最后发现是clientHeight取值受-webkit-overflow-scrolling: touch影响。解决办法是判定时用Math.abs(distance) THRESHOLD把负值也纳入因为弹性滚动时scrollTop可能超过理论最大值导致distance为负。6. 收尾让自定义标签更耐用的几个习惯做下来最大的体会是滚动加载这类交互难点从来不在加载本身而在状态和边界。数据请求谁都会写但状态什么时候变、变了之后 UI 怎么跟、边界情况空数据、请求失败、快速滚动、容器变化怎么兜底才是决定这个功能稳不稳的关键。我现在做这类需求会先画一张状态流转图纸上画就行不用工具把 idle、loading、nomore 之间的转换条件和触发源列清楚再动手写代码。这个习惯帮我省了很多返工。另外自定义标签的文案和样式尽量走配置别写死在 JS 里因为运营改文案是常事写死了每次都要改代码发版。最后分享一个小技巧调试滚动加载时可以在onScroll里临时console.log出scrollHeight、scrollTop、clientHeight和distance四个值滚动时观察它们的变化能快速定位是判定阈值的问题还是容器的问题。这个笨办法我用过很多次比瞎猜高效得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →