返回 登录
0

大型前端应用中,Redux与服务器异步通信过程全解析(文末有彩蛋!)

本文节选自程墨撰写的《深入浅出React和Redux》一书,由机械工业出版社。
作者:程墨,资深架构师,曾任职于摩托罗拉、雅虎和微软,云鸟配送平台联合创始人,目前服务于美国视频服务公司Hulu。知乎专栏《进击的React》作者,《深入浅出React和Redux》一书作者。
程墨,也是“前端开发创新实践”线上峰会的特邀讲师。他将带大家全面剖析Redux,及其在管理大型前端应用数据上的最佳实践。该峰会采用线上直播形式,将于7月8日召开,票价5折热销中。
大家注意啦,文末有彩蛋噢!!

无论Redux还是React,工作方式都是靠数据驱动,到目前为止,本文例子中的数据都是通过用户输入产生,但现实中,应用的数据往往存储在数据库中,通过一个API服务器暴露出来,网页应用要获得数据,就需要与服务器进行通信。

本文中,我们会介绍:

  • React组件访问服务器的方式;
  • Redux架构下访问服务器的方式。
  • React组件访问服务器的方式适用于简单的网页应用;对于复杂的网页应用,自然会采用Redux来管理数据,所以在Redux环境中访问服务器会是我们介绍的重点。

React组件访问服务器

我们先考虑最直接最简单的场景,在一个极其简单的网页应用中,有可能只需要单独使用React库,而不使用Redux之类的数据管理框架,这时候React组件自身也可以担当起和服务器通信的责任。

访问服务器本身可以使用任何一种支持网络访问的JavaScript库,最传统的当然是jQuery的$.ajax函数,但是我们都用上了React了,那也就没有必要使用jQuery,实在没有理由再为了一个$.ajax函数引入一个jQuery库到网页里面来。

一个趋势是在React应用中使用浏览器原生支持的fetch函数来访问网络资源,fetch函数返回的结果是一个Promise对象,Promise模式能够让需要异步处理的代码简洁清晰,这也是fetch函数让大家广为接受的原因。

对于不支持fetch的浏览器版本,也可以通过fetch的polyfill来增加对fetch的支持。接下来的例子都用fetch来访问服务器数据资源。

提示:polyfill指的是“用于实现浏览器不支持原生功能的代码”,比如,现代浏览器应该支持fetch函数,对于不支持的浏览器,网页中引入对应fetch的polyfill后,这个polyfill就给全局的window对象上增加一个fetch函数,让这个网页中的JavaScript可以直接使用fetch函数了,就好像浏览器本来就支持fetch一样。在这个链接上 https://github.com/github/fetch 可以找到fetch polyfill的一个实现。

我们用一个具体实例来说明如何让React访问服务器。

如果要做成一个完整的网站应用,那么应该把待办事项数据存储在服务器端,这样待办事项能够持久化存储下来,而且从任何一个浏览器访问都能够看到待办事项,当然服务器端如何存储不是本书要讨论的范围,我们的重点是开发一个新的应用来展示React访问服务器的方式。

近年来,天气问题成了大家都关注的问题,我们做一个能够展示某个城市天气的React组件,这样很有实际意义。

我们不想浪费篇幅去介绍如何构建一个服务器端API,而是利用互联网上现成的API来支持我们的应用。 中国天气网提供了RESTful API用于访问某个城市的天气信息,在例子中我们会利用这个API来获得天气数据。

代理功能访问API

我们利用create-react-app创建一个新的应用,名叫weather_react,读者可以在本书对应Github代码库目录下找到完整代码。

首先我们要确定访问什么样的API能够获得天气信息,中国天气网提供的RESTful API中有访问一个城市当前天气情况的API,规格如表1所示。

图片描述

表1 中国天气网获取城市天气API规格

根据这样的API规格,如果要访问北京的天气情况,先确定北京的城市编号是101010100,用GET方法访问http://www.weather.com.cn/data/cityinfo/101010100.html,就能够得到类似下面的JSON格式结果:

{
  "weatherinfo":{
    "city":"北京",
    "cityid":"101010100",
    "temp1":"-2℃",
    "temp2":"16℃",
    "weather":"晴",
    "img1":"n0.gif",
    "img2":"d0.gif",
    "ptime":"18:00"
  }
}

但是,我们的网页应用中不能够直接访问中国天气网的这个API,因为从我们本地的网页访问 weather.com.cn域名下的网络资源属于跨域访问,而中国天气网的API并不支持跨域访问,所以在我们的应用中如果直接像下面那样使用fetch访问这个API资源,肯定无法获得我们预期的JSON格式结果:

fetch(http://www.weather.com.cn/data/cityinfo/101010100.html)

解决跨域访问API的一个方式就是通过代理(Proxy),让我们的网页应用访问所属域名下的一个服务器API接口,这个服务器接口做的工作就是把这个请求转发给另一个域名下的API,拿到结果之后再转交给发起请求的浏览器网页应用,只是一个“代理”工作。

因为对于跨域访问API的限制是针对浏览器的行为,服务器对任何域名下的API的访问不受限制,所以这样的代理工作可以成功实现对跨域资源的访问。

在本地开发的时候,网页应用的域名是localhost,对应的服务器域名也是localhost,所以要在localhost服务器上建立一个代理。好在create-react-app创造的应用已经具备了代理功能,所以并不用花费时间来开发一个代理服务。

在weather_react应用的根目录package.json中添加如下一行:

"proxy": "http://www.weather.com.cn/",

这一行配置告诉weather_react应用,当接收到不是要求本地资源的HTTP请求时,这个HTTP请求的协议和域名部分替换为http://www.weather.com.cn/ 转手发出去,并将收到的结果返还给浏览器,这样就实现了代理功能。

例如,假如服务器收到了一个网页发来的 http://localhost/data/cityinfo/101010100.html的请求,就会发送一个请求到 http://www.weather.com.cn/data/cityinfo/101010100.html,并把这个请求结果返回给网页。

至此,我们就准备好了一个API。

提示:create-react-app生成应用的proxy功能只是方便开发,在实际的生产环境中,使用这个proxy功能就不合适了,应该要开发出自己的代理服务器来满足生产环境的需要。

React组件访问服务器的生命周期

在weather_react应用中,我们创造一个名为Weather的组件,这个组件可以在本书的Github代码库目录下找到。

这个Weather组件将要显示指定城市的当前天气情况,这个组件封装了两个功能:

  • 通过服务器API获得天气情况数据;
  • 展示天气情况数据。

现在面临的首要问题是如何关联异步的网络请求和同步的React组件渲染。

访问服务器API是一个异步操作。因为JavaScript是单线程的语言,不可能让唯一的线程一直等待网络请求的结果,所以所有对服务器的数据请求必定是异步请求。

但是,React组件的渲染又是同步的,当开始渲染过程之后,不可能让Weather组件一边渲染一边等待服务器的返回结果。

总之,当Weather组件进入装载过程的时候,即使此时Weather立刻通过fetch函数发起对服务器的请求,也没法等待服务器的返回数据来进行渲染。因为React组件的装载过程和更新过程中生命周期函数是同步执行的,没有任何机会等待一个异步操作。

所以,可行的方法只能是这样,分两个步骤完成:

步骤1,在装载过程中,因为Weather组件并没有获得服务器结果,就不显示结果。或者显示一个“正在装载”之类的提示信息,但Weather组件这时候要发出对服务器的请求。

步骤2,当对服务器的请求终于获得结果的时候,要引发Weather组件的一次更新过程,让Weather重新绘制自己的内容,这时候就可以根据API返回结果绘制天气信息了。

从上面的过程可以看得出来,为了显示天气信息,必须要经历装载过程和更新过程,至少要渲染Weather组件两次。

还有一个关键问题,在装载过程中,在什么时机发出对服务器的请求呢?

通常我们在组件的componentDidMount函数中做请求服务器的事情,因为当生命周期函数componentDidMount被调用的时候,表明装载过程已经完成,组件需要渲染的内容已经在DOM树上出现,对服务器的请求可能依赖于已经渲染的内容,在component-DidMount函数中发送对服务器请求是一个合适的时机。

另外,componentDidMount函数只在浏览器中执行。当React组件在服务器端渲染时,肯定不希望它发出无意义的请求,所以componentDidMount是最佳的获取初始化组件内容请求的时机。

万事俱备,我们来看一看定义Weather组件的weather.js文件内容,首先是构造函数,代码如下:

constructor() {
  super(...arguments);
  this.state = {weather: null};
}

因为Weather组件要自我驱动更新过程,所以Weather必定是一个有状态的组件,状态中包含天气情况信息,在状态上有一个weather字段,这个字段的值是一个对象,格式和服务器API返回的JSON数据中的weatherinfo字段一致。

在render函数中,所要做的是渲染this.state上的内容,代码如下:

render() {
  if (!this.state.weather) {
    return <div>暂无数据</div>;
  }

  const {city, weather, temp1, temp2} = this.state.weather;
  return (
    <div>
      {city} {weather} 最低气温 {temp1} 最高气温 {temp2}
    </div>
  )
}

在构造函数中,我们将组件状态上的weather字段初始化为null。这样,在装载过程引发的第一次render函数调用时,就会直接渲染一个“暂无数据”的文字;但是当state上包含weather信息时,就可以渲染出实际的天气信息。

通过API获得数据的工作交给componentDidMount,代码如下:

componentDidMount() {
  const apiUrl = `/data/cityinfo/${cityCode}.html`;
  fetch(apiUrl).then((response) => {
    if (response.status !== 200) {
      throw new Error('Fail to get response with status ' + response.status);
    }
    response.json().then((responseJson) => {
      this.setState({weather: responseJson.weatherinfo});
    }).catch((error) => {
      this.setState({weather: null});
    });
  }).catch((error) => {
      this.setState({weather: null});
  });
}

fetch函数执行会立刻返回,返回一个Promise类型的对象,所以后面会跟上一大串then和catch的语句。每个Promise成功的时候,对应的then中的回调函数会被调用;如果失败,对应catch中的回调函数也被调用。

值得注意的是,fetch的参数apiUrl中只有URL的路径部分,没有协议和域名部分,代码如下:

const apiUrl = `/data/cityinfo/${cityCode}.html`;

这样是为了让fetch根据当前网页的域名自动配上协议和域名,如果当前网页地址是 http://localhost,那么fetch请求的URL就是 http://localhost/data/cityinfo/101010100.html;如果当前网页地址是http://127.0.0.1,那么URL就是http://127.0.0.1/data/cityinfo/ 101010100.html ,好处就是网页代码中无需关心当前代码被部署在什么域名下。

遗憾的是,中国天气网没有提供根据访问者IP映射到城市的功能。在这个例子中我们硬编码了北京市的城市代码,读者在实际操作的时候,也可以把weather.js中的模块级变量cityCode改为你所在的城市,在 http://www.weather.com.cn/ 页面上搜索城市名,网页跳转后URL上的数字就是城市对应的城市代码。

componentDidMount中这段代码看起来相当繁杂。不过没有办法,输入输出操作就是这样,因为fetch的过程是和另一个计算机实体通信,而且通信的介质也是一个无法保证绝对可靠的互联网,在这个通信过程中,各种异常情况都可能发生,服务器可能崩溃没有响应,或者服务器有响应但是返回的不是一个状态码为200的结果,再或者服务器返回的是一个状态码为200的结果,结果的实际内容可能并不是一个合法的JSON数据。正因为每一个环节都可能出问题,所以每一个环节都需要判断是不是成功。

虽然被fetch广为接受,大有取代其他网络访问方式的架势,但是它有一个特性一直被人诟病,那就是fetch认为只要服务器返回一个合法的HTTP响应就算成功,就会调用then提供的回调函数,即使这个HTTP响应的状态码是表示出错了的400或者500。正因为fetch的这个特点,所以我们在then中,要做的第一件事就是检查传入参数response的status字段,只有status是代表成功的200的时候才继续,否则以错误处理。

当response.status为200时,也不能直接读取response中的内容,因为fetch在接收到HTTP响应的报头部分就会调用then,不会等到整个HTTP响应完成。所以这时候也不保准能读到整个HTTP报文的JSON格式数据。所以,response.body函数执行并不是返回JSON内容,而是返回一个新的Promise,又要接着用then和catch来处理成功和失败的情况。如果返回HTTP报文内容是一个完整的JSON格式数据就会成功,如果返回结果不是一个JSON格式,比如是一堆HTML代码,那就会失败。

当历经各种检查最后终于获得了JSON格式的结果时,我们通过Weather组件的this.setState函数把weatherinfo字段赋值到weather状态上去,如果失败,就把weather设为null。

处理输入输出看起来的确很麻烦,但是必须要遵照套路把所有可能出错的情况都考虑到,对任何输入输出操作只要记住一点:不要相信任何返回结果

至此,Weather功能完成了,我们打开网页刷新察看最终效果,可以看到网页最开始显示“暂无数据”,这是装载过程的渲染结果,过了一会,当通过代理调用中国天气网远端API返回的时候,网页上就会显示北京市的天气情况,这是API返回数据驱动的Weather组件更新过程的渲染结果,显示界面如图1所示。

图片描述

图1 组件Weather的界面

React组件访问服务器的优缺点

通过上面的例子,我们可以感受到让React组件自己负责访问服务器的操作非常直接简单,容易理解。对于像Weather这样的简单组件,代码也非常清晰。

但是,把状态存放在组件中其实并不是一个很好的选择,尤其是当组件变得庞大复杂了之后。

Redux是用来帮助管理应用状态的,应该尽量把状态存放在Redux Store的状态中,而不是放在React组件中。同样,访问服务器的操作应该经由Redux来完成。

接下来,我们就看一看用Redux来访问服务器如何做到。

Redux访问服务器

为了展示更丰富的功能,我们扩展前面的展示天气信息应用的功能,让用户可以在若干个城市之中选择,选中某个城市,就显示某个城市的天气情况,这次我们用Redux来管理访问服务器的操作。

对应的代码可以在本书Github代码库的chapter-07/weather_redux目录下找到。

我们还是用create-react-app创造一个新的应用,叫weather_redux,这个应用创建Store的部分将使用一个叫redux-thunk的Redux中间件。

redux-thunk中间件

使用Redux访问服务器,同样要解决的是异步问题。

Redux的单向数据流是同步操作,驱动Redux流程的是action对象,每一个action对象被派发到Store上之后,同步地被分配给所有的reducer函数,每个reducer都是纯函数,纯函数不产生任何副作用,自然是完成数据操作之后立刻同步返回,reducer返回的结果又被同步地拿去更新Store上的状态数据,更新状态数据的操作会立刻被同步给监听Store状态改变的函数,从而引发作为视图的React组件更新过程。

这个过程从头到尾,Redux马不停蹄地一路同步执行,根本没有执行异步操作的机会,那应该在哪里插入访问服务器的异步操作呢?

Redux创立之初就意识到了这种问题,所以提供了thunk这种解决方法,但是thunk并没有作为Redux的一部分一起发布,而是存在一个独立的redux-thunk发布包中,我们需要安装对应的npm包:

npm install --save redux-thunk

实际上,redux-thunk的实现极其简单,只有几行代码,将它作为一个独立的npm发布而不是放在Redux框架中,更多的只是为了保持Redux框架的中立性,因为redux-thunk只是Redux中异步操作的解决方法之一,还有很多其他的方法,具体使用哪种方法开发人员可以自行决定,在后面章节会介绍Redux其他支持异步操作的方法。

读者可能想问thunk这个命名是什么含义,thunk是一个计算机编程的术语,表示辅助调用另一个子程序的子程序,听起来有点绕,不过看看下面的例子就会体会到其中的含义。
假如有一个JavaScript函数f如下定义:

const f = (x) => {
  return x() + 5;
}

f把输入参数x当做一个子程序来执行,结果加上5就是f的执行结果,那么我们试着调用一次f:

const g = () => {
  return 3 + 4;
}

f(g); //结果是 (3+4)*5 = 37

上面代码中函数g就是一个thunk,这样使用看起来有点奇怪,但有个好处就是g的执行只有在f实际执行时才执行,可以起到延迟执行的作用,我们继续看redux-thunk的用法来理解其意义。

按照redux-thunk的想法,在Redux的单向数据流中,在action对象被reducer函数处理之前,是插入异步功能的时机。

在Redux架构下,一个action对象在通过store.dispatch派发,在调用reducer函数之前,会先经过一个中间件的环节,这就是产生异步操作的机会,实际上redux-thunk提供的就是一个Redux中间件,我们需要在创建Store时用上这个中间件。如图2所示。

图片描述

图2 Redux的action处理流程

redux-immutable-state-invariant这个中间件帮助开发者发现reducer里不应该出现的错误,现在我们要再加一个redux-thunk中间件来支持异步action对象。

我们创建的Store.js文件基本和Todo应用中基本一致,区别就是引入了redux-thunk,代码如下:

import thunkMiddleware from 'redux-thunk';

const middlewares = [thunkMiddleware];

之前我们用一个名为middlewares的数组来存储所有中间件,现在只要往这个数组里加一个元素就可以了,之后,如果需要用到更多的中间件,只需要导入中间件放在middlewares数组中就可以。

异步action对象

当我们想要让Redux帮忙处理一个异步操作的时候,代码一样也要派发一个action对象,毕竟Redux单向数据流就是由action对象驱动的。但是这个引发异步操作的action对象比较特殊,我们叫它们“异步action对象”。

前面例子中的action构造函数返回的都是一个普通的对象,这个对象包含若干字段,其中必不可少的字段是type,但是“异步action对象”不是一个普通JavaScript对象,而是一个函数。

如果没有redux-thunk中间件的存在,这样一个函数类型的action对象被派发出来会一路发送到各个reducer函数,reducer函数从这些实际上是函数的action对象上是无法获得type字段的,所以也做不了什么实质的处理。

不过,有了redux-thunk中间件之后,这些action对象根本没有机会触及到reducer函数,在中间件一层就被redux-thunk截获。

redux-thunk的工作是检查action对象是不是函数,如果不是函数就放行,完成普通action对象的生命周期,而如果发现action对象是函数,那就执行这个函数,并把Store的dispatch函数和getState函数作为参数传递到函数中去,处理过程到此为止,不会让这个异步action对象继续往前派发到reducer函数。

举一个并不涉及网络API访问的异步操作例子,在Counter组件中存在一个普通的同步增加计数的action构造函数increment,代码如下:

const increment = () => ({
  type: ActionTypes.INCREMENT,
});

派发increment执行返回的action对象,Redux会同步更新Store状态和视图,但是我们现在想要创造一个功能,能够发出一个“让Counter组件在1秒之后计数加一”的指令,这就需要定义一个新的异步action构造函数,代码如下:

const incrementAsync = () => {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  };
};

异步action构造函数incrementAsync返回的是一个新的函数,这样一个函数被dispatch函数派发之后,会被redux-thunk中间件执行,于是setTimeout函数就会发生作用,在1秒之后利用参数dispatch函数派发出同步action构造函数increment的结果。

这就是异步action的工作机理,这个例子虽然简单,但是可以看得出来,异步action最终还是要产生同步action派发才能对Redux系统产生影响。

redux-thunk要做的工作也就不过如此,但因为引入了一次函数执行,而且这个函数还能够访问到dispatch和getState,就给异步操作带来了可能。

action对象函数中完全可以通过fetch发起一个对服务器的异步请求,当得到服务器结果之后,通过参数dispatch,把成功或者失败的结果当做action对象再派发出去。这一次派发的是普通的action对象,就不会被redux-thunk截获,而是直接被派发到reducer,最终驱动Store上状态的改变。

异步操作的模式

有了redux-thunk的帮助,我们可以用异步action对象来完成异步的访问服务器功能了,但是在此之前,我们先想一想如何设计action类型和视图。

一个访问服务器的action,至少要涉及三个action类型:

  • 表示异步操作已经开始的action类型,在这个例子里,表示一个请求天气信息的API请求已经发送给服务器的状态;
  • 表示异步操作成功的action类型,请求天气信息的API调用获得了正确结果,就会引发这种类型的action;
  • 表示异步操作失败的action类型,请求天气信息的API调用任何一个环节出了错误,无论是网络错误、本地代理服务器错误或者是远程服务器返回的结果错误,都会引发这个类型的action。

当这三种类型的action对象被派发时,会让React组件进入各自不同的三种状态,如下所示。

  • 异步操作正在进行中;
  • 异步操作已经成功完成;
  • 异步操作已经失败。

不管网络的传输速度有多快,也不管远程服务器响应有多快,我们都不能认为“异步操作正在进行中”状态会瞬间转换为“异步操作已经成功完成”或者“异步操作已经失败”状态。前面说过,网络和远程服务器都是外部实体,是靠不住的。在开发环境下可能速度很快,所以感知不到状态转换,但在其他环境下可能会明显感觉存在延迟,所以有必要在视图上体现三种状态的区别。

作为一种模式,我们需要定义三种action类型,还要定义三种对应的状态类型。

相关代码在本书的Github代码库的chapter- 07/weather_redux目录下可以找到。

和以往的习惯一样,我们为Weather组件创建一个放置所有代码的目录weather,对外接口的文件是src/weather/index.js,把这个功能模块的内容导出,代码如下:

import * as actions from './actions.js';
import reducer from './reducer.js';
import view from './view.js';

export {actions, reducer, view};

在src/weather/actionTypes.js中定义异步操作需要的三种action类型:

export const FETCH_STARTED = 'WEATHER/FETCH_STARTED';
export const FETCH_SUCCESS = 'WEATHER/FETCH_SUCCESS';
export const FETCH_FAILURE = 'WEATHER/FETCH_FAILURE';

在src/weather/status.js文件中定义对应的三种异步操作状态:

export const LOADING = 'loading';
export const SUCCESS = 'success';
export const FAILURE = 'failure';

读者可能觉得actionTypes.js和status.js内容重复了,因为三个action类型和三个状态是一一对应的。虽然看起来代码重复,但是从语义上说,action类型只能用于action对象中,状态则是用来表示视图。为了语义清晰,还是把两者分开定义。

接下来我们看src/weather/actions.js中action构造函数如何定义,首先是三个普通的action构造函数,代码如下:

import {FETCH_STARTED, FETCH_SUCCESS, FETCH_FAILURE} from './actionTypes.js';

export const fetchWeatherStarted = () => ({
  type: FETCH_STARTED
});
export const fetchWeatherSuccess = (result) => ({
  type: FETCH_SUCCESS,
  result
})
export const fetchWeatherFailure = (error) => ({
  type: FETCH_FAILURE,
  error
})

三个普通的action构造函数fetchWeatherStarted、fetchWeatherSuccess和fetchWeather-Failure没有什么特别之处,只是各自返回一个有特定type字段的普通对象,它们的作用是驱动reducer函数去改变Redux Store上weather字段的状态。

关键是随后的异步action构造函数fetchWeather,代码如下:

export const fetchWeather = (cityCode) => {
  return (dispatch) => {
    const apiUrl = `/data/cityinfo/${cityCode}.html`;

    dispatch(fetchWeatherStarted())

    fetch(apiUrl).then((response) => {
      if (response.status !== 200) {
        throw new Error('Fail to get response with status ' + response.status);
      }
      response.json().then((responseJson) => {
        dispatch(fetchWeatherSuccess(responseJson.weatherinfo));
      }).catch((error) => {
        throw new Error('Invalid json response: ' + error)
      });
    }).catch((error) => {
      dispatch(fetchWeatherFailure(error));
    })
  };
}

异步action构造函数的模式就是函数体内返回一个新的函数,这个新的函数可以有两个参数dispatch和getState,分别代表Redux唯一的Store上的成员函数dispatch和getState。这两个参数的传入是redux-thunk中间件的工作,至于redux-thunk如何实现这个功能,大家可以在《深入浅出React和Redux》一书中关于中间件的章节中查看。

在这里,我们只要知道异步action构造函数的代码基本上都是这样的套路,代码如下:

export const sampleAsyncAction = () => {
  return (dispatch, getState) => {
    //在这个函数里可以调用异步函数,自行决定在合适的时机通过dispatch参数
    //派发出新的action对象。
  }
}

在我们的例子中,异步action对象返回的新函数首先派发由fetchWeatherStarted的产生的action对象。这个action对象是一个普通action对象,所以会同步地走完单向数据流,一直走到reducer函数中,引发视图的改变。同步派发这个action对象的目的是将视图置于“有异步action还未结束”的状态,完成这个提示之后,接下来才开始真正的异步操作。

这里使用fetch来做访问服务器的操作,和前面介绍的weather_react应用中的代码几乎一样,区别只是this.setState改变组件状态的语句不见了,取而代之的是通过dispatch来派发普通的action对象。也就是说,访问服务器的异步action,最后无论成败,都要通过派发action对象改变Redux Store上的状态完结。

在fetch引发的异步操作完成之前,Redux正常工作,不会停留在fetch函数执行上,如果有其他任何action对象被派发,Redux照常处理。

我们来看一看src/weather/reducer.js中的reducer函数,代码如下:

export default (state = {status: Status.LOADING}, action) => {
  switch(action.type) {
    case FETCH_STARTED: {
      return {status: Status.LOADING};
    }
    case FETCH_SUCCESS: {
      return {...state, status: Status.SUCCESS, ...action.result};
    }
    case FETCH_FAILURE: {
      return {status: Status.FAILURE};
    }
    default: {
      return state;
    }
  }
}

在reducer函数中,完成了上面提到的三种action类型到三种状态类型的映射,增加一个status字段,代表的就是视图三种状态之一。

这里没有任何处理异步action对象的逻辑,因为异步action对象在中间件层就被redux-thunk拦截住了,根本没有机会走到reducer函数中来。

最后来看看src/weather/view.js中的视图,也就是React组件部分,首先是无状态组件函数,代码如下:

const Weather = ({status, cityName, weather, lowestTemp, highestTemp}) => {
  switch (status) {
    case Status.LOADING: {
      return <div>天气信息请求中...</div>;
    }
    case Status.SUCCESS: {
      return (
        <div>
          {cityName} {weather} 最低气温 {lowestTemp} 最高气温 {highestTemp}
        </div>
      )
    }
    case Status.FAILURE: {
      return <div>天气信息装载失败</div> 
    }
    default: {
      throw new Error('unexpected status ' + status);
    }
  }
}

和weather_react中的例子不同,因为现在状态都是存储在Redux Store上,所以这里Weather是一个无状态组件,所有的props都是通过Redux Store状态获得。

在渲染函数中,根据三种不同的状态,显示出来的内容也不一样。当视图状态为LOADING时,表示一个对天气信息的API请求刚刚发出,还没有结果返回,这时候界面上就显示一个“天气信息请求中…”字样;当视图状态为SUCCESS时,根据状态显示对应的城市和天气信息;当视图状态为FAILURE时,显示“天气信息装载失败”。

这个组件对应的mapStateToProps函数代码如下:

const mapStateTopProps = (state) => {
  const weatherData = state.weather;

  return {
    status: weatherData.status,
    cityName: weatherData.city,
    weather: weatherData.weather,
    lowestTemp: weatherData.temp1,
    highestTemp: weatherData.temp2
  };
}

为了驱动Weather组件的action,我们另外创建一个城市选择器控件CitySelector,CitySelector很简单,也不是这个应用功能的重点,我们只需要它提供一个作为视图的React组件就可以。

在CitySelector组件中,定义了四个城市的代码,代码如下:

const CITY_CODES = {
  '北京': 101010100,
  '上海': 101020100,
  '广州': 101280101,
  '深圳': 101280601
};

CitySelector组件的render函数根据CITY_CODES的定义画出四个城市的选择器,代码如下:

  render() {
    return (
      <select onChange={this.onChange}>
        {
          Object.keys(CITY_CODES).map(
            cityName => <option key={cityName} value={CITY_CODES[cityName]}> {cityName}</option>
          )
        }
      </select>
    );
  }
}

其中使用到的onChange函数使用onSelectCity来派发出action,代码如下:

onChange(ev) {
  const cityCode = ev.target.value;
  this.props.onSelectCity(cityCode)
}

为了让网页初始化的时候就能够获得天气信息,在componentDidMount中派发了对应第一个城市的fetchWeatheraction对象,代码如下:

componentDidMount() {
  const defaultCity = Object.keys(CITY_CODES)[0];
  this.props.onSelectCity(CITY_CODES[defaultCity]);
}
CitySelector的mapDispatchToProps函数提供了名为onSelectCity的函数类型prop,代码如下:
const mapDispatchToProps = (dispatch) => {
  return {
    onSelectCity: (cityCode) => {
      dispatch(weatherActions.fetchWeather(cityCode));
    }
  }
};

这个city_selector提供的视图导入了weather功能组件导出的actions,显示出北京、上海、广州、深圳四个城市的选择器,当用户选中某个城市的时候,就会派发fetchWeather构造器产生的action对象,让Weather组件去服务器获取对应城市的天气信息。

完成全部代码之后,我们在网页中就可以看到最终效果,如图3所示。

图3 天气应用最终效果

当我们选择另一个城市之后,可以看到会有短暂的显示“天气信息请求中…”,然后才显示出对应城市的天气,因为访问服务器总是会有时间延迟。

我们也可以试着关闭命令行上中断npm start这个命令,等于是关闭了代理服务器,这样对API的访问必然失败,然后切换城市,可以看到“天气信息装载失败”。

异步操作的中止

对于访问服务器这样的异步操作,从发起操作到操作结束,都会有段时间延迟,在这段延迟时间中,用户可能希望中止异步操作。

从执行一个fetch语句发出请求,到获得服务器返回的结果,可能很快只有几十毫秒,也可能要花上好几秒甚至十几秒,如果没有超时限制的话,就算是等上几分钟也完全是可能的。也就是说,从一个请求发出到获得响应这个过程中,用户可能等不及了,或者改变主意想要执行另一个操作,用户就会进行一些操作引发新的请求发往服务器,而这就是我们开发者需要考虑的问题。

在weather_redux这个应用中,如果当前城市是“北京”,用户选择了“上海”之后,不等服务器返回,立刻又选择“广州”,那么,最后显示出来的天气是什么呢?

结果是难以预料的,用户的两次选择城市操作,引发了两次API请求,最后结果就看哪个请求先返回结果。假如是关于“上海”的请求先返回结果,界面上就会先显示上海的天气信息,然后关于“广州”的请求返回,界面上又自动更新为广州的天气信息。假如是关于“广州”的请求先返回,关于“上海”的请求后返回,那么结果就正相反,最后显示的是上海的天气信息。此时界面上会出现严重的信息不一致,城市选择器上显示的是“广州”,但是天气信息部分却是“上海”。

两次API的请求顺序是“上海”“广州”,有可能返回的顺序是“广州”“上海”吗?完全可能,访问服务器这样的输入输出操作,复杂就复杂在返回的结果和时间都是不可靠的,即使是访问同样一个服务器,也完全可能先发出的请求后收到结果。

要解决这种界面上显示不一致的问题,一种方法是在视图上做文章,比如当一个API请求发出去,立刻将城市选择器锁住,设为不可改变,直到API请求返回结果才解锁。这种方式虽然可行,但是给用户的体验可能并不好,用户希望随时能够选择城市,而服务器的响应时间完全不可控,锁住城市选择器的时间可能很长,而且这个时间由服务器响应时间决定,不在代码控制范围内,如果服务器要等10秒钟才返回结果,锁住城市选择器的时间就有10秒,这是不可接受的。

从用户角度出发,当连续选择城市的时候,总是希望显示最后一次选中的城市的信息,也就是说,一个更好的办法是在发出API请求的时候,将之前的API请求全部中止作废,这样就保证了获得的有效结果绝对是用户的最后一次选择结果。

在jQuery中,可以通过abort方法取消掉一个AJAX请求:

const xhr = $.ajax(...);

xhr.abort(); //取消掉已经发出的AJAX请求

但是,很不幸,对于fetch没有对应abort函数的功能,因为fetch返回的是一个Promise对象,在ES6的标准中,Promise对象是不存在“中断”这样的概念的。

既然fetch不能帮助我们中止一个API请求,那就只能在应用层实现“中断”的效果,有一个技巧可以解决这个问题,只需要修改action构造函数。

我们对src/weather/actions.js进行一些修改,代码如下:

let nextSeqId = 0;
export const fetchWeather = (cityCode) => {
  return (dispatch) => {
    const apiUrl = `/data/cityinfo/${cityCode}.html`;
    const seqId = ++ nextSeqId;
    const dispatchIfValid = (action) => {
      if (seqId === nextSeqId) {
        return dispatch(action);
      }
    }
    dispatchIfValid(fetchWeatherStarted())
    fetch(apiUrl).then((response) => {
      if (response.status !== 200) {
        throw new Error('Fail to get response with status ' + response.status);
      }
      response.json().then((responseJson) => {
        dispatchIfValid(fetchWeatherSuccess(responseJson.weatherinfo));
      }).catch((error) => {
        dispatchIfValid(fetchWeatherFailure(error));
      });
    }).catch((error) => {
      dispatchIfValid(fetchWeatherFailure(error));
    })
  };
}

在action构造函数文件中定义一个文件模块级的nextSeqId变量,这是一个递增的整数数字,给每一个访问API的请求做序列编号。

在fetchWeather返回的函数中,fetch开始一个异步请求之前,先给nextSeqId自增加一,然后自增的结果赋值给一个局部变量seqId,这个seqId的值就是这一次异步请求的编号,如果随后还有fetchWeather构造器被调用,那么nextSeqId也会自增,新的异步请求会分配为新的seqId。

然后,action构造函数中所有的dispatch函数都被替换为一个新定义的函数dispatchIf-Valid,这个dispatchIfValid函数要检查一下当前环境的seqId是否等同于全局的nextSeqId。如果相同,说明fetchWeather没有被再次调用,就继续使用dispatch函数。如果不相同,说明这期间有新的fetchWeather被调用,也就是有新的访问服务器的请求被发出去了,这时候当前seqId代表的请求就已经过时了,直接丢弃掉,不需要dispatch任何action。

虽然不能真正“中止”一个API请求,但是我们可以用这种方法让一个API请求的结果被忽略,达到了中止一个API请求一样的效果。

在这个例子中Weather模块只有一种API请求,所以一个API调用编号序列就足够,如果需要多种API请求,则需要更多类似nextSeqId的变量来存储调用编号。

拥有异步操作中止功能的代码,在本书Github代码库目录下找到,启动对应的应用,可以看到无论如何选择城市,最终显示的天气信息和选中的城市都是一致的。

Redux异步操作的其他方法

上述的redux-thunk并不是在Redux中处理异步操作的唯一方式,只不过redux-thunk应该是应用最简单,也是最容易被理解的一种方式。

在Redux的社区中,辅助进行异步操作的库还有:

  • redux-saga
  • redux-effects
  • redux-side-effects
  • redux-loop
  • redux-observable

上面列举的只是最负盛名的一些库,并不是完整清单,而且随着更多的解决方法出现,这个列表肯定还将不断增长。

如何挑选异步操作方式

所有这些辅助库,都需要通过一个Redux中间件或者Store Enhancer来实现Redux对异步操作的支持,每一个库都足够写一本书出来讲解,所以没法在这里一一详细介绍,在这里我们只是列出一些要点,帮助读者研究让Redux支持异步操作的库时需要考虑哪些方面。

第一,在Redux的单向数据流中,什么时机插入异步操作?

Redux的数据流转完全靠action来驱动,图2显示了数据流转的过程,对于redux- thunk,切入异步操作的时机是在中间件中,但是这不是唯一的位置。

通过定制化Store Enhancer,可以在action派发路径上任何一个位置插入异步操作,甚至作为纯函数的reducer都可以帮助实现异步操作。异步操作本身就是一种副作用,reducer的执行过程当然不应该产生异步操作,但是reducer函数的返回值却可以包含对异步操作的“指示”。也就是说,reducer返回的结果可以用纯数据的方式表示需要发起一个对服务器资源的访问,由reducer的调用者去真正执行这个访问服务器资源的操作,这样不违背reducer是一个纯函数的原则,在redux-effects中使用的就是这种方法。

很遗憾,很多库的文档并没有解释清楚自己切入异步操作的位置,这就容易导致很多误解,需要开发者自己去发掘内在机制。只有确定了切入异步操作的位置,才能了解整个流程,不会犯错。

第二,对应库的大小如何?

有的库看起来功能很强大,单独一个库就有几十KB大小的体积,比如redux-saga,发布的最小化代码有25KB,经过gzip压缩之后也有7KB,要知道React本身被压缩之后也不过是45KB大小。

不同的应用对JavaScript的体积有不同的要求。比如,对于视频类网站,观看视频本来就要求访问者的网络带宽比较优良,那多出来的这些代码大小就不会有什么影响。但是对于一些预期会在网络环境比较差的情况下访问的网站,可能就需要计较一下是否值得引入这些库。

第三,学习曲线是不是太陡?

所有这些库都涉及一些概念和背景知识,导致学习曲线比较陡,比如redux-saga要求开发者能够理解ES6的async和await语法,redux-observable是基于Rx.js库开发的,要求开发者已经掌握响应式编程的技巧。

如果一个应用只有一个简单的API请求,而且使用redux-thunk就能够轻松解决问题,那么选择一个需要较陡学习曲线的辅助库就显得并不是很恰当;但是如果应用中包含大量的API请求,而且每个请求之间还存在复杂的依赖关系,这时候也许就是考虑使用某个辅助库的时机。

切记,软件开发是团队活动,选用某种技术的时候,不光要看自己能不能接受,还也要考虑团队中其他伙伴是否容易接受这种技术。毕竟,软件开发的终极目的是满足产品需求,不要在追逐看似更酷更炫的技术中迷失了初心。

第四,是否会和其他Redux库冲突?

所有这些库都是以Redux中间件或者Redux Store Enhancer的形态出现,在用Redux的createStore创建store实例时,可能会组合多个中间件和多个Store Enhancer,在Store这个游戏场上,不同的玩家之间可能会发生冲突。

总之,使用任何一个库在Redux中实现异步操作,都需要多方面的考虑,到目前为止,业界都没有一个公认的最佳方法。

相对而言,虽然redux-thunk容易产生代码臃肿的问题,但真的是简单又易用,库也不大,只有几行代码而已,在第9章中我们会详细介绍redux-thunk的实现细节。

利用Promise实现异步操作

除了redux-thunk,还有另一种异步模式,将Promise作为特殊处理的异步action对象,这种方案比redux-thunk更加易用,复杂度也不高。

fetch函数返回的结果也是一个Promise对象,用Promise来连接访问API操作和Redux,是天作之合。

不过,对于Promise在Redux中应该如何使用,也没有形成统一观点,相关的库也很多,但是都很简单,用一个Redux中间件就足够实现:

  • redux-promise
  • redux-promises(名字只比上面的多了一个表示复数的s)
  • redux-simple-promise
  • redux-promise-middleware

同样,这样一个清单可能也会不断增长,所以我们也不逐一介绍。

小结

在这一章中我们介绍了一个网页应用必须具备的功能,通过API访问获取服务器数据资源。

无论是从服务器获取数据,还是向服务器提交数据,都是一个异步的过程。在一个React组件中,我们可以利用componentDidMount,在装载过程结束时发起对服务器的请求来获取数据填充组件内容。

在一个Redux应用中,状态要尽量存在Redux的Store上,所以单个React组件访问服务器的方案就不适用了,这时候我们需要在Redux中实现异步操作。最简单直接的方法是使用redux-thunk这个中间件,但是也有其他的库作为选择,每种方案都有其优缺点,开发者要了解权衡决定哪种库适合自己的应用。

如果觉得本文对您有用,欢迎转发至朋友圈。截止到6月19日17:00,集赞最多的前两位同学将免费获得程墨老师的新书《深入浅出React和Redux》一本,还包邮噢。朋友圈截图请私信给小编(微信:Rachel_qg)。


2017年7月8日(星期六),「“前端开发创新实践”线上峰会」将在 CSDN 学院召开。本次峰会集结来自Smashing Magazine、美国Hulu、美团、广发证券、去哪儿网、百度的多位国内外知名前端开发专家、资深架构师,主题涵盖响应式布局、Redux、Mobx、状态管理、构建方案、代码复用、个性化图表定制度等前端开发重难点技术话题。技术解析加项目实战,帮你开拓解决问题的思路,增强技术探索实践能力。目前火热报名中,5折票价最后一周,欲购从速,详情点击注册参会

图片描述

评论