1. 内容整体设计与思路拆解
1.1 下拉刷新在移动端为什么这么难搞
先亮个身份:我平时主要用 React 技术栈做移动端 H5 和小程序,Taro 3.x 搭配 Taroify 组件库是最近两年团队里用得比较多的组合。Taroify 是 Taro 官方生态里那套偏 React 风格的 UI 组件库,PullRefresh 下拉刷新算是我在项目里踩坑最多的组件之一——不是因为组件本身有多烂,而是移动端下拉刷新的交互链路实在太长了:从用户手指触摸、滑动、到视觉回弹、再到异步请求完成、最后状态归位,中间任何一环没处理好,都会出现"刷新半天没反应"或者"页面直接卡死"这种让人抓狂的现象。
你可能觉得下拉刷新不就是监听 touchmove 然后改一下状态吗?如果真这么简单,就不会有这么多人往社区里发帖问"PullRefresh 怎么不触发"、"为什么下拉的时候页面也跟着滚动了"、"刷新完成后 loading 图标一直在转"。
从本质上讲,下拉刷新的难点在于三件事:嵌套滚动容器的冲突处理、异步请求的状态同步、组件生命周期与 Taro 多端渲染差异的调和。Taro 的 PullRefresh 组件已经帮我们封装了大部分手势识别和动画逻辑,但它把"判断何时触发刷新"的责任交还给了业务侧——开发者需要理解组件内部状态机的流转,才能在正确的时间点做正确的事。
1.2 Taroify PullRefresh 组件的工作模式
Taroify 的 PullRefresh 组件从设计上分成了两个部分:可视区域的指示器(也就是那个转圈或文字提示)和内容区域的滚动容器。它不像某些原生下拉刷新库那样直接接管整个 WebView 的滚动事件,而是通过 Taro 的 scroll-view 或 View 组件模拟了一套下拉刷新手势系统。
这套设计的好处是跨端一致性强——不管是微信小程序、支付宝小程序还是 H5,都走同一套 JS 手势逻辑,不会因为端能力差异导致交互分裂。代价就是性能敏感度高,尤其在低端 Android 设备或小程序 WebView 容器里,touch 事件的处理效率直接影响用户手感。
组件对外暴露的核心属性其实不算多,真正决定刷新逻辑的只有value(是否处于刷新中)和onRefresh(触发刷新回调),外加一个disabled(禁用下拉)。你可能会问:就这么点 API,能踩出什么坑?恰恰是因为 API 简单,大家才容易误用——比如把value当成了普通状态去同步赋值,却忽略了它和onRefresh之间的依赖关系,最后导致刷新回调永远只触发一次,或者 loading 结束后页面没有正确归位。
1.3 为什么选择 PullRefresh 而不是自研滚动容器
在动手解决问题之前,我先说下我的选型思路。头部 APP 的 WebView 页面和微信小程序页面,都有更底层的原生下拽刷新能力(比如小程序的enablePullDownRefresh、H5 的overscroll-behavior),那为什么还要用 Taroify 的 PullRefresh?
原因有三个。第一,原生的下拉刷新样式没法自定义,UI 设计师给的通常是品牌色圆环加文字提示,原生方案很难对齐。第二,原生下拉刷新的事件粒度太粗,不支持"下拉超过阈值才显示松手刷新""在下拉过程中实时更新文案"这种精细化控制,而 Taroify 的 PullRefresh 支持插槽,你完全可以在指示器区域塞进自定义动画。第三,也是最重要的一点,Taroify 的 PullRefresh 可以把刷新逻辑和页面业务代码解耦——请求状态管理在组件外部,刷新触发的视觉反馈交给组件,这样代码结构更清爽。
不过,任何选择都有代价。PullRefresh 的代价就是:你需要对它内部的工作机制有足够的认识,否则很容易写出"能跑但一碰就崩"的代码。下面我把我实际项目中踩过的坑和解决方案完整拆给你看。
2. 核心细节解析与实操要点
2.1 理解 PullRefresh 的状态流转闭环
先把 Taroify PullRefresh 的状态机画在脑子里(不能画图,你自己脑补一下):它有四个关键阶段——pulling(手指按住下拉)、loosing(下拉距离超过阈值,提示松手)、loading(触发刷新请求)、finished(请求完成,回弹归位)。
这四个状态的切换完全由组件内部管理,对外只暴露一个value布尔值。当你把value设为true,组件展示 loading;当你把value设为false,组件认为刷新结束,开始回弹。也就是说,外部不需要关心当前是 pulling 还是 loosing,只需要告诉组件"现在是否在刷新中"即可。
这听起来很美好,但坑就在这。由于 Taroify 的 PullRefresh 内部用了状态锁,当一次下拉刷新流程走完(即value从true变回false),组件需要一小段时间来重置内部状态。如果你在onRefresh里同步把value改回false——比如你的请求走了缓存,瞬间返回——组件内部还没来得及进入 loading 锁定状态,就收到了结束信号,结果就会出现"下拉释放后页面闪了一下,但指示器没有转圈,刷新似乎也没发生"的假象。
正确做法是:onRefresh触发后,至少异步等一个事件循环,或者干脆在请求的finally里再置回false。很多新手写的代码长这样:
const [refreshing, setRefreshing] = useState(false); const onRefresh = async () => { setRefreshing(true); // 假设请求瞬间完成 const data = await fetchList(); setRefreshing(false); };这个写法在本地 mock 数据时通常没问题,一旦到了真实网络环境,请求有延迟,就暴露问题了:快速下拉多次,或者下拉到一半松手,组件的状态锁可能卡在某个中间态,导致指示器一直转圈。
我给你的建议是:永远不要在 onRefresh 里同步操作状态,用setTimeout或者微任务把setRefreshing(false)延后一个 tick。如果你用了数据请求库(如 SWR 或 React Query),直接把请求的生命周期绑定到refreshing上:
const { isFetching, refetch } = useFetchList(); const onRefresh = async () => { // 让 PullRefresh 的 value 跟随 isFetching await refetch(); };这种写法天然同步,因为请求结束前isFetching一直为true,组件就不会收到提前结束的信号。
2.2 解决 PullRefresh 与页面滚动冲突的终极方案
这是 PullRefresh 被问得最多的一个问题:当页面内容可以上下滚动时,下拉刷新手势会被页面滚动“吃掉”,有时你明明在顶部下拉,却只看到页面反弹了一下,刷新组件毫无反应;有时又相反,手指还没触摸到顶部,只是下滑了一下,刷新就被误触发了。
Taroify 的 PullRefresh 默认是把手势监听挂在自身根节点上的,它通过判断当前滚动位置是否为 0 来决定是否接管下拉手势。但这个判断依赖于组件内部对滚动容器的识别。如果你的页面结构是"PullRefresh 包裹着一个 scroll-view",那组件能正确感知滚动容器的位置;但如果你的页面结构是"PullRefresh 外层的兄弟节点在滚动",或者外层有一个overflow-y: auto的 div 同时包含 PullRefresh 和页脚,组件就无法感知外层滚动位置,冲突就来了。
我的项目里遇到过一个典型场景:页面是一个长列表,列表上方有 tab 切换,整个页面结构是"外层 div 滚动,内部包含 tab 和 PullRefresh"。Taro 编译到微信小程序时,外层 div 变成了小程序的 page 滚动容器,这时候 PullRefresh 组件的手势识别就会失效,因为组件默认监听的是自身容器的 touch 事件,而不是页面级滚动事件。
解决方法分两步。第一步,把滚动容器明确地限定在 PullRefresh 内部,让 PullRefresh 成为真正的滚动容器。标准结构长这样:
<PullRefresh value={refreshing} onRefresh={handleRefresh} style={{ height: '100vh', overflowY: 'auto' }} > {/* 你的列表内容 */} </PullRefresh>关键看style:PullRefresh 自身必须具备可滚动的约束条件(高度固定 +overflow-y: auto),否则内部内容的高度会直接把容器撑开,PullRefresh 就永远滚动不了。
第二步,如果你的页面结构没法调整,必须让外层容器负责滚动,那只能在 PullRefresh 外层包一个"滚动位置守卫"。具体来说,就是监听外层容器的scroll事件,当滚动位置大于 0 时,动态给 PullRefresh 设置disabled;当滚动位置回到 0 时再恢复启用。这样能保证手指在非顶部区域滑动时,PullRefresh 不会抢事件。
我在项目里封装过一个自定义 hook 来做这件事,核心逻辑是:
const scrollRef = useRef<HTMLDivElement>(null); const [pullingDisabled, setPullingDisabled] = useState(true); useEffect(() => { const el = scrollRef.current; if (!el) return; const onScroll = () => { // 滚动位置超过1px就禁用下拉刷新,防止误触 setPullingDisabled(el.scrollTop > 1); }; el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); }, []);然后PullRefresh的disabled={pullingDisabled}。注意scrollTop > 1这个阈值很重要,不要用 0,因为 iOS 的滚动回弹会导致滚动位置短暂不为 0,用 1px 做缓冲可以有效避免闪烁。
2.3 多端差异:小程序端 PullRefresh 的隐藏陷阱
Taro 的口号是"一次编写,多端运行",但 PullRefresh 在微信小程序端的表现和 H5 端有着不小的差异,很多坑只有真机调试时才能暴露。
第一个差异是事件对象的结构。在 H5 端,Taro 的 touch 事件就是浏览器的原生事件,e.touches[0].clientY这些字段直接可用;但在微信小程序端,Taro 会做一层事件代理,你的获取坐标逻辑必须写成e.touches[0].clientY ?? e.changedTouches[0].clientY,否则某些机型和端上会拿到undefined。这块不是组件的问题,而是你写的自定义手势逻辑需要做兼容。
第二个差异是小程序原生的页面下拉刷新拦截。如果你在小程序页面的配置里开启了"enablePullDownRefresh": true,那原生窗口的下拉动作会优先触发,你的 Taroify PullRefresh 手势就会失效。我遇到过一次,现象是:H5 端一切正常,小程序端下拉没反应,查了半天发现是 app.config 里残留了原生下拉刷新的配置。两者二选一,千万别同时开。
第三个差异是滚动容器的识别。小程序端的"页面滚动"和"view 组件滚动"在原生层是两套完全不同的机制。Taroify 的 PullRefresh 组件内部如果用的是scroll-view,那么组件内部的scroll-view滚动事件能正常接收;但如果你的列表是普通 view 通过page 滚动实现的,PullRefresh 的触摸监听就感知不到页面滚动的状态。所以小程序端强烈建议把列表容器包在ScrollView组件里,并且把滚动的scrollY打开。
第四个差异是setData 的性能问题。小程序端每次 setData 都会触发一次视图层更新,如果你的onRefresh里塞了太多状态更新(比如刷新金额、刷新列表、刷新用户信息),组件会明显卡顿。建议把非关键状态合并成一个对象,或者用 Taro 的nextTick做一次批量更新。
2.4 控制刷新时机:如何避免重复请求和闪烁
PullRefresh 还有一个容易忽略的坑:onRefresh的触发频率不受组件限制。下拉一次松手,onRefresh会触发一次;如果你在请求还没有完成时又下拉了一次,组件会再次触发onRefresh,这时候就会出现两个并发的刷新请求,造成数据不一致。
这不是 Taroify 的 bug,而是使用者没有做好"请求不可重入"的保护。正确做法是在刷新函数入口加锁:
const [refreshing, setRefreshing] = useState(false); const refreshingRef = useRef(false); const handleRefresh = async () => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); try { await fetchData(); } finally { refreshingRef.current = false; setRefreshing(false); } };这里的关键点是refreshingRef和refreshing是两种不同的状态——一个用于阻止并发(同步的 ref 读写在事件循环中立即生效),一个用于驱动视图更新(异步的 state 渲染)。很多开发者只用 state,结果在快速下拉时因为有延迟,state 还没来得及更新,第二次 onRefresh 就进来了,照样并发。用 ref 做锁是我踩过坑之后才加上的,强烈建议你也这么干。
另外,闪烁问题也很常见:刷新完成后,列表数据更新了,但列表高度变矮,导致 PullRefresh 容器高度骤减,页面出现跳动。解决办法是给列表容器设置一个minHeight,让列表在数据加载完成前后至少保持一个视觉上的稳定高度,比如:
<View style={{ minHeight: '100vh' }}> {/* 列表内容 */} </View>这样即使数据从 20 条减少到 10 条,容器也不会塌缩,视觉上体验好很多。
3. 实操过程与核心环节实现
3.1 从零搭建一个带 PullRefresh 的完整页面
下面我贴一个我项目里的真实案例,这个页面是"订单列表",包含下拉刷新、滚动加载、错误重试三个功能。完整的代码依赖 Taro 3.6 以上版本、Taroify 1.x、React 18。
先看页面结构:
import { View, Text } from '@tarojs/components'; import { PullRefresh, Empty, Button } from '@taroify/core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchOrderList } from '@/services/order'; interface OrderItem { id: string; amount: number; status: string; } export default function OrderList() { const [list, setList] = useState<OrderItem[]>([]); const [refreshing, setRefreshing] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(false); const refreshingRef = useRef(false); const pageRef = useRef(1); // 加载列表数据 const loadList = useCallback(async (currentPage: number, isRefresh = false) => { try { setError(false); const res = await fetchOrderList(currentPage); const newList = res.list; if (isRefresh) { setList(newList); } else { setList(prev => [...prev, ...newList]); } setHasMore(res.hasMore); setPage(currentPage); pageRef.current = currentPage; } catch (e) { setError(true); } }, []); // 下拉刷新 const handleRefresh = useCallback(async () => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); try { await loadList(1, true); } finally { refreshingRef.current = false; setRefreshing(false); } }, [loadList]); // 滚动加载更多 const handleLoadMore = useCallback(() => { if (!hasMore || loadingMore || refreshingRef.current) return; setLoadingMore(true); const nextPage = pageRef.current + 1; loadList(nextPage).finally(() => setLoadingMore(false)); }, [hasMore, loadingMore, loadList]); // 首次加载 useEffect(() => { handleRefresh(); }, [handleRefresh]); return ( <View className='order-page'> <PullRefresh value={refreshing} onRefresh={handleRefresh} disabled={error} > {list.length === 0 && !refreshing ? ( <Empty description='暂无订单'> <Button color='primary' onClick={handleRefresh}> 重新加载 </Button> </Empty> ) : ( <View className='order-list'> {list.map(item => ( <View key={item.id} className='order-item'> <Text>订单金额:{item.amount}</Text> <Text>订单状态:{item.status}</Text> </View> ))} </View> )} {hasMore && ( <View className='load-more' onClick={handleLoadMore}> {loadingMore ? '加载中...' : '点击加载更多'} </View> )} {!hasMore && ( <View className='load-end'>没有更多订单了</View> )} </PullRefresh> </View> ); }这段代码我反复确认过几个关键点:disabled={error}是为了在请求失败时禁止下拉刷新,避免用户反复下拉触发失败的请求;空状态时依然保留 PullRefresh 的包裹,这样用户可以在空页面上下拉刷新而不是只能点按钮;refreshingRef作为锁,保证 handleRefresh 不可重入。这些都是实战中验证过的细节,你直接抄就行。
3.2 自定义下拉刷新指示器:从转圈到高级动画
默认的 PullRefresh 指示器是个加载图标加"下拉可以刷新"文字,但真实项目的 UI 往往需要自定义。Taroify 的 PullRefresh 支持通过children传入自定义内容,方式很灵活。
先看我最常用的一种自定义方式:
import { PullRefresh } from '@taroify/core'; import { View, Text } from '@tarojs/components'; <PullRefresh value={refreshing} onRefresh={handleRefresh} > {refreshing ? ( <View className='custom-refresh'>拼命加载中...</View> ) : ( <View className='custom-refresh'>下拉刷新</View> )} {/* 列表内容 */} </PullRefresh>但这种方式有个问题:你只能区分"刷新中"和"非刷新中",无法感知下拉距离和松手状态。如果你要做"下拉超过 80px 显示松手刷新,没超过显示继续下拉"这种细腻交互,就需要在children外部访问 PullRefresh 内部的状态。
Taroify 的 PullRefresh 组件没有直接暴露"下拉进度"的 prop,这时候有一个 hack 方案:通过 Taro 的createIntersectionObserver或者自己监听触摸事件,把下拉位移转换成进度。不过我不推荐在业务代码里这么干,因为耦合度太高,组件升级就废了。
更稳妥的方式是利用 Taroify 的PullRefresh包裹一个自定义的滚动监听容器,把下拉滑动的位移转换成元素的translateY。这个方案的原理是:当 PullRefresh 处于 pulling 状态时,内部children会被组件向下推移;你可以在 children 的顶层节点上加一个onTouchMove事件,读取touchmove的 deltaY,然后映射到指示器的样式上。
不过说实话,对于 90% 的项目,默认指示器加自定义文案已经够用了。我做过一次大型活动页的下拉刷新定制,最后发现单纯换文字和图标最省事,性能也最好。花里胡哨的动画在小程序端很容易掉帧,特别是刷新指示器在列表滚动时还涉及大量重绘,能把你的性能分扣得惨不忍睹。
3.3 接入请求状态:React Query 无痛绑定 PullRefresh
如果你在项目里用了数据请求库,比如 TanStack Query(React Query)或 SWR,PullRefresh 的接入就变得非常简单——不再需要手动管理refreshing状态了。
以 React Query 为例:
import { useQuery } from '@tanstack/react-query'; import { PullRefresh } from '@taroify/core'; const fetchOrders = async ({ queryKey }) => { const [, page] = queryKey; const res = await fetchOrderList(page); return res; }; export function OrderList() { const { data, refetch, isFetching } = useQuery({ queryKey: ['orders', 1], queryFn: fetchOrders, }); return ( <PullRefresh value={isFetching} onRefresh={() => { // React Query 的 refetch 会返回 promise // 组件内部有请求去重机制,不会重复请求 refetch(); }} > {/* 数据渲染 */} </PullRefresh> ); }注意这里的isFetching和isRefetching的区别:isFetching包含首次加载和后台刷新,isRefetching只包含下拉刷新触发的重新请求。如果你的首次加载也需要展示 PullRefresh 的 loading,就用isFetching;如果只想在用户主动下拉时展示,就用isRefetching,首次加载单独用骨架屏。这两种模式我都在项目里用过,根据需求选一个就好。
React Query 的好处是自带的"请求去重"和"缓存失效"能力,能天然解决我在前面提到的重复请求问题。refetch()在同一时间只会发起一次请求,剩余的对同一 key 的 refetch 会被合并。这一点比我手动加refreshingRef锁优雅得多。
3.4 性能优化:列表页下拉刷新的关键渲染指标
移动端性能优化是绕不开的话题,PullRefresh 组件本身也可能成为性能瓶颈。我有一次在做长列表页面时,列表有 100 条数据,下拉刷新后需要重新渲染整个列表,结果在低端安卓机上下拉动画明显卡顿。排查下来,发现是渲染数据量太大,组件树太深,每次状态变化都要 diff 大量节点。
优化方案有三种层次:
第一种,虚拟列表。列表数据多时,别直接渲染所有节点,用 Taro 的VirtualList组件(Taro 3.6 以上内置)或者 Taroify 的VirtualList。PullRefresh 和虚拟列表可以配合使用,只需确保虚拟列表容器的高度是固定值即可。
第二种,数据剪枝。下拉刷新后,如果服务端返回了 100 条,但页面上一次只显示 20 条,就不要把 100 条全 setState 进 store。用分页思想,先渲染一页,滚动到底部再追加。这样下拉刷新的重渲染成本直接降低五倍。
第三种,优化状态更新。不要在onRefresh里同时更新多个独立 useState,尽量合并成一整个对象。React 18 的自动批处理已经改善了很多,但在 Taro 小程序端,状态更新最终要经过 setData,合并成一个对象能减少小程序 setData 的次数。
举个例子,如果你要更新"订单列表、分页、总数、同步时间"四个状态,不建议这样:
setList(newList); setHasMore(false); setTotalCount(100); setSyncTime(Date.now());建议合成一个:
setPageState({ list: newList, hasMore: false, totalCount: 100, syncTime: Date.now(), });虽然写法上多了一层对象,但在小程序端的渲染性能提升是立竿见影的。
4. 常见问题与排查技巧实录
4.1 下拉刷新完全没反应:八成是事件被吞了
现象描述:手指在屏幕上疯狂滑动,PullRefresh 组件纹丝不动,好像根本没有绑定事件一样。
排查步骤我按优先级列出来:
第一步:检查 PullRefresh 是否被 disabled。如果你在代码里写了disabled={error}或某个条件,先确认这个条件当前的值。遇到过一个情况:请求失败后设置了 error=true,然后 PullRefresh 被禁用,列表没有数据,页面空荡荡,用户想下拉刷新,但 disabled 阻止了手势。这种情况不算 bug,但是交互上容易让人困惑,所以要看 UI 上是否有明确的错误提示。
第二步:检查滚动容器是否为 PullRefresh 本身。前文说过,PullRefresh 内置的手势监听依赖于"滚动位置为 0 时才开始响应下拉"。如果你的页面滚动容器是 pull-refresh 的外层,那组件识别不到内层滚动的状态,自然就吞掉了手势。这时候需要把滚动容器移到 PullRefresh 内部,或者手动监听外层滚动位置来动态禁用/启用。
第三步:检查是不是小程序端原生下拉刷新抢占。在微信开发者工具里打开页面配置,搜enablePullDownRefresh,如果为 true,果断删掉。原生和组件不能共存,二者取其一。
第四步:真机调试,看触摸事件是否触发。在 PullRefresh 根节点上加一个onTouchMove,打印e.touches[0].clientY,如果打印正常说明事件有,组件没识别;如果打印 undefined,那就是多端事件兼容问题,用e.detail或changedTouches再试一次。
我遇到过最隐蔽的一次:H5 端正常,小程序开发工具正常,但真机上下拉完全没反应。后来发现是项目里某个全局样式把touch-action设成了none,直接干掉了所有触摸事件。查了半天,最后发现是引入的一个轮播图组件的全局样式污染。这个教训就是:排查事件问题时,先检查全局样式里有没有touch-action、user-select之类干扰属性的设置。
4.2 下拉刷新触发了但 loading 图标一直在转
现象描述:onRefresh正常执行,请求也成功了,数据也更新了,但 PullRefresh 的 loading 指示器就是停不下来,像转圈圈上了瘾。
这种情况 99% 是value没有被正确置回false。可能的原因有三类:
第一类,你在 onRefresh 里面写了 setRefreshing(true),但没有写的 setRefreshing(false)。常见于请求函数被 try-catch 包裹,但 catch 分支里只做了错误提示,忘了在 finally 里复位。一定要用 try-finally 或 promise.finally。
第二类,把 value 绑定的不是请求状态,而是某个常量。比如你直接写了value={true},忘了改成动态状态,导致组件永远认为正在刷新。这种低级错误很常见,我代码 review 时经常抓到。
第三类,异步状态更新丢失。在 React 中,如果你在定时器或某些异步回调里调用 setRefreshing(false),而组件此时已经被卸载,那状态自然不会生效。这种场景多存在于:从列表页跳转到详情页,回来后发现之前那个列表页的 PullRefresh 还在转(实际上页面已经重建了)。解决办法是给请求加一个 mounted 标记:
useEffect(() => { let mounted = true; // 在异步回调里检查 mounted return () => { mounted = false; }; }, []);4.3 下拉刷新和滚动加载互相打架
这是列表页最常见的组合场景:下拉刷新和触底加载同时存在。很多开发者发现,下拉刷新的手势还没完全释放,触底加载就触发了;或者刷新完成后,列表加载了很多新数据,结果页面又自动触发了 loadMore,导致重复请求。
解决思路是:让下拉刷新和触底加载共享同一个"请求锁"。我在前面代码里的refreshingRef就是干这个的。当 handleRefresh 执行时,锁被占用,loadMore 直接 return;当 loadMore 执行时,如果 refreshingRef.current 为 true,同样 return。这样两个操作天然互斥,不会打架。
另外,还有一个小细节:触底加载的触发距离。Taro 的 scroll-view 有一个lowerThreshold属性,我通常设置为 100px,意思是距离底部 100px 时触发加载。这样即使用户在快速滑动时,也能提前加载下一页,减少等待。
但如果你的列表高度刚好超过一屏一点点,触底加载可能会在下拉刷新回弹时被动触发,因为页面回弹导致滚动位置瞬间变化。解决方案是给 list 的末尾加一个固定的占位元素,并且 loadMore 只在用户主动上滑到不可见区域时才触发,不要依赖 scroll 事件的 belt 触发。实际中我推荐用 Taroify 的InfiniteScroll组件替代手写 loadMore,它自带了防抖和阈值控制,和 PullRefresh 配合使用更省心。
4.4 小程序端 PullRefresh 出现空白闪烁
这是我在一次活动页开发中遇到的:微信小程序端,下拉刷新完成后,页面内容会闪一下白屏,然后才展示数据。H5 端完全正常,只有小程序端有。
原因定位到小程序特有的渲染机制:setData更新大列表时,原生层会重新计算节点布局,如果新列表数据和旧列表数据的结构差异大,比如旧列表是 20 个 A 类型节点,新列表是 10 个 B 类型节点,原生层会丢弃旧节点并创建新节点,这个瞬间就会出现空白。
解决方案有几种。最直接的是给列表容器加wx:key或key属性,确保 Taro 可以复用已有的节点而不是全部重建。我这里的代码里已经给每个订单项加了key={item.id},但要注意你的数据是否每次都返回相同的 id。如果刷新后数据乱序且 id 不稳定,节点复用就失效,闪烁依然存在。
另一种方案是开启小程序的分层渲染。在 Taro 页面的config里设置"renderer": "skyline"(如果项目基础库支持),Skyline 渲染引擎对滚动和列表渲染的性能更好。不过我提醒一句:Skyline 和 Taroify 的部分组件存在兼容性问题,尤其是涉及position: sticky和overflow的组件。在没有充分测试前不要轻易切换。
最稳妥的方案其实是让列表容器高度稳定。记住我之前说的minHeight技巧,闪烁的一个重要原因就是刷新前后容器高度变化太大,给容器设置一个最小高度,让渲染结果至少有个承接,视觉上的空白感会小很多。
5. PullRefresh 进阶调优与团队协作规范
5.1 设计一个新员工也能维护的刷新方案
PullRefresh 之所以容易出问题,还有一个原因是"每个页面都有一套自己的刷新逻辑"。代码规范不一致,你在这个页面用refreshing,另一个页面用pullingDown,第三个页面直接用 ref,维护成本很高。
我最近在团队里推进了一套"统一刷新封装",效果很好,分享一下。抽象一个usePullRefresh的 hook,所有页面统一从它取状态:
// hooks/usePullRefresh.ts import { useCallback, useRef, useState } from 'react'; export function usePullRefresh() { const [refreshing, setRefreshing] = useState(false); const refreshingRef = useRef(false); const startRefresh = useCallback(async (task: () => Promise<void>) => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); try { await task(); } finally { refreshingRef.current = false; setRefreshing(false); } }, []); return { refreshing, startRefresh, lockRef: refreshingRef, }; }然后页面里的用法:
const { refreshing, startRefresh, lockRef } = usePullRefresh(); const handleRefresh = useCallback(() => { return startRefresh(async () => { const res = await fetchList(); setList(res); }); }, [startRefresh]);这个 hook 的好处是:业务代码里不会出现散落的 ref 锁,不会出现忘记 reset 的状态,不会出现多个页面写法不一致。新员工接手时,只需要记住startRefresh传入一个异步任务,其他都不用管。
5.2 从技术选型到性能预算:PullRefresh 的完整落地清单
如果你想在一周内把一个带 PullRefresh 的页面做到既稳又快,我建议你按这个清单执行:
第一,确认 Taro 版本和 Taroify 版本兼容性。Taro 3.6 之前的版本和 Taroify 1.0 之后的版本,在scroll-view的事件绑定上有一些小 bug,使用前先去 Taroify 官网看一下版本要求。
第二,把 PullRefresh 组件库的样式引入方式统一。Taroify 默认按需引入样式,如果你项目里配的是全量引入,在 H5 端没问题,小程序端会打包出很多冗余样式,影响体积。建议用babel-plugin-import配置按需引入。
第三,评估下拉刷新区域的面积。PullRefresh 的触摸监听范围是整个容器,如果你把容器高度设置成100vh,那整个屏幕都是下拉区域,误触概率高;如果你把容器设置成内容实际高度,又会出现下拉无反应。我的经验是:列表页用100vh没问题,因为用户知道列表页可以下拉刷新;但如果是表单页或详情页,建议只在顶部 60% 区域开启下拉,防止误触。
第四,监控下拉刷新的响应速度。一个合格的下拉刷新,应当从手指松手到 loading 出现控制在 100ms 以内。如果超过这个阈值,说明你的手势识别或状态更新有性能瓶颈。可以在onRefresh里加一个performance.now()日志,快速定位延迟发生在哪一段。
第五,做好降级方案。如果用户网络很差,下拉刷新后请求长时间不结束,loading 一直转,用户体验极差。建议在 PullRefresh 外层套一个"刷新超时"逻辑,例如 15 秒无响应就强制置回refreshing=false,并展示错误提示。这个逻辑不要写在业务页里,放进 usePullRefresh 的 hook 里,统一处理。
5.3 与设计团队对齐刷新交互规范
最后说点非技术层面的经验。下拉刷新不只是技术组件,它还是产品交互的一部分。很多纠结"为什么实现这么难"的问题,其实是产品需求本身就有冲突:既要求"任何位置都能下拉刷新",又要求"不能误触",这两者是矛盾的。
我经历过一个项目,设计师要求下拉时展示一个弹性动画,并且动画要跟随手指位移实时缩放,小屏幕、大屏幕、刘海屏都要适配。这种精细交互在 H5 端可以实现,但在小程序端,手势事件的频率和渲染性能根本撑不住。最终我们在技术评审时强行砍掉了实时跟随的动画,改成"下拉超过阈值后播放一段 300ms 的固定动画"。最终用户反馈并不差,因为用户真正在意的是"刷新是否生效、数据是否正确更新",而不是那 0.3 秒的动画细节。
所以,如果你在产品沟通中遇到"为什么别人家的下拉刷新动画那么丝滑"这种质疑,你可以从性能预算角度解释:流畅的动画需要 60fps 的运行能力,而小程序 WebView 和低端 Android 机很难保证,技术实现上必须做取舍。与其花时间打磨动画,不如保证刷新逻辑的稳定性和数据的正确性——这是 PullRefresh 组件的核心价值,也是我在这个标题下最想分享给你的经验。