1. 无限滚动到底在解决什么问题——先别急着写代码
如果你刚开始做 Flutter 列表,很容易陷入一个错觉:把数据全部塞进ListView,完事了。数据量不大时确实没什么感知,但一旦列表项超过几百条,或者每项的图片、卡片布局比较复杂,你就开始能明显感觉到滑动掉帧、内存涨得快、首屏渲染慢。真正上过生产环境的人都知道,长列表从来不是"把数据塞进去"这么简单。
无限滚动解决的核心矛盾是:你永远不知道后端到底有多少条数据,也不可能把全部数据一次性拉到客户端渲染。所以它天然要把"滚动位置"和"数据加载"绑定在一起——用户滑到接近底部时,触发新一页数据的请求,请求成功后追加到现有列表后面,列表继续滚动,继续触发,循环往复。这种体验最早在移动端普及,现在 Web、桌面端也都默认这么玩。
那为什么ListView不能直接加载全部数据呢?关键在 Flutter 的渲染机制里。普通的ListView(children: [...])会一次性构建所有的列表项 widget 树,即便当前屏幕上看不到,它们也真实存在于内存和渲染管线中。而ListView.builder走的是懒加载路线:只有列表项真正滑入可视区域(严格说是视口加一点点预缓存区)时,itemBuilder才会被调用,对应的 widget 才被创建。这个机制是无限滚动的底层基础,没有它就谈不上"接续加载"。
但ListView.builder只是让你"能懒加载",并没有替你解决"什么时候加载、加载完怎么追加、加载中给什么反馈、加载失败怎么办"这一整套流程。这些零散的逻辑散落在页面里,就是无数 bug 的来源。我见过太多项目里写出来这样的代码:每个页面各写一份滚动监听、各维护一套page和isLoading布尔值,改一个页面的逻辑,其余页面全都得跟着改。所以今天这篇,我想聊聊的不只是"怎么写一个无限滚动列表",而是怎么把它沉淀成一个可复用的无限滚动组件——你把它丢到任何页面,传一个数据加载函数和 item 构建函数,剩下的事情组件自己处理。
读这篇内容的人,我默认你至少写过 Flutter 的ListView,对StatefulWidget、setState这些基础概念不陌生。如果你是完全的新手,建议先把ListView.builder跑通再来,否则下面那些细节和坑你很难有体感。
2. 核心实现:ScrollController 监听、阈值判断与分页状态机
要做无限滚动,第一步是感知"用户快到底了"。Flutter 里最常见的做法是用ScrollController监听滚动位置的变化,然后判断当前偏移量离底部还有多远。很多人第一次写的时候会困惑:为什么要在initState里给 controller 挂监听?为什么判断用的是pixels >= maxScrollExtent - 200而不是==?这里我先把原理拆开讲清楚。
2.1 ScrollController 的工作原理
ScrollController不只是用来"回顶部"的。它内部维护了一个ScrollPosition,也就是当前滚动位置状态。当你给ListView的controller参数传了这个实例,ListView 在初始化时会把这个 controller 和内部的ScrollPosition绑定。之后每次用户滑动、惯性滚动、动画滚动,ScrollPosition的pixels(当前像素偏移量)都会变化,同时触发 listeners 回调。
这里有三个关键属性:
position.pixels:当前滚动到的像素位置。position.maxScrollExtent:能滚动的最大像素偏移量,也就是内容总长度减去视口长度。position.viewportDimension:视口在滚动方向上的尺寸(对竖屏列表来说就是可视高度)。
当pixels等于maxScrollExtent时,意味着滚到底了。但在实际体验中,我们肯定不能等用户完全滚到底才去请求数据——网络有延迟,如果到底才触发,用户会看到明显的"加载转圈"空档。所以一般会留一个提前量,比如maxScrollExtent - 200,也就是距离底部还有约 200 像素时就开始加载。
这 200 不是玄学,我一般会在视口高度的一半到一屏之间取。为什么?因为如果提前量太小,比如 50 像素,用户滑动的惯性稍大就会直接越过触发点,到底部了数据还没回来,体验就很突兀。如果提前量太大,比如一屏以上的距离就开始加载,那用户明明还有大量内容可看,你却提前触发请求,一方面浪费流量,另一方面可能出现"用户根本还没滑到,数据却已经加载完"的脱节感。200 像素大致是一个用户正常惯性滑动的缓冲区间,够用也稳妥。
2.2 状态的划分:不只是"正在加载"
无限滚动组件能不能写稳,状态设计占一大半功劳。看看多少个页面出过"重复请求"的问题——明明上一次请求还没返回,用户快速滑到底部,监听触发了十几次,后端背着十几份相同参数的请求,列表里出现一堆重复数据。根因就是没有把"是否正在请求"当成一个守卫条件。
我习惯把无限滚动的状态划分成四类:
| 状态 | 含义 | 触发行为 |
|---|---|---|
| idle | 空闲,可以加载下一页 | 滚动到阈值时触发加载 |
| loading | 正在请求下一页 | 忽略所有触发信号 |
| noMore | 没有更多数据了 | 停止监听触发 |
| error | 加载失败 | 展示失败提示,可重试 |
核心就是两把锁:_isLoading和_hasMore。_isLoading保证在请求没有返回期间,重复的滚动触发不会发出新请求;_hasMore保证数据已经拉完时,不再白白发请求。很多初写者只记得_isLoading,忘了_hasMore,结果列表到底了还在不停地请求空数据。
2.3 一个最小可跑的实现
先把最基础的版本放出来,看完这段再拆解怎么封装成通用组件:
class InfiniteListPage extends StatefulWidget { const InfiniteListPage({super.key}); @override State<InfiniteListPage> createState() => _InfiniteListPageState(); } class _InfiniteListPageState extends State<InfiniteListPage> { final ScrollController _scrollController = ScrollController(); final List<String> _items = []; int _page = 1; bool _isLoading = false; bool _hasMore = true; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); _loadMore(); } @override void dispose() { _scrollController.dispose(); super.dispose(); } void _onScroll() { if (_isLoading || !_hasMore) return; final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 200) { _loadMore(); } } Future<void> _loadMore() async { setState(() { _isLoading = true; }); // 模拟接口请求,真实场景替换为 HTTP 调用 final list = await _fetchPage(_page); if (!mounted) return; setState(() { _items.addAll(list); _page++; if (list.length < _pageSize) { _hasMore = false; } _isLoading = false; }); } }这段代码有几个细节值得注意。第一,_onScroll里先判断_isLoading || !_hasMore,这个判断放在 distance 判断之前,因为布尔判断开销极小,可以快速短路,避免在滚动回调里做多余的数值计算(虽然这点计算可以忽略,但习惯要养好)。第二,_loadMore在请求返回后必须再判断一次mounted,因为 widget 可能已经在请求期间被用户销毁了,不判断的话直接setState会报错。第三,判断是否还有更多数据,我用的是"返回的条数是否小于页大小",这是分页接口里最常用、最可靠的判断方式——后端一般按页返回,如果这一页都不满,说明后面没了。
但你看这个实现,完全是为当前这个页面定制的:列表类型写死了String、请求函数写死在页面里、底部 loading 的样式没有任何自定义。所以下面要说的,就是怎么把它从"这个页面里的一段逻辑"提炼成"一个所有页面都能用的组件"。
3. 把无限滚动封装成通用组件:数据层与 UI 层彻底解耦
无限滚动这件事,逻辑是完全通用的:监听滚动、判断阈值、请求数据、追加列表、维护状态。不同的只是"用什么 item 渲染每条数据"和"从哪里拿数据"。所以理论上完全可以把通用逻辑抽成组件,把差异部分用参数暴露出去。这就是标题里"无限滚动组件"的核心价值。
3.1 组件对外暴露哪些接口
设计接口时我想得比较清楚:一个无限滚动列表,调用方最关心四件事——加载数据、渲染条目、是否触底、空态长什么样。
于是我的组件大致长这样:
class InfiniteListView<T> extends StatefulWidget { const InfiniteListView({ super.key, required this.itemBuilder, required this.loadPage, this.controller, this.onRefresh, this.onLoadMoreError, this.emptyWidget, this.itemExtent, this.threshold = 200, this.physics, this.padding, }); final IndexedWidgetBuilder itemBuilder; final Future<List<T>> Function(int page) loadPage; final ScrollController? controller; final Future<void> Function()? onRefresh; final Widget Function()? onLoadMoreError; final Widget? emptyWidget; final double? itemExtent; final double threshold; final ScrollPhysics? physics; final EdgeInsetsGeometry? padding; ... }几个设计决策的思考过程说一下。
用loadPage(int page)而不是loadPage(int page, int pageSize),是为了简化调用方的实现。页大小在真实项目里要么后端固定,要么前端统一定义,没必要每个页面都传一遍,组件内部用常量管理即可。
itemBuilder用 Flutter 原生IndexedWidgetBuilder类型,好处是熟悉 ListView 的人零学习成本,而且index参数可以直接对应列表里的数据位置,方便做数据索引传递。
controller设计成可传可不传。组件内部自己会创建一个ScrollController用于监听,但如果某个页面需要同一个 controller 做"回到顶部"之类的操作,外部传入则可以互相共享。这里要注意,组件内部要把外部传入的 controller 和内部逻辑绑定,而不是直接ignorePointer掉外部的 controller——否则页面上再也无法主动控制滚动位置了。
3.2 泛型和 Builder 是怎么配合的
组件内部会维护一份List<T> _items,类型 T 完全由调用方决定。数据加载后_items.addAll(list),然后itemBuilder在构建时通过列表索引拿到数据:
Widget _buildItem(BuildContext context, int index) { if (index >= _items.length) { // 超出当前数据范围的索引,返回底部状态组件 return _buildFooter(); } return widget.itemBuilder(context, index); }为什么底部状态组件不单独放在ListView的尾部,而是通过index判断返回?因为ListView.builder的itemCount有两种设计方式。第一种是itemCount = _items.length + 1,最后一条 item 的位置用来渲染 footer;第二种是itemCount = _items.length,footer 单独作为ListView的footer参数或Column尾部。我强烈建议用第一种,因为它让 footer 和列表项一起参与懒加载和滚动连贯性处理,而且当列表数据变化引起滚动位置变化时,整体表现更自然。
footer 的渲染逻辑也很清晰:_isLoading时显示 loading 指示器;!_hasMore时显示"没有更多了";错误状态时显示重试按钮。
3.3 组件与页面之间的"通信"设计
无限滚动组件本身是自洽的,但页面往往还关心两件事:数据加载失败后要不要给用户提示;列表数据更新后页面已有的其他状态要不要跟着变。这里就涉及 Flutter 里常说的"组件通信"。原生开发里可能要用 EventChannel 这类桥接手段,但 Flutter 组件之间通信没那么复杂——无非三种手段:构造参数传回调、GlobalKey暴露组件方法、状态管理框架的事件或 store。
我建议绝大多数场景直接用回调。比如组件暴露一个onLoadFailed回调:
// 组件内部 loadPage 抛出异常时 widget.onLoadFailed?.call(error, _page);页面可以在回调里弹 toast、上报日志、或者记录一个"列表是否出过错"的状态,用于后续展示。这样组件不需要感知页面的业务细节,页面也能对列表的"健康状态"有感知。如果有些场景需要外部主动触发刷新列表,比如"用户点了切换 tab,列表要重置重新加载",那么用GlobalKey暴露refresh()方法是最直观的做法。不要为了追求复杂而引入 EventChannel 之类的桥接,Flutter 的纯 Dart 世界里,回调基本上能解决九成通信需求。
3.4 下拉刷新怎么和无限滚动共存
无限滚动通常要配合下拉刷新。Flutter 自带的RefreshIndicator包一层ListView就能实现,但它的问题在于:刷新完成后,RefreshIndicator的回调只告诉你"可以收起动画了",不会帮你清空现有数据、重置页码、回到顶部。这些都得自己做。
我习惯把刷新逻辑放进组件内部统一管理。流程是:
onRefresh触发。- 组件把
_page重置为 1。 - 调用
loadPage(1)获取第一页数据。 - 成功后用新数据替换
_items,而不是addAll。 - 如果
loadPage失败,保留原来的数据不变,并通知页面刷新失败。
第 5 点很容易被忽略。很多人的做法是先_items.clear()再请求,失败后列表就变成空白了。正确做法是请求成功后替换数据,失败时数据不动,只弹提示。我自己踩过这个坑,刷新失败导致列表洗白,用户再想看到内容只能杀掉 App 重进,这种体验是非常糟糕的。
4. 实测中的性能细节:itemExtent、滚动节流与状态隔离
无限滚动组件的逻辑跑通只是第一步。真正让用户觉得"这个列表好用",还差一轮性能优化。这几个优化点是我在实践里反复验证过的,对长列表的流畅度影响非常直接。
4.1 itemExtent 的威力
ListView.builder的懒加载只负责"按需构建",但每一项构建出来之后,Flutter 还是要经过布局阶段去计算它有多高。如果每一项的高度是不确定的,布局引擎就得真实地测量每个可见项。滚动过程中,新的 item 不断进入视口,每一项都要走"构建 -> 测量 -> 布局 -> 绘制"这条链路。当 item 结构复杂(图片、多行文本、嵌套布局)时,这个测量成本会累积出肉眼可见的掉帧。
itemExtent的作用是告诉 Flutter:"这一项的高度就是这么多,你不用真实测量了。"一旦指定,Flutter 会直接跳过测量步骤,滚动时的布局计算快了很多。实测中,一个包含网络图片和两行文本的标准卡片列表,设定itemExtent: 100之后,滑动流畅度提升非常明显。但前提是你必须知道 item 的固定高度,或者能接受所有 item 高度统一。如果 item 高度不固定,该用itemExtent就不合适,可以换prototypeItem或者接受逐项测量的成本。
即便不用itemExtent,也建议尽量保证 item 布局"够扁平"——减少过深的 widget 嵌套。嵌套层级每多一层,布局和绘制阶段的计算量就多一层,累计起来的性能差异很可观。
4.2 滚动监听的节流与判断顺序
ScrollController的 listener 在每次滚动都会触发,连续快速滑动时,回调的触发频率可能达到每秒几十次以上。虽然每次回调里的逻辑很轻量,但如果你在里面写了复杂的判断或者触发了 setState,性能就会出问题。
我建议做两层优化:
第一,所有资源消耗高的操作不能直接放在 listener 里。最典型的就是"每次滚动都 setState 更新 footer 状态"。要避免这个,可以让 footer 的 loading 状态与滚动无关——只要_isLoading为 true,footer 固定显示 loading,不随滚动位置变化而重建。
第二,判断顺序要合理。_isLoading和_hasMore是最轻量的布尔判断,放在最前面;阈值判断是数值运算,放后面;最后才调用加载方法。这样大部分滚动回调在几步内就 return 了,开销接近零。
有人把滚动监听改成WidgetsBinding.instance.addPostFrameCallback或者Timer节流,但实测下来,只要不在 listener 里做重活,直接用ScrollController的 listener 就够了。节流的代码反而增加复杂度,收益有限。
4.3 图片懒加载与滑动时的资源释放
无限滚动列表的另一个隐形杀手是图片。如果 item 里有网络图片,默认情况下 Flutter 会在 item 滑出视口时保留它已加载的图片缓存,但如果你不做任何缓存控制,每次重新滑回同一个 item 时,图片可能要走一遍网络请求。我常用的策略是:
- 用
cached_network_image包一层图片加载,它在内存和磁盘上都有缓存,重复滑动不再请求网络。 - 对超大图的缩略图,服务端直接返回压缩版本,客户端不做全尺寸加载。
- 在
ListView上设置addAutomaticKeepAlives: false和addRepaintBoundaries: true(builder 默认是 true,不用动),避免不必要地持有离屏 item 的状态。
图片加载是无限滚动列表体验分水岭。数据加载得再快,图片一卡,用户感知到的还是"这个 App 不行"。
4.4 组件内部状态隔离的坑
组件通用化之后,有个坑特别容易出现:状态被多个页面共享。如果你的无限滚动组件用的是全局状态管理(比如把_items、_page放进了某个全局 store),那么 A 页面滚动加载了 50 条数据,切换到 B 页面时,组件可能直接复用了 A 页面的数据——因为 store 是同一个。
我的建议是:无限滚动组件的状态保持"组件内部自持",也就是State里面的普通字段,不要提升到全局。如果页面之间真的要共享列表数据(比如 tabs 切换保留各列表独立状态),可以设计成在组件外部、页面级别各自持有一个数据源实例,组件只负责渲染和触发。这里就涉及组件通信更复杂一点的场景:组件向外部报告"我滚动到底了,请给我数据",外部把状态和缓存都管理起来。这种场景下,loadPage回调里带上页号,页面自己决定从缓存还是从网络取,组件完全不感知。
我见过不少团队一上来就给无限滚动列表接 Bloc、Riverpod、Provider 全家桶,结果是维护成本直线上升。说句心里话,无限滚动的状态机天生适合内聚在组件内部,全局状态管理反而容易引入跨页面污染。等真到了多个页面需要共享和联动列表数据的阶段,再升级也不迟。
5. 边界情况处理:刷新回跳、空态展示与失败恢复
无限滚动组件做到这一步,核心逻辑和性能优化都齐了,但真正影响"好用到什么程度"的是边界情况。用户遇到的不是"加载更多"这种顺利场景,而是"网络断了怎么办""刷新完停留在第 300 条怎么办""数据为空怎么办"。这些不处理,组件只能算玩具。
5.1 刷新后回到顶部还是保持位置
下拉刷新完成后,列表数据从头开始,如果用户本来已经滑了几屏远,刷新完还停在原来的位置,看到的却是全新的第一条数据,视觉上是"跳帧"的感觉。我通常做两种选择:
- 简单方案:刷新成功后
scrollController.jumpTo(0),强制回到顶部。适用于内容偏好"从头看"的场景,比如新闻流、商品列表。 - 进阶方案:记录刷新前的
pixels,刷新后重新计算当前数据数量对应的位置,尽可能保持浏览位置。这个方案实现成本高,而且刷新前的数据被替换后,位置对齐的语义本来就不准确,所以我一般只在"新增了少数置顶内容"的场景用。
实际项目里,如果需求没有明确要求,回到顶部是最不容易出问题的默认行为。做个侧滑手势或者按钮让用户可以一键回顶,比强行维持位置好得多。
5.2 空态展示的时机
空态看起来很简单的功能,但放错地方会产生误导。很多列表组件加载第一页数据返回 0 条时,直接渲染一个空态页面,这是合理的。麻烦的是"加载中"和"空态"的切换时机。
我第一次做的时候把空态放在了_items.isEmpty && !_isLoading的条件里,但忽略了初次加载时_items也是空的。结果一进页面,loading 转圈的同时,空态也闪现了一下。后来我加了个_isFirstLoad的标志,初次加载期间只显示 loading,下完数据之后再判断空态。这部分代码没什么技术含量,但非常能体现一个组件在细节上的成熟度。
组件暴露的emptyWidget参数也很关键——不同页面空态的文案和样式完全不同,有的要一个插画加一句"这里什么都没有",有的要放个"去登录"按钮。组件内部负责"什么时候显示空态",页面负责"空态长什么样",职责边界划清楚。
5.3 加载失败后的恢复策略
加载失败不能只弹一个 toast 就完了。用户往下滑,第二次触发加载时,如果还是失败,是不是还弹 toast?连续失败三次要不要停止自动加载?这些规则我在组件里统一处理。
我的默认策略是:失败后底部 footer 显示"加载失败,点击重试",同时把_isLoading置回 false,这样用户继续往下滑会再次触发加载,也允许点击 footer 主动重试。连续失败次数我会记录,超过 3 次后不再自动触发加载,只展示重试按钮,防止用户不知情的情况下反复发请求消耗流量和电量。
loadPage抛出的异常,组件内捕获后setState把状态切换到 error,并向外抛一个onLoadMoreError回调,页面可以在这里做统一的埋点上报。注意异常要catch起来,不能让异常直接冒泡导致红屏。
5.4 数据量变化时的索引跳动
无限滚动还有一个比较隐蔽的问题:当列表数据在滚动过程中被外部变更(比如用户删除了某一条内容、点赞后 item 被置顶),列表索引会发生变化。如果监听滚动位置没处理这层变化,可能出现两种情况:
ScrollController的position.maxScrollExtent在数据变化后重新计算,如果用户当前滚动位置大于新的 maxScrollExtent,会自动被 clamp 到边界,视觉上表现为"咻"地一下跳到底部或顶部。- 如果正在加载下一页的过程中数据被替换,加载完追加时可能出现数据错位。
解决思路是:发起加载前,把当前滚动位置记录下来;加载完成后,计算新的数据总量和旧数据总量的差值,把滚动位置偏移回去。这部分的实现要结合 item 高度来换算,不复杂但很容易被忽略。我通常在商品收藏页(用户会频繁删除 item)遇到这个问题,处理了之后用户滑动体验稳定不少。
6. 组件最终的形态与使用示例
说了这么多,最后把组件的完整骨架和调用方式贴出来,给想直接抄的人一个参考。
组件核心逻辑整合后大概是这个形态:
class InfiniteListView<T> extends StatefulWidget { const InfiniteListView({ super.key, required this.itemBuilder, required this.loadPage, this.controller, this.onRefresh, this.onLoadMoreError, this.emptyWidget, this.itemExtent, this.threshold = 200, this.padding, this.physics, }); final Widget Function(BuildContext context, int index) itemBuilder; final Future<List<T>> Function(int page) loadPage; final ScrollController? controller; final Future<void> Function()? onRefresh; final void Function(Object error, int page)? onLoadMoreError; final Widget? emptyWidget; final double? itemExtent; final double threshold; final EdgeInsetsGeometry? padding; final ScrollPhysics? physics; @override State<InfiniteListView<T>> createState() => _InfiniteListViewState<T>(); } class _InfiniteListViewState<T> extends State<InfiniteListView<T>> { late final ScrollController _controller; final List<T> _items = []; int _page = 1; bool _isLoading = false; bool _hasMore = true; bool _isFirstLoad = true; bool _isRefreshing = false; int _consecutiveErrorCount = 0; @override void initState() { super.initState(); _controller = widget.controller ?? ScrollController(); _controller.addListener(_onScroll); _loadFirstPage(); } @override void dispose() { if (widget.controller == null) { _controller.dispose(); } super.dispose(); } void _onScroll() { if (_isLoading || !_hasMore || _isRefreshing) return; final position = _controller.position; if (position.pixels >= position.maxScrollExtent - widget.threshold) { _loadMore(); } } Future<void> _loadFirstPage() async { setState(() => _isFirstLoad = true); try { final list = await widget.loadPage(1); if (!mounted) return; setState(() { _items ..clear() ..addAll(list); _page = 2; _hasMore = list.length >= _pageSize; _isFirstLoad = false; _isLoading = false; }); } catch (e) { if (!mounted) return; setState(() { _isFirstLoad = false; _isLoading = false; }); widget.onLoadMoreError?.call(e, 1); } } Future<void> _loadMore() async { setState(() => _isLoading = true); try { final list = await widget.loadPage(_page); if (!mounted) return; setState(() { if (list.isNotEmpty) { _items.addAll(list); _page++; } _hasMore = list.length >= _pageSize; _isLoading = false; _consecutiveErrorCount = 0; }); } catch (e) { if (!mounted) return; setState(() { _isLoading = false; _consecutiveErrorCount++; }); widget.onLoadMoreError?.call(e, _page); } } Future<void> _handleRefresh() async { if (_isRefreshing) return; _isRefreshing = true; try { final list = await widget.loadPage(1); if (!mounted) return; setState(() { _items ..clear() ..addAll(list); _page = 2; _hasMore = list.length >= _pageSize; _isRefreshing = false; }); } catch (e) { if (!mounted) return; setState(() => _isRefreshing = false); widget.onLoadMoreError?.call(e, 1); } } @override Widget build(BuildContext context) { if (_isFirstLoad) { return const Center(child: CircularProgressIndicator()); } if (_items.isEmpty) { return widget.emptyWidget ?? const SizedBox.shrink(); } return RefreshIndicator( onRefresh: _handleRefresh, child: ListView.builder( controller: _controller, itemCount: _items.length + 1, itemBuilder: (context, index) { if (index >= _items.length) { return _buildFooter(); } return widget.itemBuilder(context, index); }, itemExtent: widget.itemExtent, padding: widget.padding, physics: widget.physics, ), ); } ... }页面里用起来就非常干净了:
InfiniteListView<Article>( itemBuilder: (context, index) => ArticleCard(article: _articles[index]), loadPage: (page) => Api.fetchArticles(page: page, pageSize: 20), onRefresh: () async { // 刷新时需要的额外逻辑,比如清缓存 }, onLoadMoreError: (error, page) { // 埋点或者弹提示 }, emptyWidget: const EmptyView(text: '暂无文章'), )组件内部把列表数据_items管理好,页面完全不用关心页码怎么维护、触底怎么判断、刷新和加载更多怎么协调。我把这个组件在两个项目里复用了十几处,再也没出现"这个页面忘了加滚动监听"这种低级 bug。
最后说一个我反复强调的体会:无限滚动组件不是一个"炫技"的存在,它真正的价值是把列表分页这种高频思维负担从业务代码里拿掉。业务开发者的注意力应该放在"这一页展示什么内容、怎么排版、怎么埋点",而不是"我是不是忘了判断 isLoading"。组件不是越复杂越好,能把loadPage和itemBuilder这两个口子留好,把滚动、状态、刷新、错误处理全部收敛起来,就已经完成了它 90% 的使命。剩下那 10%,留给你自己项目的特殊场景去扩展就好。