很多人做移动端页面时都会直接套用现成的组件库,pull-refresh和load-more一套上,列表就能拉了。但一旦遇到嵌套滚动、自定义回弹、或者需要精确控制加载时机时,组件库就有点使不上劲了。更大的问题是:很多人用了多年下拉刷新,却说不清它和滚动监听之间到底是什么关系——为什么有些场景要监听scroll,有些又要监听touch?为什么随便一个库都能做到的事,自己写就卡顿?
这篇文章想把这些原理一次讲透。我会从交互的本质开始聊,然后给出两份完全独立的原生实现(一套基于touch事件做下拉刷新,一套基于scroll事件做上拉加载),再讲清楚它们在实际项目里遇到的边界问题,最后用表格梳理一下数据和状态机的配合方式。如果你只需要"能跑"的代码,可以直接抄第二、三节的实现;如果你想知道"为什么这么写",那这整篇都值得读完。
1. 先搞清楚交互本质:上拉加载和下拉刷新到底在解决什么问题
很多教程一上来就给你贴代码、讲手势,但很少说清楚这两个交互解决的问题到底是什么。我第一次正经做这个功能时也走了弯路,总想着"怎么实现",却忽略了首先是"为什么要这样设计"。聊完本质之后你会发现,核心代码量其实非常小,难点全在边界场景和状态管理上。
下拉刷新的本质是**"拉"这个手势与"刷新动作"之间的映射**。用户停留在页面顶部时,如果再做一个向下拖拽的动作,系统需要识别这个动作的意图——这不是误触,而是希望重新从服务器获取最新数据。所以它至少包含三个部分:拖拽位移的判定、回弹动效的呈现、刷新触发与结束的回调。位移到达某个阈值后触发刷新,没到达就取消并回弹,这是最经典的逻辑。
上拉加载的本质是**"滚动位置触底之后自动触发数据追加"**。用户翻页到内容底部时,继续向上滑动的意图已经很明确了:我还想看更多内容。但这里有个容易被忽略的点,上拉加载并不一定需要监听手势,因为滚动位置本身就是最可靠的信号。也就是说,滚动条的位置比手指的位移更能代表"内容快看完了"。这也就是为什么下拉刷新必须用手势、上拉加载用滚动监听就足够的原因。
这两个交互其实描述的是一个完整列表的两种"边界状态":
- 顶部边界:下拉刷新入口,数据需要整体替换
- 底部边界:上拉加载入口,数据需要增量追加
它们配合起来,本质上是一个列表页面对数据生命周期的管理。如果你只是把主键排好序、把分页参数传对,其实后端和前端各出一半力就能跑通。真正的复杂度在于手势冲突、滚动嵌套、加载锁定和数据竞态。这篇文章的后面部分,会花大篇幅拆这些问题。
2. 从零实现一次下拉刷新:手势判定与状态流转不能省
2.1 触控事件里最重要的三组数据
下拉刷新不需要框架,原生touch事件就足够了。关键在于你要从三个事件里提取出正确信息,并且维护一个状态机:
touchstart:记录起始点,锁定本次手势touchmove:计算当前位移,把它映射为下拉距离和箭头旋转角度touchend:根据最终位移决定触发刷新,还是回弹复位
这里最核心的数据是手指在垂直方向上的移动距离,我一般用currentTouch.clientY - startTouch.clientY来计算。之所以用clientY而不是pageY,是因为它反映的是视口内的坐标,不会受页面本身滚动的影响,计算手势位移更干净。
很多人踩过的一个坑是:touchmove的默认行为会跟随页面滚动,如果页面本身可以上下滚动,下拉手势就会同时触发页面滚动和头部的拖拽效果,看起来非常奇怪。所以需要在根节点上调用preventDefault(),把默认的滚动行为打断,让整个页面在这个手势期间只听我们的指令。
我贴一份兼容 H5 场景的核心片段,指定touch-action为none防止浏览器在触摸时干预滚动:
const INDICATOR = document.querySelector('.indicator'); let startY = 0; let currentY = 0; let pulling = false; let refreshing = false; const THRESHOLD = 70; container.addEventListener('touchstart', (e) => { if (refreshing) return; if (window.scrollY > 0) return; startY = e.touches[0].clientY; pulling = true; }); container.addEventListener('touchmove', (e) => { if (!pulling) return; currentY = e.touches[0].clientY; let diff = currentY - startY; if (diff < 0) { pulling = false; return; } e.preventDefault(); renderPullState(diff); }); container.addEventListener('touchend', () => { if (!pulling) return; pulling = false; const diff = currentY - startY; if (diff >= THRESHOLD) { triggerRefresh(); } else { resetPullState(); } });你注意到renderPullState没有给出实现,因为这段逻辑需要根据你的 UI 去补。但有几个值是必须有的:下拉位移、回弹阻尼、旋转角度。这三个值建议封装成一个方法,之后调 UI 会很顺手。
2.2 阻尼系数和回弹动画到底怎么算
阻尼系数damping是为了让下拉过程手指移动快时、指示器跟随得慢一点,这样手感更柔和,不会像直接 1:1 平移那么僵硬。常见的算法是位移 / (位移 / 某个基数 + 1),也可以用平方根做曲线,效果类似:
function renderPullState(diff) { const damped = diff / (diff / 100 + 1); indicator.style.height = Math.min(damped, 100) + 'px'; spinner.style.transform = `rotate(${Math.min(damped / 2, 180)}deg)`; }这里最重要的思路是:你拖拽的距离和 UI 移动的距离不应该相等。如果你在实际项目里用过原生下拉刷新手感,会明显感觉到手指在前半段拖得很快、指示器移动得很慢,后半段才逐渐跟上。这是用阻尼模拟出来的,不是真实位移。
回弹动画一般交给transition做。resetPullState里把指示器高度、旋转角度全部清零,并加一个transition属性让过程平滑。触发刷新时,指示器不要立刻消失,而是进入一个持续 loading 的状态:
function triggerRefresh() { refreshing = true; indicator.classList.add('refreshing'); // 通知外部开始请求数据 onRefresh().finally(() => { indicator.classList.remove('refreshing'); resetPullState(); refreshing = false; }); }如果你的场景是 React Native 或小程序,touch事件的名字会换成onTouchStart或bindtouchstart,但逻辑是通用的。手势判定的三段式状态机在任何端上都是同一套思想。
2.3 滚动判定要注意"已经滚到顶部"这个前提
下拉刷新只在页面处于顶部时合法,如果用户已经在列表中间往下拉,那应该还原成正常的滚动,而不是触发刷新。这就是我上面代码里if (window.scrollY > 0) return;的来源。
但这里有个嵌套滚动的问题。如果页面头部有一个横向 Tab 或者一个内嵌的滚动容器,外层页面本身不能滚动,那么你需要判断的就不是window.scrollY,而是内层容器是否滚动到了最顶部。这个判断可以在touchstart里做:
function isAtTop() { return scrollContainer.scrollTop <= 0; }如果容器支持 iOS 橡皮筋效果,scrollTop可能是负数,你还要做Math.abs(scrollTop) < 1这样的兼容。这个细节我第二次写时踩过坑,第一次写只判断了<= 0,结果在部分手机上列表下拉时会先出现一段诡异的橡皮筋再进入刷新逻辑。
状态: idle → pulling → release → refreshing → idle这张状态流转图虽然只有四个状态,但它回答了"为什么 touchstart 里要判断 refreshing"——如果上一次刷新还没结束,就绝不能允许开启第二次手势,否则会出现并发请求和 UI 抖动。
3. 上拉加载的滚动判定:位置计算与外层适配
3.1 scrollTop、clientHeight 与 scrollHeight 的经典公式
上拉加载比下拉刷新简单得多,因为它不依赖手势,全靠滚动位置就能判断。核心公式只有一条:
if (scrollTop + clientHeight >= scrollHeight - threshold) { loadMore(); }scrollTop:已经滚动的距离,也就是用户“看到顶部以上”的高度clientHeight:视口高度,就是当前屏幕里能看到的区域高度scrollHeight:整个内容的总高度,包括不可见的部分threshold:距离底部还有多少像素时提前触发预加载
这里最容易犯的错是:直接判断scrollTop + clientHeight === scrollHeight。因为页面渲染、图片加载、字体渲染都有延迟,这个值在绝大多数时候不会精确相等。你很可能看到用户明明滚到底了,却始终差那么十几个像素触发不了。所以一定要用>=和threshold做一个缓冲带。
至于 threshold 取多少,需要看你的实际布局。列表项高度 100px 左右时,我喜欢取100;如果是瀑布流或图片较多,我会取300~500,因为图片加载是异步的,等用户真正看到底部时,还没触发的加载会显得很突兀。
3.2 监听 scroll 事件的两个性能陷阱
滚动事件在移动端的触发频率比你预期的要高得多。如果你每次scroll都去做一次完整的位置计算和分页请求,能明显感觉到两个问题:
第一,多次请求并发。用户快速从顶部滑到底部时,滚动事件几乎每帧都在触发,同一瞬间可能会触发多次loadMore()。解决方法是建立一个锁:
let loading = false; async function loadMore() { if (loading) return; loading = true; try { const data = await fetchNextPage(); appendData(data); } finally { loading = false; } }这个loading锁不仅防住了并发,也保证了用户不会在数据没回来之前反复触底造成空洞加载。第二,渲染性能问题。如果你在scroll回调里频繁访问scrollTop、修改 DOM,即使不做请求,也会引起布局抖动。因为读取这些滚动属性会强制浏览器重新计算布局,如果紧接着又改了innerHTML,就会形成强制同步布局,代价非常高。
更好的做法是给scroll回调套一层requestAnimationFrame节流:
let ticking = false; scrollContainer.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { checkAndLoadMore(); ticking = false; }); });requestAnimationFrame本质上是把连续触发的事件合并到每帧只执行一次,比throttle(time)更适合滚动场景,因为它和浏览器的渲染节奏是天然对齐的。你不需要猜什么 16ms 还是 20ms,交给浏览器最合适。
3.3 外部滚动容器和页面级滚动的判断差异
如果你的页面结构是"整个页面滚动",那事件应该挂在window上,位置计算用document.documentElement的属性;如果页面只是一个局部区域滚动,那事件要挂在那个容器上,位置计算用容器的属性。两套代码不能混,我见过不少因为容器选错导致"死活不触发"的排查经历。
页面级滚动:
const scrollTop = document.documentElement.scrollTop; const clientHeight = document.documentElement.clientHeight; const scrollHeight = document.documentElement.scrollHeight;容器级滚动:
const scrollTop = container.scrollTop; const clientHeight = container.clientHeight; const scrollHeight = container.scrollHeight;顺带提一个很容易被忽略的场景:如果容器本身用了transform做列表位移动效,那么scrollTop可能会因为 GPU 合成层的改变而出现测量偏差。遇到这种情况,不建议在动画帧进行中做分页判定,可以等transitionend或动画结束后再计算。实测中这个坑在长列表卡片滑动场景比较常见。
4. 数据层接力:分页参数、加载状态与竞态处理
4.1 分页参数不是 page 那么简单
很多后端接口的分页参数就是page和size,这部分大多数人都会。但遇到游标分页(cursor-based pagination)、按时间倒序分页时,前端的处理方式就完全不同了。
游标分页的核心思想是:不传页码,而是传上次最后一条数据的 ID 或排序值。好处是:如果数据集合在翻页过程中新增了记录,传统页码分页会出现重复或漏项,而游标分页永远以“上次结束的位置”继续往后取,不会错位。
let cursor = null; let hasMore = true; async function fetchFeeds() { if (!hasMore) return; const params = cursor ? { cursor, size: 20 } : { cursor: '', size: 20 }; const res = await api.fetchFeeds(params); appendFeeds(res.list); cursor = res.nextCursor; hasMore = res.hasMore; }注意hasMore这个字段很关键。很多实现只靠“返回数组的长度等于 size”来判断还有没有更多,这其实不严谨——万一后端返回了 20 条,但恰好这批数据正好是最后 20 条呢?下次请求就会白打一次。正确做法是让后端显式返回hasMore,或者返回nextCursor为null表示到底了。
4.2 竞态:刷新和加载同时发生怎么办
下拉刷新会把整个列表重置为第一页,而上拉加载可能正在请求第二页。如果这两个请求并发,返回顺序又不固定,很容易出现“新数据被旧数据覆盖”的问题。
处理方案比较正统的做法是:给每次请求的轮次加一个自增序号。当触发下拉刷新时,refreshSeq++,上拉加载发起时先记录当时的refreshSeq,返回后检查如果序号不是最新,就丢弃这次的追加结果:
let refreshSeq = 0; async function onRefresh() { const seq = ++refreshSeq; const res = await api.fetchFeeds({ cursor: '', reset: true }); if (seq !== refreshSeq) return; list = res.list; cursor = res.nextCursor; hasMore = res.hasMore; } async function loadMore() { if (!hasMore || loading) return; const seq = refreshSeq; loading = true; try { const res = await api.fetchFeeds({ cursor }); if (seq !== refreshSeq) return; list = list.concat(res.list); cursor = res.nextCursor; hasMore = res.hasMore; } finally { loading = false; } }竞态问题排查起来非常隐蔽,因为它们不一定会复现。我的建议是写代码时从一开始就按这个模式去写,不要等问题出现再补。被用户反馈“我刷了一下,页面反而回滚到旧数据”的时候,再排查就晚了。
4.3 状态机不止服务于手势,更服务于渲染
下拉刷新的状态机上文已经有图,上拉加载也有自己的状态机:
idle → loading → append success → idle loading → append fail → idle(带错误提示)你会发现这两个状态机并不独立,它们共享同一个列表的数据形态。所以我在实际项目里通常把三种状态拆成三个可观察值,而不是用一个字符串枚举:
refreshing:布尔值,控制顶部指示器loadingMore:布尔值,控制底部加载中提示hasMore:布尔值,控制底部“没有更多了”提示
这三个值一起驱动了页面上几乎所有的反馈逻辑。推到极致的做法是用一个useReducer或reducer统一管理,这样竞态和状态跳转都变得极其可控:
function reducer(state, action) { switch (action.type) { case 'REFRESH_START': return { ...state, refreshing: true, error: null }; case 'REFRESH_DONE': return { ...state, refreshing: false, list: action.list }; case 'LOAD_MORE_START': return { ...state, loadingMore: true }; case 'LOAD_MORE_SUCCESS': return { ...state, loadingMore: false, list: state.list.concat(action.list), hasMore: action.hasMore, }; default: return state; } }列表页面的所有“动”——不管是手势、滚动、还是网络请求的响应——本质上都是在疯狂地喂给这个 reducer 各种 action。把这个数据流理顺,比把手势玩出花重要得多。
5. 常见方案对比与落地优化:从轮子到框架
5.1 原生手写和现成组件的选择标准
市面上的下拉刷新、上拉加载组件非常多,PC 端和移动端各有各的生态。选择标准其实不是"哪个功能多",而是你的滚动容器结构是不是标准的。
如果你的页面结构就是一个简单的 FlatList / ListView,或者一个没有复杂嵌套的滚动区域,那我强烈建议直接用组件库,真没必要自己造轮子。这些组件库已经把惯性滚动、回弹、多指手势这些边缘情况都处理好了,维护成本很低。
但如果你的页面有这些特征,就要考虑手写或高度定制:
- 列表头部有吸顶 Tab,Tab 内部又嵌套横向滚动
- 下拉刷新的指示器需要自定义成品牌插画或复杂动画
- 刷新和加载需要与业务请求状态深度绑定
- 需要同时兼容多个小程序平台和 H5
这种场景下,组件库的抽象反而成了阻力。这时候你需要的不是组件的配置项,而是对手势和滚动位置的完全掌控。本文的代码虽然基础,但恰好是你能掌控一切的起点。
5.2 三种主流实现路线对比
我把目前常见的主流接触方式做了一个对比表格。不要迷信任何一种方案,选最适合你项目结构的:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Web 原生 touch 事件 | 监听手势位移,手动映射 UI 状态 | 可控性最强、可定制回弹曲线 | 需要自己处理多端兼容 |
| 组件库内置 | 如 vant、taro-ui 等封装好的手势组件 | 开箱即用、兼容性好 | 自定义能力受限、嵌套滚动时难调整 |
| 基于滚动的虚拟触发 | 用 scroll 位置换算比例,配合 CSS 动效模拟 | 流畅度高、无需 touch 事件 | 手势判定不够精准、iOS 橡皮筋下易误触 |
组件库内置通常是最省心的,但如果你需要做“下拉刷新头部区域插入一个自定义品牌动画”,或者需要在上拉加载时联动一个统计埋点,那原生方案的灵活性是无价的。
5.3 预加载与数据拼接的语义
上拉加载和“预加载”有时候会被混在一起。我在接到一个列表需求时,总会先和产品确认底部提示的形态:是“加载中”的 spinner,还是“上拉加载更多”的文字提示,还是两者都有。
这些 UI 细节看似无关,实则决定了你的触发时机。如果底部是一个 spinner,那意味着你必须在用户接近底部之前就触发请求;如果底部是一段文字提示,那你可以在用户真正触底甚至再往下拉一点时触发。前者是预加载,后者是边界加载。
预加载的核心指标是“影子屏数”。比如你的视口高度是 800px,列表项平均高度 200px,那屏幕里能显示 4 条数据。预加载触发点设为距离底部还有 2 个视口高度,也就是提前 8 条数据开始请求,这样用户滑到这里时,数据已经到位,体验非常丝滑。
但这会增加无效请求的概率——用户可能根本没往下滑那么深。所以预加载的阈值要结合产品数据去看,不要盲目设得很激进。我给个经验值:内容类产品可以激进一点,工具类产品保守一点。
6. 实际项目里的坑与体验优化细节
6.1 下拉刷新在 iOS 橡皮筋下的诡异表现
iOS 自带的橡皮筋效果会让整个页面在滚动到顶部后继续下拉时出现一段弹性拉伸,这个拉伸效果和我们的下拉指示器叠加在一起,最初看起来像两个刷新在打架。
解决思路有两条线。一条是关闭浏览器的原生橡皮筋,用自定义手势代替,通过在 CSS 里设置overscroll-behavior来限制滚动链。另一条是保留橡皮筋,但它不负责业务刷新,只负责视觉反馈,业务刷新完全由我们自己的touch事件判断触发。
我个人的偏好是保留原生橡皮筋,但把刷新指示器和橡皮筋的位移解耦。不要让指示器跟着页面位移走,而是根据touchmove计算出来的"可下拉距离"来驱动它。这样视觉上指示器看起来和橡皮筋同步,但实际上不会受到橡皮筋返回时的加速度干扰,触发逻辑也更稳定。
6.2 防抖、节流到底该用在哪个环节
很多人一提到滚动性能就说防抖节流,但用错地方反而会造成功能异常。我整理一下我的使用思路:
touchmove里的 UI 更新:不需要节流,但需要rAF合并,否则动效会跳scroll里的触底判定:需要rAF合并,但不建议debounce,否则会延迟加载- 请求触发:必须用
loading锁,不能用debounce,因为用户可能连续触发两次合法操作 - 刷新结束后的 UI 复位:不要节流,复位必须立即执行
这里最容易被搞混的是scroll事件里的debounce。有些教程让你用debounce避免重复请求,但debounce的副作用是:用户停住滚动后,要等几百毫秒才会触发加载,体验上会有明显的停顿感。正确的做法是用loading锁,而不是延迟执行。
6.3 骨架屏和数据的优雅降级
列表在刷新和加载的间隙里,最容易出现的差评点不是慢,而是"闪了一下"。加骨架屏是现在比较标准的做法了。骨架屏和加载指示器的取舍,取决于数据是不是首次加载:
- 首次进入页面:用骨架屏撑满整个首屏,避免白屏感
- 下拉刷新:保留原有列表内容,只显示顶部指示器
- 上拉加载:不遮挡原有列表,底部追加 loading
这个思路在体验上最大的好处是:用户始终能看到旧数据,新数据悄悄进来,视觉连续性不会被打破。如果请求失败,给用户一个明确的错误提示,而不是让列表恢复成"未开始"的样子。
6.4 悬浮按钮、回到顶部与列表刷新机制的联动
提一个很多人做了下拉刷新之后才遇到的跨界问题:长列表通常要配一个"回到顶部"悬浮按钮,这个按钮的显示条件通常是在列表滚动超过某个阈值后出现。在下拉刷新和回到顶部联动时,有一个细节值得注意——回到顶部的滚动过程应该和刷新手势互斥,不然用户刚点完回去,手指还没离开屏幕,列表又开始刷新了。
我的做法是在点击"回到顶部"时设置一个短暂禁用手势的标记,等滚动动画完成后再恢复。虽然看起来只是很小的细节,但在真机体验上,能明显感觉到页面“听话”了很多。
7. 参数与状态整理:一次说清所有关键配置
7.1 一页文档式的可配置项
如果你写的是通用组件,把参数抽出来确实省心。我把下拉刷新和上拉加载相关的可配置项整理成下表,你直接照着设计你的参数即可:
| 参数名 | 类型 | 默认值 | 作用 |
|---|---|---|---|
| threshold | number | 70 | 下拉到该位移时触发刷新 |
| damping | number | 0.5 | 阻尼系数,调控指示器跟随速度 |
| refreshDuration | number | 300 | 回弹动画时长 |
| loadMoreThreshold | number | 100 | 距离底部多少像素时触发加载 |
| hasMore | boolean | true | 是否还有下一页 |
| error | boolean | false | 当前加载是否出错 |
这里的默认值我都是用真机调过的,适合大多数列表页。如果你的页面是图片瀑布流,threshold和loadMoreThreshold建议都加大,因为图片加载需要提前量。
7.2 关键计算的参考值
有朋友会问阻尼算法里的100是什么。其实这个值决定了"手感曲线的陡峭程度"。数字越小,指示器越早被拉满;数字越大,手感越绵软。我实测下来,100是个中庸偏舒适的值,宁可让它反应慢一点,也不要让用户感觉手指还没怎么动、指示器已经冲出去了。
位移公式:
拉动力度 = diff / (diff / 100 + 1)当diff = 50时,拉动力度约 33;当diff = 100时,拉动力度约 50;当diff = 300时,拉动力度约 75。这组数据说明,手指移动 300 像素时,指示器只移动 75 像素,下降趋势会越来越平缓,这正好符合"手指越往下越费力"的物理直觉。
7.3 列表数据合并的两种姿势:替换与追加
下拉刷新对应替换,上拉加载对应追加,这个语义千万别搞反。替换会清空旧列表,所以你需要保证请求成功后再清空,否则刷新失败时列表就空了。追加则要在成功后再concat,中途失败不能破坏已有的列表。
我这里以一个伪代码,把刷新和加载共用的数据更新逻辑抽出来,避免两套业务代码各自写各自的:
function mergeList(oldList, newList, mode) { if (mode === 'replace') return newList; return oldList.concat(newList); }合并姿势虽然简单,但它是列表页所有竞态问题的“兜底防线”。只要每次操作都基于最新的oldList,就不会出现覆盖。
8. 一处容易翻车的细节:滚动容器的选择与定位
8.1 到底该用 window 还是 div
这个问题我最早做 H5 时吃过亏。当时页面结构是body整页滚动,却把监听挂在了某个外层div上,结果scrollTop永远为 0,加载一直不触发。排查了半天才想起来,能滚动的元素是body,不是那个div。
判断方法很简单:打开浏览器控制台,选中页面,看滚动条的位置落在哪个元素上。如果滚动条占据的是整个视口,那么滚动发生在document.documentElement或body上;如果滚动条只出现在某个局部区域内,那才要用div。
实际项目中常见的坑是:一开始整页滚动,后来为了布局把页面内容包进了一个设置了overflow-y: auto的容器里,滚动层级发生变化,但监听没有跟着改。所以凡是重构过页面结构的项目,我都建议重新验证一下滚动位置计算有没有失效。
8.2 sticky 定位和滚动监听的关系
页面顶部如果有position: sticky的吸顶栏,滚动监听依然有效,但吸顶栏本身会改变页面布局高度。如果你用scrollHeight判断触底,吸顶栏的固定定位会参与高度计算,导致提前或延后触发加载。
我记得有个案例是:吸顶栏高度 50px,触底判断用>= scrollHeight - threshold,结果每次都在底部还剩 50px 时提前加载了。原因是吸顶栏脱离文档流后,可滚动高度比想象中少。这种差异不大,但如果你的吸顶栏是在滚动过程中动态出现的,就可能会导致判断边界一直漂移。
一个推荐的做法是:在计算触底时,把吸顶栏高度排除在外,用一个变量统一维护:
function getScrollBottomDistance() { const stickyHeight = 50; return scrollHeight - scrollTop - clientHeight - stickyHeight; }8.3 用户快速滚动的场景怎么覆盖
快速滚动到列表三分之一的位置然后突然松手,滚动会因惯性继续前进。很多新手以为scroll事件会在惯性期间持续触发,确实会,但触发频率会因为节流而降低。如果你的加载锁和rAF合并时机不对,可能用户已经滑过底部,事件还没来得及触发,等看到底部时列表还是空的。
针对这种情况,我倾向于在底部分页组件渲染完成后,主动检查一次当前位置是否还需要加载:
requestAnimationFrame(() => { checkAndLoadMore(); });这个兜底检查非常便宜,却能覆盖绝大多数快速滚动、惯性滑动的场景。
9. 从纯前端到跨端:多端复用的经验总结
9.1 小程序和 H5 的差异其实在事件模型
小程序没有touchend之后自动复位回弹的能力,需要你自己声明animation或 CSS 过渡;H5 的touch事件可以比较放心地preventDefault,但小程序里touchmove的阻止默认行为有更严格的要求——不能在catchtouchmove中直接取消,需要setData配合。
我自己做过一个小程序版本,核心思路是:把下拉位移的数值存在一个data字段里,通过setData驱动指示器的高度和旋转角度。这个方案能跑,但要注意setData的频繁调用会带来性能压力,尽量减少每次更新的数据量,只更新变化的部分:
onTouchMove(e) { const diff = e.touches[0].clientY - this.startY; if (diff < 0) return; this.setData({ pullDistance: Math.min(this.damp(diff), 100) }); }注意这里没有把旋转角度一起塞进去,因为pullDistance发生变化后,CSS 可以用transform: rotate(${pullDistance * 2}deg)直接计算,省一次setData。
9.2 如果只是换个端,能抽离的层是什么
跨端项目的第一步不是选框架,而是把“逻辑层”和“渲染层”拆开。逻辑层包括:手势状态机、触底判定、数据合并、竞态处理。渲染层包括:指示器的位置、旋转动画、底部 loading 的样式。
如果你能把这两层拆开,那么迁移到 React Native、Flutter Web、小程序的时候,只需要重写渲染层。我在一个实际项目里就是这么做的,跨了两个端,逻辑代码复用率差不多 80%。
这里的技巧是:把renderPullState和checkAndLoadMore做成纯函数,不直接操作 DOM,而是返回当前状态对象,由渲染层去消费。这种方式兼容性最广,也很容易写单元测试。
9.3 工具链的选型不决定手势实现
最后提醒一句,不要因为项目用了某个框架,就觉得必须用这个框架的手势库。手势的本质是事件序列的监听与状态机的流转,在 React 里可以用原生onTouchStart,在小程序里可以用bindtouchstart,在 Flutter 里可以用GestureDetector。原理都一样,只是 API 壳子不同。
理解了这一层,你就不会被某个框架绑架。以后不管技术栈怎么换,你都能快速写出下拉刷新和上拉加载。这篇文章里给的所有公式和判定条件,都是可以迁移的,你在任何平台上遇到问题时,回来看一眼原理就会很清晰。
我自己在做过几版列表页之后最大的体会是:这类交互看起来简单,实际上细节非常多,而且这些细节只有上了真机才能暴露。你照着文档抄一遍组件库能跑,但遇到卡顿、误触、竞态时,只有真正理解原理的人才能快速定位问题。希望这篇文章能帮你少走点弯路。