背景
最近有一个需求:大致是要展示一个列表信息,但每次接口只返回 20 条数据,当用户滑动到到页面底部并继续上拉页面时再继续调接口获取更多的数据(相当于分页)。这里就需要使用到一个上拉加载更多的功能,实现的效果可以看这里。考虑到项目时间比较紧而且可靠性问题,就没有自己去造轮子了,而是在 Github 上找现成的工具 来用。这个库实现的功能主要有两个,一是上拉加载更多,另一个是下拉刷新。它提供的功能也算齐全,不过还是有几个小问题:
它的文案是写死在 Less 中的,要修改文案的话只能去修改 Less,并且也只能写死在里面。如果要做国际化文案的话会比较麻烦,可能需要去手动操作下 DOM 修改内容了。
文案过渡效果不是很好。组件初始化时默认会渲染出上拉加载更多的提示语,导致从后端接口获取数据渲染的时候,会有一个提示语被下压到底部的突变过程,点用户体验不是很友好。
使用的是 JavaScript,在校验这块比较弱(不过这个也不算问题啦)。
原理解析
事后我去看了这个组件的实现源码以及其他一些参考资料,大致理解了下拉刷新和上拉加载更多的实现原理,并用 TypeScript
重写了这个组件(其实是因为组里刚好轮到我做技术分享,而我也没想到其他分享主题,就刚好拿这个来研究研究并当做分享主题了 Orz)。重写后的源代码可以看我的 Github,具体支持的 Props 可以看 README。主要做的改动是:
各个阶段的文案和 Icon 都作为 props 传递到组件内部,方便业务方自定义 Icon 和做国际化文案。
改善文案过渡的效果,避免初始化页面时文案突变。
上拉加载更多
基本原理:监听 scroll
事件,判断页面是否达到底部,是的话则调用加载函数获取后面的数据,并将新拿到的数据拼接到现有的列表数组后面(一个列表信息通常都是使用一个数组来装的)。
这里需要解决的问题是:
1.判断页面是否已经到达底部
判断页面是否到达底部有一个专门的公式:element.scrollHeight <= element.scrollTop + element.clientHeight
,如果结果为 true
的话说明已经滚动到页面底部了。其中 scrollHeight
表示元素的全部高度,包含了因超出而隐藏部分的高度。取值上等于 height
+ padding
+ 被隐藏的内容高度,元素没有隐藏内容时等同于 clientHeight
。scrollTop
表示元素已经滚动的距离。clientHeight
表示元素可见区域的高度,取值上等于 height
+ padding
。
有时候我们不会等到页面滚动到底部才去加载更多数据,这样会给用户带来等待加载更多数据的时间。所以我们会使用一个变量,比如说是 distance
来表示距离底部还有多远时就开始加载更多数据,此时判断页面是否达到底部的公式就变成了 element.scrollHeight - distance <= element.scrollTop + element.clientHeight
。
2.在加载完成后如何改变加载状态
当滚动到页面底部时,组件内部调用加载函数 handleMore
,并将修改状态的代码写成函数作为 handleMore
的参数。父组件在加载完成数据后调用该函数,就可以在加载完成后修改一些状态变量,从而改变页面的文案显示。
// 子组件
this.props.handleMore(() => {
this.setState({
footerStatus: 'finish'
})
});
// 父组件
async handleMore(resolve) {
await this.fetchData();
resolve();
}
下拉刷新
基本原理:监听 touchStart
、touchMove
和 touchEnd
事件,判断手势是下拉并且到达了页面顶部,满足这两个条件的话下拉后拖动内容向下移动,并在释放后调用刷新函数。
为了下文使用方便,先标记几个变量:
startScrollTop
表示触发touchStart
事件时页面已经滚动的距离。startClientY
表示触发touchStart
事件时触发点距离视口顶部的距离.curScrollTop
表示触发touchMove
事件时页面已经滚动的距离。curClientY
表示触发touchMove
事件时触发点距离视口顶部的距离。
这里需要解决的问题是:
1.判断手势是下拉页面 ⏬
如果 curClientY - startClientY
大于 0 说明手势是下拉 ⏬,小于 0 则是 ⏫。
2.判断页面此时是否位于顶部
如果 curScrollTop ≤ 0
,则说明此时没有页面滚动,是处于顶部的。
3.拖动列表下拉移动的距离
当满足上述两个条件后,下拉页面就可以拖拽列表向下移动,此时需要计算列表移动的距离,需要移动的距离 = 手指在屏幕上移动的距离 - touchStart
时页面滚动的距离。用上面的变量来写成公式就是 distance = (curClientY - startClientY) - startScrollTop
。
详情可见下图,左图表示 touchStart
时的状态,右图表示 touchEnd
时的状态,圆圈表示当时手指所在的屏幕位置。touchStart
时有个列表,它的上面既滚动了一部分,下边也有因超出而隐藏的部分。随着手指慢慢向下移动,到了右图 touchEnd
的状态。此时随着手指移动,列表页也会向下移,其中 distance
就表示列表向下移动的距离。
在这里父组件还可以传递一个 distancePullDownRefresh
参数过来,表示列表向下移动了多大距离后,释放时就调用刷新函数。所以需要在 touchMove
中计算出 distance
后判断两者的大小关系,如果 distance
大于 distancePullDownRefresh
的话,则将状态变量标记为释放刷新。在 touchEnd
中再判断该状态变量的值,决定是否要调用刷新函数。
FAQ
- 为什么下拉刷新和上拉加载更多是监听不同的事件?
因为在下拉刷新中,列表在到达顶部时需要向下移动。如果是监听 scroll
的话,因为已经到达顶部了,所以无法再向上滚动,也就导致计算不了列表需要移动的距离。而监听 touch
事件,可以通过计算手指在屏幕上移动的距离,从而得出列表向下移动的距离,
- 如何解决原来的组件在页面初始化时文案突变的问题?
初始文案默认为空字符串,并且把数据初始化函数 initData
作为 prop
传递给组件。组件内部在 componentDidMount
时调用 initData
,并将控制文案的变量状态修改写成函数作为参数传递给 initData
。这样业务使用方在获取完成数据后调用该函数,就可以把改变状态变量,将其修改回其他的提示文案了。
这样做的弊端是,数据初始化函数 initData
需要放到我们的组件内部去调用。如果不这样做的话,还有另一种方法是,依旧把初始文案默认为空字符串,但在 scroll
监听中再判断页面滚动是否快到底部了,是的话就将默认的空字符串文案修改为我们的提示语文案。