[React-章节9 实践篇] 做一个留言板项目 之 实现Loading特效
接上篇《[React-章节8 实践篇] 做一个留言板项目 之 留言列表页》。
一般h5应用在页面加载完成之前都会有一个loading加载的动画效果,一旦完成加载一次性展现完整的内容给用户,这样更贴合native应用的体验,我相信没有用户喜欢看着一个残缺的页面等待网络数据下载完成。
我也要实现这样一个效果,原理是在每个页面最初显示一个转菊花的画面,在背后发起网络请求,等待必要的数据获取完成后停止转菊花的画面,转而渲染页面本身的内容。
实现为通用组件
我将转菊花实现成了一个组件叫做LoadingLayer,它在一个div的中央加载了一个svg转菊花图片,并支持上层通过props传递内联css样式进行控制。本次并没有实现加载失败之后点击重新刷新的效果,这个我计划在后续redux学习中去实现。
先看一下LoadingLayer的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import React from "react"; import $ from "jquery"; import style from "./LoadingLayer.css"; import loadingImg from "../../common/img/loading.svg"; export default class LoadingLayer extends React.Component { constructor(props, context) { super(props, context); this.state = { }; } render() { let outerStyle = this.props.outerStyle ? this.props.outerStyle : {}; let innerStyle = this.props.innerStyle ? this.props.innerStyle : {}; return ( <div id={style.outer} style={outerStyle}> <div id={style.inner} style={innerStyle}> <img src={loadingImg}/> </div> </div> ); } } LoadingLayer.contextTypes = { router: () => { React.PropTypes.object.isRequired } }; |
我在common/img目录里准备了一个loading.svg菊花动画,并在这里加载。最重要的是上层需要通过透传props.outerStyle来控制整个加载页面的高度,而菊花图片已经通过css控制总是显示在中央了:
1 2 3 4 5 6 7 8 9 10 |
#outer { display: table; width: 100%; } #inner { text-align: center; vertical-align: middle; display: table-cell; } |
另外,实现过程中发现svg图片加载时报错,原来是我的webpack.config.js中url-loader少配置了svg后缀文件的识别,加上即可:
1 2 3 4 5 |
{ // 小于8KB的图片使用base64内联 test: /\.(png|jpg|gif|svg)$/, loader: 'url-loader?limit=8192&name=images/[name]_[hash].[ext]' // 图片提取到images目录 } |
改造现有页面
改造的思路需要把握2方面,一个是首屏是loading组件,同时发起网络请求获取数据,在此期间仍旧展示loading效果。其次,加载数据完成后通过state变换重新渲染组件原本内容。
MsgListPage列表页
改造后的MsgListPage组件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 |
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阶段的事件捕捉 import LoadingLayer from "../LoadingLayer/LoadingLayer"; import loadingImg from "../../common/img/loading.svg"; export default class MsgListPage extends 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=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 if (!this.state.isLoading) { this.ensureIScrollInstalled(); // 仅当列表发生了变更,才调用iscroll的refresh重新计算滚动条信息 if (this.itemsChanged) { this.iScrollInstance.refresh(); } } return true; } renderLoading() { let outerStyle = { height: window.innerHeight, }; return ( <div> <LoadingLayer outerStyle={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> <div id={style.ListOutsite} style={{height: window.innerHeight}} onTouchStart={this.onTouchStart} onTouchEnd={this.onTouchEnd}> <ul id={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详情页
比列表页的处理还要简单很多,不再赘述:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
import React from "react"; import {Link} from "react-router"; import $ from "jquery"; import style from "./MsgDetailPage.css"; import ToolBar from "./ToolBar/ToolBar"; import LoadingLayer from "../LoadingLayer/LoadingLayer"; export default class MsgDetailPage extends React.Component { constructor(props, context) { super(props, context); this.state = { msgId: this.props.params.msgId, contentHeight: 0, isLoading: true, outerStyle: {height: 0}, }; } fetchDetail() { let msgId = 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界面的样式 let ToolBar = $(this.refs.ToolBar.refs.ToolBarContainter); this.setState({outerStyle: {height: window.innerHeight - ToolBar.height()}}); // 发起数据加载(setTimeout模拟延迟) setTimeout(() => { this.fetchDetail(); }, 500); } componentDidUpdate() { // 加载完成 if (!this.state.isLoading) { let title = $(this.refs.MsgTitle); let container = $(this.refs.MsgContainer); let ToolBar = $(this.refs.ToolBar.refs.ToolBarContainter); // 上半部分总高度 let height = 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() { let outerStyle = { height: window.innerHeight, }; return ( <div> <ToolBar ref="ToolBar"/> <LoadingLayer outerStyle={this.state.outerStyle}/> </div> ); } renderPage() { // refs属性会捕获对应的原生的Dom节点,会在componentDidUpdate中访问Dom来动态计算高度 return ( <div> <ToolBar ref="ToolBar"/> <h1 id={style.MsgTitle} ref="MsgTitle">{this.state.msgTitle}</h1> <div id={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返回栏,即便在转菊花期间也是存在的,这样用户不耐烦可以点击返回退到列表页。
扫码体验
手机扫描,使用浏览器打开!
思考
经过一系列修修改改,这份代码成为了现在这个样子。
最大的困扰其实就是,每次看完详情页后退到列表页,列表页都被重新刷新,我先前停留的位置压根找不到了,这种体验是没法接受的。
这个问题从几篇之前我就提出,但是一直避而不谈,现在我认为时机成熟多了,是时候专门研究一下相关的技术点了,让我们向redux出发吧。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~

呃的下
1
1