React、Redux 的单页应用在初始化时恢复状态
这是一个我思考了很久的问题 - 如何通过 URL 恢复 React 网页保存在 Redux 中的所有状态,目前正在开发的项目网页上有很多选项框,需要把 URL 给别人打开该 URL 时,所有选项框都必须恢复成之前的状态。
这个用 React + Redux 因为没有先例,而它又有自己内部的,不对外暴露的 Store,导致整个中秋节都在思考这个问题
先来看一下最终实现的效果(请忽略还没有开始写样式。。。)。
URL 是什么?
一个典型的 URL: http://example.com/user/xqkuang/articles/add?kmref=km_header&editor=md ,其中包含了:
- 协议 Protocol-
http://
协议 - 服务器域名 Host -
km.oa.com
- 访问网页路径,学名
路由
Router -/user/xqkuang/articles/add
-这是网页路由,还有一种单页应用专有的通过 hash#
符号开头确定的路由,因为单页应用只有一个 HTML 文件,当没办法做 nginx tryfile 的时候 hashHistory 也是个 workaround。 - 参数 Query,问号后面的字符串,用于给路由的网页确定展示的方式 -
?kmref=km_header&editor=md
其中记录状态,主要依靠 路由
确定当前显示哪个网页,参数
确定那个网页怎么进行展示。
路由和参数如何设计?
设计原则其实非常简单,一个网页从一开始就必须确定的内容,就放到路由里面,例如上面的例子,意思非常明显,就是 /用户/xqkuang/文章/添加
把主谓宾都准备妥当了,谁在哪儿要干什么都很清楚,这些参数都不能少(这个例子其实不是很好,/user/xqkuang
可以通过 session 确定,而无需写到 URL 里),如果把这个网页拷贝给另外一个人打开,也会是相同的页面。
而可变的内容,就放到参数里,例如这里的 &编辑器=md
就表明了要用 markdown 而不是 html 编辑器去写文章,如果这里发生变化,那网页的内容也会发生变化,它并不用去描述具体做什么,而是锦上添花似地说明怎么去做,如果没有这些参数,并不影响网页的具体行为和它要做的事情,网页也会以一个默认的参数去达到目标。
服务器端网页和单页应用在处理路由和参数时的异同
服务器端渲染处理路由和参数其实非常简单,因为服务器端直接承接来自浏览器完整的 HTTP 请求,整个 URL 都 直接暴露与 Web service 可见范围之内,服务器端网页拿到请求后,可以根据路由选择合适的 HTML 模板文件,根据参数去判断各种条件,读取数据库并且生成页面,是个很平滑的过程。
而单页应用不同,它从服务器那里获取获取的实际只有一个 Javascript 文件具备处理能力(该文件其实通常放在没有任何处理能力的 CDN 上),路由和参数都是由该 Javascript 进行处理,路由和参数虽然也都传递给了后端服务器,但其实后端根本不处理,对于单页应用的后端服务器而言,除了数据接口服务,它的功能只是生成一个引用了 CDN 上 Javascript 文件的 HTML 字符串给浏览器,Javascript 需要自行根据路由选择合适的前端模板,根据参数去生成 Ajax 数据请求,获取到数据后再在浏览器中显示。
还有一个很重要的不同点,和服务器渲染的网页不同,单页应用通常是通过 window.history.pushState() 方法做到无刷新切换页面的,虽然在页面中点击链接跳转,其实并没有发起新的网页请求到服务器,而全部被 Javascript 捕获,并进行内部根据链接地址进行页面重新渲染,所以每次 URL 的状态也需要随时进行更新。
React + Redux 方案处理路由需要的两个库
从这里开始就需要你有 React + Redux 开发经验了。。。
React 本身只是个 View,所以需要其它的库进行扩展,以方便在应用中使用路由、参数处理等功能。
1. react-router - 路由库
https://github.com/ReactTraining/react-router
这已经是 React 应用标准组件之一,截稿时最新稳定版本是 2.8.x,最新开发版本的 React router 4.0 alpha 将会有很大改变(事实上这个库每次升级看起来都像新的路由库),具体写法请参考官方文档,特别需要提到的是,它在 v2.4 之后提供了 withRouter() 的高阶组件,为 Component 增加了 props.router 方法, 可以通过它进行页面路由、参数的跳转切换,后面会继续提到它。
2. react-router-redux - 将 react-router 的状态映射到 redux store 中
https://github.com/reactjs/react-router-redux
这个库本来只是一个个人开发的小作品,但现在也被 Facebook 收入到 reactjs git 库之下,它的作用是将当前 URL 的路由和状态信息,跟 Store 进行同步,放到 props.routing 里,里面有类似 path、query 那样的属性,这样 Component 可以很方便的查询到当前的 URL 信息。
PS: React router 4.0 后将会提供一个单独的 props.location 来支持相同功能,这个库到时可能会被废弃。
思考路程和 Redux 的限制
在 React Redux 应用中,所有的数据初始化、保存都是在 Redux 中的,经过特别细致的 Component 拆分,目前我所开发应用已经不再使用 React state,而全部使用 mapStateToProps() 方法把所有的 Redux state 转换为 React props 在 Component 间传递数据,这样整个结构非常简单,业务逻辑全部放到 Redux 中,通过 dispatch action 获取需要的数据后,触发 reducer 整理成 Component 所需要的数据,Component 唯一要做的事情,就是根据数据进行重新渲染,类似表格 Component 也是由 Reducer 直出表格行数据进行绘制,数据层与视图层完全分离,这样的优势无疑是很大的,Component 不但不再需要传统网页那样的操作 DOM,甚至不需要和未拆分完全 React 应用那样进行各种是否需要重新渲染的判断,而且特别方便进行单元测试。
Redux 的数据初始化,一般放在 reducer 中,它有两个参数:
state
- 指向当前 Store 里的 state 信息,第一次 Store 为空时初始化 initialState 数据也在这里声明。action
- dispatch 过来的 action 信息,或者是异步 action 返回来的数据。
实际的业务流程是这样的,分两部分:
一、当网页打开时:
从 URL 获取参数
-> 生成 Action 的初始化参数
-> 根据初始化参数生成 ajax 参数
-> 发起数据请求
-> 异步回调将数据传递给 reducer
-> reducer 生成 Component 所需要的数据进行渲染
二、当在页面跳转或者选项框 Component 发生变化时:
选项框发生变化
-> 生成新的参数 dispatch action
-> 根据初始化参数生成 ajax 参数
-> 发起数据请求
-> 异步回调将数据传递给 reducer
-> reducer 生成 Component 所需要的数据进行渲染
-> 更新 URL 中的参数字段
依然使用 Redux 去同步数据相对会让视图更新更加容易,因为需要通过它对数据进行修改触发重新渲染机制,但是这样的方案会出现有 Redux Store 和 URL Store 两个存储数据的地方。
最初的想法,是希望能找到一个办法,把 URL 参数 Query 和 Redux Store 里同步起来,但是 Dan 大神认为通过 React Router 进行跳转已经足够。而且在实际的探索中,因为那时 LOCATION_CHANGE action 尚未触发,发现 react-router-redux 的 props.routing 并不能在页面加载完第一次初始化时使用,整个 props 都是空值。同时 reducer 的第一个 state 参数在应用初始化之后不可修改,这给最初的想法带来了很深的困扰。
最终的解决方案
既然不能在应用运行时修改 reducer 的 state 初始化参数,那就在应用初始化时获取一下 URL 里的参数,进行 state 参数初始化好了,虽然没有用上 react-router-redux 的 props.routing 进行参数获取,多余做了一步,但是起码能先解决问题。
监听 history 变动
首先,我这儿先做了一步,在入口代码中,监听 react-router 的 history 改变事件,将路由状态信息放到了 window 全局中(这个确实有点脏,不过路由信息我这儿别的地方也要用,放在这里方便一些)。
/* Initial history */
export const history = syncHistoryWithStore(
reactRouter[config.historyBackend], store
)
// Binding location to window for reducer initialState
history.listen(location => {
window.reactLocation = location;
});
应用初始化时从 URL 获取参数
然后在工具类中加入 getUrlQuery() 方法,用于获取指定的 URL 参数,还有一个 getUrlQueryTarget() 方法,用于从数据数组中提取指定的对象,我这儿的 action 都是传递对象,这样当 URL 参数不对时(比如被用户篡改了),也可以恢复成默认参数。
可以看到 getUrlQuery() 方法首先从刚才定义的 window.reactLocation 中获取参数,第一次初始化时它不存在,才去直接判断 window.location 中获取实际的参数值的,这里还用到了 qs 库进行 URL 参数解析。
import qs from 'qs';
/*
* Get url params
*/
export const getUrlQuery = key => {
if (window.reactLocation) {
return window.reactLocation.query[key];
}
let queryStr = ''
if (config.historyBackend === 'hashHistory') {
queryStr = window.location.hash.split('?')[1];
} else if (config.historyBackend === 'browserHistory') {
queryStr = window.location.search.substring(1);
}
const query = qs.parse(queryStr);
return query[key]
}
/*
* Get url query specific object
*/
export const getUrlQueryTarget = (key, collection) => {
const query = getUrlQuery(key);
const targets = collection.filter(x => x.key === query);
if (!targets.length) {
console.error(`Target was not found for query key; ${key}`);
return null
}
return targets[0]
}
Reducer 初始化时需要通过 getUrlQueryTarget() 方法,从 URL 里获取初始化参数,并且监听 LOCATION_CHANGE action,当路由发生改变时,处理好 Component 需要的数据并且返回给 Component。
import { LOCATION_CHANGE } from 'react-router-redux';
import { INTERNET_PLUS_KINDS } from '../constants';
const kindInitialState = {
kinds: INTERNET_PLUS_KINDS,
kind: getUrlQueryTarget('kind', INTERNET_PLUS_KINDS) || INTERNET_PLUS_KINDS[0]
}
export const internetPlusKindReducer = (state = kindInitialState, action) => {
switch (action.type) {
case LOCATION_CHANGE: {
const kind = INTERNET_PLUS_KINDS.filter(
x => x.key === action.payload.query.kind
)[0];
return {
kinds: INTERNET_PLUS_KINDS,
kind: kind || INTERNET_PLUS_KINDS[0]
}
}
default:
return state
}
}
同样的,在另外一个负责 Ajax 请求的容器 Component 里,也需要 connect 到 internetPlusKindReducer,当接收到新的 kind 参数时,通过 componentWillReceiveProps(nextProps) 方法 dispatch action.
之所以把请求放在容器 Component 里,是因为这些组件都是共享一个来自 fetchInternetPlusProvincesData action 的数据,但是会经过 reducer 处理成各自不同需要的数据,所以触发放在容器里。所有子 Component 都无需从父 Container 的 props 里获取参数,而是对接各自的 Reducer 直接获取数据,这样其实就解耦了数据层和视图层。
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux'
import Kind from './kind';
import Content from './content';
import { fetchInternetPlusProvincesData } from '../../actions/internet-plus';
export class Container extends Component {
static propTypes = {
fetchInternetPlusProvincesData: PropTypes.func,
kind: PropTypes.object,
}
componentDidMount () {
const { fetchInternetPlusProvincesData, kind } = this.props;
fetchInternetPlusProvincesData({ kind });
}
componentWillReceiveProps (nextProps) {
const { fetchInternetPlusProvincesData } = this.props;
const { kind } = nextProps;
fetchInternetPlusProvincesData({ kind });
}
render () {
return (
<div>
<Kind className="internet-plus-kind" />
<Content />
</div>
);
}
}
// State to props for connect argument
export const mapStateToProps = (state) => {
return {
kind: state.internetPlusKindlReducer.kind
};
};
// Dispatch to props for connect argument
const mapDispatchToProps = {
fetchInternetPlusProvincesData
};
export default connect(mapStateToProps, mapDispatchToProps)(Container);
当选项框发生改变时,直接改变路由参数。
流程和之前设计时发生了一点点变化,变成了直接改变 URL 参数,然后触发 LOCATION_CAHNGE,reducer 接收到之后整理数据,到容器 Component 触发 Ajax call,整理的数据再返回给容器和选项框组件。
选项框发生变化
-> 直接改变路由参数触发 LOCATION_CHANGE
-> Reducer 接受到之后根据参数整理新的 Ajax 参数
-> 发起数据请求
-> 异步回调将数据传递给 reducer
-> reducer 生成 Component 所需要的数据进行渲染
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux'
import { withRouter } from 'react-router';
import { DropdownButton, MenuItem } from 'react-bootstrap';
import { changeQuery } from '../../utils';
export class InternetPlusChartKind extends Component {
static propTypes = {
router: PropTypes.object,
className: PropTypes.string,
kinds: PropTypes.array,
kind: PropTypes.object
}
changeKindHandle (eventKey) {
const { router } = this.props;
changeQuery(router, 'kind', eventKey);
}
render () {
const { className, kinds, kind } = this.props;
return (
<div className={ className }>
<DropdownButton
id="internet-plus-kind"
onSelect={ eventKey => { this.changeKindHandle(eventKey); } }
bsStyle={ 'primary' }
title={ kind.name }>
{
kinds.map((d) => {
return (
<MenuItem key={ d.key } eventKey={ d.key }>
{ d.name }
</MenuItem>
);
})
}
</DropdownButton>
</div>
);
}
}
// State to props for connect argument
export const mapStateToProps = (state) => {
return {
kinds: state.internetPlusKindReducer.kinds,
kind: state.internetPlusKindReducer.kind
};
};
export default connect(mapStateToProps)(withRouter(InternetPlusChartKind));
其实我有尝试过保留选项框组件的单独的 change kind action dispatching,不在 Reducer 中监听 LOCATION_CHANGE,但是发现那样会导致浏览器的前进后退按钮失效,最终改成了这样的方案。
这个方案简单、干脆、明了,以上都是核心代码,思路仅供参考,希望能给大家提供帮助。
版权所有丨转载请注明出处:https://kxq.io/archives/reactredux的单页应用在初始化时恢复状态