React、Redux 的单页应用在初始化时恢复状态

这是一个我思考了很久的问题 - 如何通过 URL 恢复 React 网页保存在 Redux 中的所有状态,目前正在开发的项目网页上有很多选项框,需要把 URL 给别人打开该 URL 时,所有选项框都必须恢复成之前的状态。

这个用 React + Redux 因为没有先例,而它又有自己内部的,不对外暴露的 Store,导致整个中秋节都在思考这个问题

先来看一下最终实现的效果(请忽略还没有开始写样式。。。)。

1474192360_7_w885_h534.gif

URL 是什么?

一个典型的 URL: http://example.com/user/xqkuang/articles/add?kmref=km_header&editor=md ,其中包含了:

  1. 协议 Protocol- http:// 协议
  2. 服务器域名 Host - km.oa.com
  3. 访问网页路径,学名 路由 Router - /user/xqkuang/articles/add -这是网页路由,还有一种单页应用专有的通过 hash # 符号开头确定的路由,因为单页应用只有一个 HTML 文件,当没办法做 nginx tryfile 的时候 hashHistory 也是个 workaround。
  4. 参数 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 中,它有两个参数:

  1. state - 指向当前 Store 里的 state 信息,第一次 Store 为空时初始化 initialState 数据也在这里声明。
  2. 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的单页应用在初始化时恢复状态