[React-章节9 实践篇] 做一个留言板项目 之 实现Loading特效

greenleopard 发布于2年前
0 条问题
 

接上篇 《 [React-章节8 实践篇] 做一个留言板项目 之 留言列表页 》

一般h5应用在页面加载完成之前都会有一个loading加载的动画效果,一旦完成加载一次性展现完整的内容给用户,这样更贴合native应用的体验,我相信没有用户喜欢看着一个残缺的页面等待网络数据下载完成。

我也要实现这样一个效果,原理是在每个页面最初显示一个转菊花的画面,在背后发起网络请求,等待必要的数据获取完成后停止转菊花的画面,转而渲染页面本身的内容。

实现为通用组件

我将转菊花实现成了一个组件叫做LoadingLayer,它在一个div的中央加载了一个svg转菊花图片,并支持上层通过props传递内联css样式进行控制。本次并没有实现加载失败之后点击重新刷新的效果,这个我计划在后续redux学习中去实现。

先看一下LoadingLayer的实现:

importReactfrom "react";
import $ from "jquery";
importstylefrom "./LoadingLayer.css";
importloadingImgfrom "../../common/img/loading.svg";
 
exportdefault class LoadingLayerextends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = {
 
        };
    }
 
    render() {
        letouterStyle = this.props.outerStyle ? this.props.outerStyle : {};
        letinnerStyle = this.props.innerStyle ? this.props.innerStyle : {};
 
        return (
            <divid={style.outer} style={outerStyle}>
                <divid={style.inner} style={innerStyle}>
                    <imgsrc={loadingImg}/>
                </div>
            </div>
        );
    }
}
 
LoadingLayer.contextTypes = {
    router: () => { React.PropTypes.object.isRequired }
};

我在common/img目录里准备了一个loading.svg菊花动画,并在这里加载。最重要的是上层需要通过透传props.outerStyle来控制整个加载页面的高度,而菊花图片已经通过css控制总是显示在中央了:

#outer{
    display: table;
    width: 100%;
}
 
#inner{
    text-align: center;
    vertical-align: middle;
    display: table-cell;
}

另外,实现过程中发现svg图片加载时报错,原来是我的webpack.config.js中url-loader少配置了svg后缀文件的识别,加上即可:

      {
          // 小于8KB的图片使用base64内联
          test: /\.(png|jpg|gif|svg)$/,
          loader: 'url-loader?limit=8192&name=images/[name]_[hash].[ext]' // 图片提取到images目录
      }

改造现有页面

改造的思路需要把握2方面,一个是首屏是loading组件,同时发起网络请求获取数据,在此期间仍旧展示loading效果。其次,加载数据完成后通过state变换重新渲染组件原本内容。

MsgListPage列表页

改造后的MsgListPage组件如下:

importReactfrom "react";
import {Link} from "react-router";
import $ from "jquery";
importstylefrom "./MsgListPage.css";
importiScrollfrom "iscroll/build/iscroll-probe"; // 只有这个库支持onScroll,从而支持bounce阶段的事件捕捉
importLoadingLayerfrom "../LoadingLayer/LoadingLayer";
importloadingImgfrom "../../common/img/loading.svg";
 
exportdefault class MsgListPageextends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = {
            items: [],          // 文章列表
            pullDownStatus: 3,  // 下拉状态
            pullUpStatus: 0,    // 上拉状态
            isLoading: true,    // 是否处于首屏加载中
        };
 
        this.page = 1;  // 当前翻页
        this.itemsChanged = false;  // 本次渲染是否发生了文章列表变化,决定iscroll的refresh调用
 
        // 下拉状态文案
        this.pullDownTips = {
            0: '下拉发起刷新',
            1: '继续下拉刷新',
            2: '松手即可刷新',
            3: '正在刷新',
            4: '刷新成功',
        };
        // 上拉状态文案
        this.pullUpTips = {
            0: '上拉发起加载',
            1: '松手即可加载',
            2: '正在加载',
            3: '加载成功',
        };
 
        // 是否在触屏中
        this.isTouching = false;
 
        // 点击文章跳转处理
        this.onItemClicked = this.onItemClicked.bind(this);
 
        // iscroll的事件函数
        this.onScroll = this.onScroll.bind(this);  // 滚动中回调
        this.onScrollEnd = this.onScrollEnd.bind(this); // 滚动结束回调
 
        // 触屏事件
        this.onTouchStart = this.onTouchStart.bind(this);
        this.onTouchEnd = this.onTouchEnd.bind(this);
    }
 
    /**
     * 加载完成后初始化一次iscroll
     */
    ensureIScrollInstalled() {
        if (this.iScrollInstance) {
            return;
        }
        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.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 500);
    }
 
    /**
     * react组件第一次加载调用
     */
    componentDidMount() {
        // 首屏是loading效果,仅做数据拉取(setTimeout模拟延迟)
        setTimeout(() => {
            this.fetchItems(true);
        }, 500);
    }
 
    /**
     * 网络获取文章列表
     * @param isRefresh
     */
    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
                        });
                        if (!this.state.isLoading) {
                            this.iScrollInstance.scrollTo(0, -1 * $(this.refs.PullDown).height(), 500);
                        } else {
                            this.setState({isLoading: false}); // 首屏loading页面结束,触发重画
                        }
                    }
                } else {    // 加载操作
                    if (this.state.pullUpStatus == 2) {
                        this.setState({
                            pullUpStatus: 0,
                            items: this.state.items.concat(response.data.items)
                        });
                    }
                }
                ++this.page;
                console.log(`fetchItems=effectedisRefresh=${isRefresh}`);
            }
        });
    }
 
    /**
     * 点击跳转详情页
     */
    onItemClicked(ev) {
        // 获取对应的DOM节点, 转换成jquery对象
        letitem = $(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() {
        letpullDown = $(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);
 
        letpullDown = $(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
        if (!this.state.isLoading) {
            this.ensureIScrollInstalled();
            // 仅当列表发生了变更,才调用iscroll的refresh重新计算滚动条信息
            if (this.itemsChanged) {
                this.iScrollInstance.refresh();
            }
        }
        return true;
    }
 
    renderLoading() {
        letouterStyle = {
            height: window.innerHeight,
        };
        return (
            <div>
                <LoadingLayerouterStyle={outerStyle}/>
            </div>
        );
    }
 
    renderPage() {
        let lis = [];
        this.state.items.forEach((item, index) => {
            lis.push(
                <li key={index} to={`/msg-detail-page/${index}`} onClick={this.onItemClicked}>
                    {item.title}{index}
                </li>
            );
        })
 
        // 外层容器要固定高度,才能使用滚动条
        return (
            <div>
                <divid={style.ListOutsite} style={{height: window.innerHeight}}
                    onTouchStart={this.onTouchStart} onTouchEnd={this.onTouchEnd}>
                    <ulid={style.ListInside}>
                        <p ref="PullDown" id={style.PullDown}>{this.pullDownTips[this.state.pullDownStatus]}</p>
                        {lis}
                        <p ref="PullUp" id={style.PullUp}>{this.pullUpTips[this.state.pullUpStatus]}</p>
                    </ul>
                </div>
            </div>
        );
    }
 
    render() {
        if (this.state.isLoading) {
            return this.renderLoading();
        } else {
            return this.renderPage();
        }
    }
}
 
MsgListPage.contextTypes = {
    router: () => { React.PropTypes.object.isRequired }
};
  • 添加一个state变量isLoading,并在render()方法中进行分支变换。

  • 在componentDidMount中仅发起网络请求,因为此时renderPage尚未执行,还无法初始化iscroll。

  • fetchItems完成后,保存items同时设置isLoading=false。这样在componentDidUpdate的时候,如果isLoading为false表示完成了renderPage()的渲染,所以可以初始化iscroll对象,当然别忘记将下拉提示栏滚到屏幕外面。

MsgDetailPage详情页

比列表页的处理还要简单很多,不再赘述:

importReactfrom "react";
import {Link} from "react-router";
import $ from "jquery";
importstylefrom "./MsgDetailPage.css";
importToolBarfrom "./ToolBar/ToolBar";
importLoadingLayerfrom "../LoadingLayer/LoadingLayer";
 
exportdefault class MsgDetailPageextends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = {
            msgId: this.props.params.msgId,
            contentHeight: 0,
            isLoading: true,
            outerStyle: {height: 0},
        };
    }
 
    fetchDetail() {
        letmsgId = this.state.msgId;
        $.ajax({
            type: 'GET',
            url: '/msg-detail',
            data: {'msgId': msgId},
            dataType: 'json',
            success: (response) => {
                this.setState({
                    msgTitle: response.data.title,
                    msgContent: response.data.content,
                    isLoading: false,  // 首屏加载完成, 标记loading结束
                });
                console.log(`msg-detail?msgId=${msgId} 请求成功, msgContent=${this.state.msgContent}`);
            },
            error: () => {
                console.log(`msg-detail?msgId=${msgId} 请求异常`);
            }
        });
    }
 
    componentDidMount() {
        // 调整loading界面的样式
        letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
        this.setState({outerStyle: {height: window.innerHeight - ToolBar.height()}});
 
        // 发起数据加载(setTimeout模拟延迟)
        setTimeout(() => {
            this.fetchDetail();
        }, 500);
    }
 
    componentDidUpdate() {
        // 加载完成
        if (!this.state.isLoading) {
            lettitle = $(this.refs.MsgTitle);
            letcontainer = $(this.refs.MsgContainer);
            letToolBar = $(this.refs.ToolBar.refs.ToolBarContainter);
 
            // 上半部分总高度
            letheight = title.height() + parseInt(title.css('padding-top')) +
                parseInt(title.css('padding-bottom')) +
                parseInt(container.css("padding-top")) +
                parseInt(container.css("padding-bottom")) +
                parseInt(ToolBar.height());
 
            // 窗口高度-上半部分总高度作为文章的最小高度
            if (this.state.contentHeight != window.innerHeight - height) { // 如果一样则不要setState避免递归渲染
                this.setState({
                    contentHeight: window.innerHeight - height,
                });
            }
        }
    }
 
    renderLoading() {
        letouterStyle = {
            height: window.innerHeight,
        };
        return (
            <div>
                <ToolBarref="ToolBar"/>
                <LoadingLayerouterStyle={this.state.outerStyle}/>
            </div>
        );
    }
 
    renderPage() {
        // refs属性会捕获对应的原生的Dom节点,会在componentDidUpdate中访问Dom来动态计算高度
        return (
            <div>
                <ToolBarref="ToolBar"/>
                <h1id={style.MsgTitle} ref="MsgTitle">{this.state.msgTitle}</h1>
                <divid={style.MsgContainer} ref="MsgContainer" style={{minHeight: this.state.contentHeight}}>
                    <p id={style.MsgContent}>{this.state.msgContent}</p>
                </div>
            </div>
        );
    }
 
    render() {
        if (this.state.isLoading) {
            return this.renderLoading();
        } else {
            return this.renderPage();
        }
    }
}
 
MsgDetailPage.contextTypes = {
    router: () => { React.PropTypes.object.isRequired }
};

这个页面最大的差别就是上面一个ToolBar返回栏,即便在转菊花期间也是存在的,这样用户不耐烦可以点击返回退到列表页。

扫码体验

手机扫描,使用浏览器打开!

[React-章节9 实践篇] 做一个留言板项目 之 实现Loading特效

思考

经过一系列修修改改,这份代码成为了现在这个样子。

最大的困扰其实就是,每次看完详情页后退到列表页,列表页都被重新刷新,我先前停留的位置压根找不到了,这种体验是没法接受的。

这个问题从几篇之前我就提出,但是一直避而不谈,现在我认为时机成熟多了,是时候专门研究一下相关的技术点了,让我们向redux出发吧。

需要 登录 后回复方可回复, 如果你还没有账号你可以 注册 一个帐号。