【React.js点滴知识|React + iscroll5 实现完美 下拉刷新,上拉加载

经过几天的反复折腾,总算做出一个体验还不错的列表页了,主要支持了下拉刷新,上拉加载两个功能。
http://www.aichengxu.com/other/11094788.htm

【React.js点滴知识|React + iscroll5 实现完美 下拉刷新,上拉加载
文章图片

一开始直接采用了react-iscroll插件,它是基于iscroll插件开发的组件。但是开发过程中,发现它内部封装的行为非常固化,限制了我对iscroll的控制能力,因此我转而直接基于iscroll插件实现。
网上也有一些基于浏览器原生滚动条实现的方案,找不到特别好的博客说明,而iscroll是基于Js模拟的滚动条(滚动条也是一个div哦),其兼容性更好,所以还是选择iscroll吧。
先体验效果 在讲解实现之前,可以先体验一下app整体效果。如果使用桌面浏览器访问,必须进入开发者模式,启动手机仿真,并使用鼠标左键触发滑动,否则无法达到真机效果( 点我进入 )!建议还是扫描二维码直接在手机浏览器中体验,二维码如下:
【React.js点滴知识|React + iscroll5 实现完美 下拉刷新,上拉加载
文章图片

下载demo源码 点击这里 下载源码 ,之后一起看一下实现中需要注意的事项和思路。
实现关键点 本篇实现了MsgListPage这个组件,支持消息列表的滚动查看,下拉刷新,上拉加载功能。
这里使用了开源的iscroll5实现滚动功能,它对iscroll4重构并修复若干bug,是目前主流版本。网上鲜有iscroll5实现下拉刷新,上拉加载功能的好例子,提供的仅是一些思路,绝大多数实现都是修改iscroll5源码,并不完美。我这次的实现不需要修改iscroll5源码,其实通过巧妙的设计是可以完美的实现这些特效的。
代码如下:

import React from "react"; import {Link} from "react-router"; import $ from "jquery"; import style from "./MsgListPage.css"; import iScroll from "iscroll/build/iscroll-probe"; // 只有这个库支持onScroll,从而支持bounce阶段的事件捕捉export default class MsgListPage extends React.Component { constructor(props, context) { super(props, context); this.state = { items: [], pullDownStatus: 3, pullUpStatus: 0, }; this.page = 1; this.itemsChanged = false; this.pullDownTips = { // 下拉状态 0: '下拉发起刷新', 1: '继续下拉刷新', 2: '松手即可刷新', 3: '正在刷新', 4: '刷新成功', }; this.pullUpTips = { // 上拉状态 0: '上拉发起加载', 1: '松手即可加载', 2: '正在加载', 3: '加载成功', }; this.isTouching = false; this.onItemClicked = this.onItemClicked.bind(this); this.onScroll = this.onScroll.bind(this); this.onScrollEnd = this.onScrollEnd.bind(this); this.onTouchStart = this.onTouchStart.bind(this); this.onTouchEnd = this.onTouchEnd.bind(this); }componentDidMount() { const options = { // 默认iscroll会拦截元素的默认事件处理函数,我们需要响应onClick,因此要配置 preventDefault: false, // 禁止缩放 zoom: false, // 支持鼠标事件,因为我开发是PC鼠标模拟的 mouseWheel: true, // 滚动事件的探测灵敏度,1-3,越高越灵敏,兼容性越好,性能越差 probeType: 3, // 拖拽超过上下界后出现弹射动画效果,用于实现下拉/上拉刷新 bounce: true, // 展示滚动条 scrollbars: true, }; this.iScrollInstance = new iScroll(`#${style.ListOutsite}`, options); this.iScrollInstance.on('scroll', this.onScroll); this.iScrollInstance.on('scrollEnd', this.onScrollEnd); this.fetchItems(true); }fetchItems(isRefresh) { if (isRefresh) { this.page = 1; } $.ajax({ url: '/msg-list', data: {page: this.page}, type: 'GET', dataType: 'json', success: (response) => { if (isRefresh) {// 刷新操作 if (this.state.pullDownStatus == 3) { this.setState({ pullDownStatus: 4, items: response.data.items }); this.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 500); } } else {// 加载操作 if (this.state.pullUpStatus == 2) { this.setState({ pullUpStatus: 0, items: this.state.items.concat(response.data.items) }); } } ++this.page; console.log(`fetchItems=effected isRefresh=${isRefresh}`); } }); }/** * 点击跳转详情页 */ onItemClicked(ev) { // 获取对应的DOM节点, 转换成jquery对象 let item = $(ev.target); // 操作router实现页面切换 this.context.router.push(item.attr('to')); this.context.router.goForward(); }onTouchStart(ev) { this.isTouching = true; }onTouchEnd(ev) { this.isTouching = false; }onPullDown() { // 手势 if (this.isTouching) { if (this.iScrollInstance.y > 5) { this.state.pullDownStatus != 2 && this.setState({pullDownStatus: 2}); } else { this.state.pullDownStatus != 1 && this.setState({pullDownStatus: 1}); } } }onPullUp() { // 手势 if (this.isTouching) { if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY - 5) { this.state.pullUpStatus != 1 && this.setState({pullUpStatus: 1}); } else { this.state.pullUpStatus != 0 && this.setState({pullUpStatus: 0}); } } }onScroll() { let pullDown = $(this.refs.PullDown); // 上拉区域 if (this.iScrollInstance.y > -1 * pullDown.height()) { this.onPullDown(); } else { this.state.pullDownStatus != 0 && this.setState({pullDownStatus: 0}); }// 下拉区域 if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY + 5) { this.onPullUp(); } }onScrollEnd() { console.log("onScrollEnd" + this.state.pullDownStatus); let pullDown = $(this.refs.PullDown); // 滑动结束后,停在刷新区域 if (this.iScrollInstance.y > -1 * pullDown.height()) { if (this.state.pullDownStatus <= 1) {// 没有发起刷新,那么弹回去 this.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 200); } else if (this.state.pullDownStatus == 2) { // 发起了刷新,那么更新状态 this.setState({pullDownStatus: 3}); this.fetchItems(true); } }// 滑动结束后,停在加载区域 if (this.iScrollInstance.y <= this.iScrollInstance.maxScrollY) { if (this.state.pullUpStatus == 1) { // 发起了加载,那么更新状态 this.setState({pullUpStatus: 2}); this.fetchItems(false); } } }shouldComponentUpdate(nextProps, nextState) { // 列表发生了变化, 那么应该在componentDidUpdate时调用iscroll进行refresh this.itemsChanged = nextState.items !== this.state.items; return true; }componentDidUpdate() { // 仅当列表发生了变更,才调用iscroll的refresh重新计算滚动条信息 if (this.itemsChanged) { this.iScrollInstance.refresh(); } return true; }render() { let lis = []; this.state.items.forEach((item, index) => { lis.push(
  • {item.title}{index}
  • ); })// 外层容器要固定高度,才能使用滚动条 return (
      {this.pullDownTips[this.state.pullDownStatus]}
      {lis}【【React.js点滴知识|React + iscroll5 实现完美 下拉刷新,上拉加载】{this.pullUpTips[this.state.pullUpStatus]}
    ); } }MsgListPage.contextTypes = { router: () => { React.PropTypes.object.isRequired } };



    思路 在react的componentDidMount回调中,DOM已经渲染完成。此时进行iscroll插件的初始化,监听其scroll和scrollEnd两个插件回调用于滚动监听,同时,调用fetchItems发起首次数据加载。
    在react的shouldComponentUpdate回调中,我判断并记录本次render是否对ul的元素进行了增删,从而在componentDidUpdate回调中决策是否需要为iscroll进行refresh刷新,因为如果iscroll容器内的元素数量发生变动,iscroll是需要重新计算整个高度等信息的。
    为了获知用户是否在触屏,我给div注册了onTouchStart和onTouchEnd两个事件函数,这主要是为了区分滚动条是因为触屏拖拽移动,还是因为惯性移动。
    在iscroll的onScroll回调中,专门处理用户的触屏行为。我判断y坐标确认当前滚动条所处的范围是顶部的上拉区域,还是底部的下拉区域。当处于上拉区域中的时候,根据拖拽的偏移量展现不同的文案,下拉区域也是一样。
    在iscroll的onScrollEnd回调中,专门处理滚动结束后的状态判断,主要是判断用户是否此前的触屏行为是否触发了下载需求,如果产生了下载需求那么发起网络调用fetchItems。
    需要注意, 下拉刷新条 也位于iscroll容器内,在它能被用户可见但又没有抵达刷新触发偏移量之前,如果用户没有触屏那么应该立即向上滚动把 下拉提示条 滚到视野范围外。 上拉加载条 也位于iscroll容器内,但是它总是可以被用户看见,所以对应的处理逻辑相对简单。
    不要在onScroll内调用scrollTo等移动滚动条的函数,因为onScroll内调用ScrollTo会导致继续回调onScroll,如此往复像在打乒乓球,是不合理的。我的实现中,onScroll仅仅检测用户的触屏行为(不处理惯性滑动),而onScrollEnd中才进行对应的逻辑处理或者发起scrollTo,而scrollTo触发的是惯性滑动(isTouching=false),因而又不会造成onScroll的困扰。
    点击某一行会跳转到MsgDetailPage组件,这是通过注册onClick事件回调,并通过this.context.router操作react-router的路由实现的切换。
    如果iscroll内元素太少没有产生滚动条,那么会影响上述的效果实现逻辑。因此,我给