使用React,Redux,redux-sage构建图片库(翻译)

看到这篇文章build an image gallery using redux saga,以为写的不错,长短也适中. 文后有注释版的github代码库,请使用comment分枝. Flickr API可能须要有fQ的基本能力.可使用google的翻译做为参考,这篇文章google翻译版的中文水平让我吃了一惊. 翻译已经完成.javascript

使用React,Redux和reudx-saga构建一个图像浏览程序(翻译)

Joel Hooks ,2016年3月php

构建一个图片长廊

图像长廊是一个简单的程序,从Flicker API 加载图片URLs,容许用户查看图片详情。css

Screen Shot 2016-03-20 at 3.42.17 PM-2.png

后续咱们会使用React,Redux和redux-saga.React做为核心框架,优点是虚拟dom(virtual-dom)的实现。Redux在程序内负责state的管理。最后,咱们会使用redux-saga来执行javascript的异步操做步骤。html

咱们会使用ES6(箭头函数,模块,和模板字符串),因此咱们首先须要作一些项目的配置工做。java

#####项目配置和自动化node


若是要开始一个React项目,须有有一系列的配置选项。对于一个简单的项目,我想把配置选项尽量缩减。考虑到浏览器的版本问题,使用Babel把ES6编译为ES5。react

首先使用npm init 建立一个package.json文件git

package.json程序员

{
  "name": "egghead-react-redux-image-gallery",
  "version": "0.0.1",
  "description": "Redux Saga beginner tutorial",
  "main": "src/main.js",
  "scripts": {
    "test": "babel-node ./src/saga.spec.js | tap-spec",
    "start": "budo ./src/main.js:build.js --dir ./src --verbose --live -- -t babelify"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/joelhooks/egghead-react-redux-image-gallery.git"
  },
  "author": "Joel Hooks <joelhooks@gmail.com>",
  "license": "MIT",
  "dependencies": {
    "babel-polyfill": "6.3.14",
    "react": "^0.14.3",
    "react-dom": "^0.14.3",
    "react-redux": "^4.4.1",
    "redux": "^3.3.1",
    "redux-saga": "^0.8.0"
  },
  "devDependencies": {
    "babel-cli": "^6.1.18",
    "babel-core": "6.4.0",
    "babel-preset-es2015": "^6.1.18",
    "babel-preset-react": "^6.1.18",
    "babel-preset-stage-2": "^6.1.18",
    "babelify": "^7.2.0",
    "browserify": "^13.0.0",
    "budo": "^8.0.4",
    "tap-spec": "^4.1.1",
    "tape": "^4.2.2"
  }
}
复制代码

有了package.json, 能够在项目文件夹命令行运行 npm install 安装程序须要的依赖项。github

.babelrc

{
  "presets": ["es2015", "react", "stage-2"]
   } 
复制代码

这个文件告诉babel,咱们将会使用ES2015(ES6),React以及ES2106的stage-2的一些特征。

package.json有两个标准的script脚本配置:starttest.如今咱们想经过start脚本加载程序,start会使用src目录的一些文件,因此西药先建立src文件夹.在src文件夹添加下面的一些文:

index.html

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>egghead: React Redux Image Gallery</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="title">
  ![](http://cloud.egghead.io/2G021h3t2K10/download/egghead-logo-head-only.svg)
  <h3>Egghead Image Gallery</h3>
</div>

<div id="root"></div>

<script type="text/javascript" src="build.js"></script>
</body>
</html>
 
复制代码

main.js

import "babel-polyfill"

import React from 'react'
import ReactDOM from 'react-dom'

ReactDOM.render(
  <h1>Hello React!</h1>,
  document.getElementById('root')
);
复制代码

style.css

body {
    font-family: Helvetica, Arial, Sans-Serif, sans-serif;
    background: white;
}

.title {
    display: flex;
    padding: 2px;
}

.egghead {
    width: 30px;
    padding: 5px;
}

.image-gallery {
    width: 300px;
    display: flex;
    flex-direction: column;
    border: 1px solid darkgray;
}

.gallery-image {
    height: 250px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.gallery-image img {
    width: 100%;
    max-height: 250px;
}

.image-scroller {
    display: flex;
    justify-content: space-around;
    overflow: auto;
    overflow-y: hidden;
}

.image-scroller img {
    width: 50px;
    height: 50px;
    padding: 1px;
    border: 1px solid black;
}

复制代码

index.html文件加载style.css文件提供一些基本的布局样式,同时也加载build.js文件,这是一个生成出来的文件.main.js是一个最基础的React程序,他在index.html#root元素中渲染一个h1元素。建立这些文件之后,在项目文件夹中命令行运行npm start。在浏览器打开http://10.11.12.1:9966.就能够看到index.html中渲染的页面

运行加载图

如今咱们来构建基础的Gallery React 组件

在Gallery中显示一些图片

首先咱们须要尽量快的得到一个能够显示的图片素材.在项目文件夹中建立一个文件Gallery.js

Gallery.js

import React, {Component} from 'react'

const flickrImages = [
  "https://farm2.staticflickr.com/1553/25266806624_fdd55cecbc.jpg",
  "https://farm2.staticflickr.com/1581/25283151224_50f8da511e.jpg",
  "https://farm2.staticflickr.com/1653/25265109363_f204ea7b54.jpg",
  "https://farm2.staticflickr.com/1571/25911417225_a74c8041b0.jpg",
  "https://farm2.staticflickr.com/1450/25888412766_44745cbca3.jpg"
];

export default class Gallery extends Component {
  constructor(props) {
    super(props);
    this.state = {
      images: flickrImages,
      selectedImage: flickrImages[0]
    }
  }
  render() {
    const {images, selectedImage} = this.state;
    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
            <div key={index}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}
复制代码

咱们直接在组件中硬编码了一个提供数据的数组,让项目尽快的工做起来.Gallery组件继承Component组件,在构造函数中建立一些组件的出事状态.最后咱们利用一些样式标记渲染一下文件。image-scroller元素遍历(map方法)图片数组,生成摘要小图片。

import "babel-polyfill"

import React from 'react'
import ReactDOM from 'react-dom'

+ import Gallery from './Gallery'

ReactDOM.render(
-  <h1>Hello React!</h1>,
+  <Gallery />,
  document.getElementById('root')
);
复制代码

到如今,咱们使用硬编码的图片URLs(经过fickrImages)数组,第一张图片做为selectedImage.这些属性在Gallery组件的构造函数缺省配置中,经过初始状态(initial)来设定.

接下来在组件中添加一个和组件进行交互操做的方法,方法具体内容是操作setSate. Gallery.js

export default class Gallery extends Component {
  constructor(props) {
    super(props);
    this.state = {
      images: flickrImages,
      selectedImage: flickrImages[0]
    }
  }
+  handleThumbClick(selectedImage) {
+    this.setState({
+      selectedImage
+   })
+  }
  render() {
    const {images, selectedImage} = this.state;
    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
-            <div key={index}>
+            <div key={index} onClick={this.handleThumbClick.bind(this,image)}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}
复制代码

Gallery组件添加handleThumbClick方法,任何元素均可用经过onClick属性调用这个方法.image做为第二个参数传递,元素自身做为第一个参数传递.bind方法传递javascript函数调用上下文对象是很是便捷。

看起来不错!如今咱们有了一些交互操做的方法,有点“APP”的意思了。截止目前,咱们已经让app运行起来了,接下来要考虑怎么加载远程数据。最容易加载远程数据的地方是一个React组件生命周期方法,咱们使用componentDidMount方法,经过他从Flikr API请求并加载一些图片.

Gallery.js

export default class Gallery extends Component {
  constructor(props) {
    super(props);
    this.state = {
      images: flickrImages,
      selectedImage: flickrImages[0]
    }
  }
+  componentDidMount() {
+    const API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5';
+    const API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.interestingness.+getList&api_key=${API_KEY}&format=json&nojsoncallback=1&per_page=5`;+
+
+    fetch(API_ENDPOINT).then((response) => {
+      return response.json().then((json) => {
+        const images = json.photos.photo.map(({farm, server, id, secret}) => { 
+            return `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg`
+        });
+
+        this.setState({images, selectedImage: images[0]});
+      })
+    })
+  }
[...]
复制代码

咱们在Gallery类中添加了一个新的方法,经过React的componentDidMount生命周期方法触发Flickr图片数据的获取。

React组件运行的不一样时间点,组件会调用不一样的生命周期函数。在这段代码中,当组件被渲染到DOM中的时间点,componentDidMount函数就会被调用。须要注意的是:Gallery组件只有一次渲染到DOM的机会,因此这个函数能够提供一些初始化图片.考虑到在APP的整个生命周期中,有更多的动态组件的加载和卸载,这可能会形成一些多余的调用和没法考虑到的结果。

咱们使用浏览器接口(browser API)的fetch方法执行请求.Fetch返回一个promise对象解析response对象.调用response.json()方法,返回另外一个promise对象,这就是咱们实际须要的json格式的数据.遍历这个对象之后就能够获取图片的url地址.

坦白讲,这个应用目前还很简单.咱们还须要在这里花费更多的时间,还有一些基础的需求须要完成.或许咱们应该在promise处理流程中添加错误处理方法,若是图片数据获取成功也须要一些处理逻辑.在这个地方,你须要发挥一些想象力考虑一下更多的逻辑.在生产实践中简单的需求是不多见的.很快,应用中就会添加更多的需求。认证,滚动橱窗,加载不一样图片库的能力和图片的设置等等.仅仅这些还远远不够.

咱们已经使用React构建了一个加载图片库的程序。接下来咱们须要考虑到随着程序功能的添加,到底须要哪些基础的模式.首先考虑到的一个问题就是要把应用的状态(state)控制从Gallery组件中分离出来.

咱们经过引入Redux来完成应用的状态管理工做。

使用Redux来管理状态

在你的应用中只要使用了setState方法都会让一个组件从无状态变为有状态的组件.糟糕的是这个方法会致使应用中出现一些使人困惑的代码,这些代码会在应用中处处蔓延。

Flux构架来减轻这个问题.Flux把逻辑(logic)和状态(state)迁移到Store中.应用中的动做(Actions)被Dispatch的时候,Stores 会作相应的更新.Stores的更新会触发View根据新状态的渲染.

那么咱们为何要舍弃Flux?他居然仍是“官方”构建的. 好吧!Redux是基于Flux构架的,可是他有一些独特的优点.下面是Dan Abramov(Redux建立者)的一些话:

Redux和Flux没有什么不一样.整体来说他们是相同的构架,可是Redux经过功能组合把Flux使用回调注册的复杂点给屏蔽掉了. 两个构架从更本上讲没有什么不一样,可是我发现Redux使一些在Flux比较难实现的逻辑更容易实现.

Redux文档很是棒. 若是你尚未读过代码的卡通教程或者Dan的系列文章.赶快去看看吧!

启动Redux

第一件须要作的事事初始化Redux,让他在咱们的程序中运行起来.如今不须要作安装工做,刚开始运行npm install的时候已经安装好了依赖项,咱们须要作一些导入和配置工做. reducer函数是Redux的大脑. 每当应用分发(或派遣,dispatch)一个操做(action)的时候,reducer函数会接受操做(action)而且依据这个动做(action)建立reducer本身的state.由于reducers是纯函数,他们能够组合到一块儿,建立应用的一个完整state.让咱们在src中建立一个简单的reducer:

reducer.js

export default function images(state, action) {
      console.log(state, action)
      return state;
   }
复制代码

一个reducer函数接受两个参数(arguments).

  1. [x] state-这个数据表明应用的状态(state).reducer函数使用这个状态来构建一个reducer本身能够管理的状态.若是状态没有发生改变,reducer会返回输入的状态.
  2. [x] action-这是触发reducer的事件.Actions经过store派发(dispatch),由reducer处理.action须要一个type属性来告诉reducer怎么处理state.

目前,images reuducer在终端中打印出日志记录,代表工做流程是正常的,能够作接下来的工做了.为了使用reducer,须要在main.js中作一些配置工做:

main.js

import "babel-polyfill";

import React from 'react';
import ReactDOM from 'react-dom';

import Gallery from './Gallery';

+ import { createStore } from 'redux'
+ import reducer from './reducer'

+ const store = createStore(reducer);

+ import {Provider} from 'react-redux';

ReactDOM.render(
+  <Provider store={store}>
    <Gallery />
+  </Provider>,
  document.getElementById('root')
);
}
复制代码

咱们从Redux库中导入createStore组件.creatStore用来建立Redux的store.大多数状况下,咱们不会和store直接交互,store在Redux中作幕后管理工做.

也须要导入刚才建立的reducer函数,以便于他能够被发送到store. 咱们将经过createStore(reducer)操做,利用reducer来配置应用的store.这个示例仅仅只有一个reducer,可是createStore能够接收多个reducer做为参数.稍后咱们会看到这一点.

最后咱们导入高度集成化的组件Provider,这个组件用来包装Gallery,以便于咱们在应用中使用Redux.咱们须要把刚刚建立的store传递给Provider.你也能够不使用Provider,实际上Redux能够不须要React.可是咱们将会使用Provider,由于他很是便于使用.

打印日志

这张图可能有点古怪,可是展现了Redux的一个有意思的地方.全部的reducers接收在应用中的所有actions(动做或操做).在这个例子中咱们能够看到Redux本身派发的一个action.

链接Gallery组件

借助Redux,咱们将使用”connected”和“un-connected”组件.一个connected组件被连线到store.connected组件使控制动做事件(controls action event)和store协做起来.一般,一个connected组件有子组件,子组件具备单纯的接收输入和渲染功能,当数据更新时执行调用.这个子组件就是unconnected组件.

提示:当Rect和Redux配合是工做的很是好,可是Redux不是非要和React在一块儿才能工做.没有React,Redux其实能够和其余框架配合使用.

在应用中须要关联React组件Redux Store 的时候,react-redux提供了便捷的包装器.咱们把react-redux添加进Gallery中 ,从而使Gallery成为首要的关联组件.

Gallery.js

import React, {Component} from 'react'
+import {connect} from 'react-redux';

-export default class Gallery extends Component {
+export class Gallery extends Component {
  constructor(props) {
    super(props);
+    console.log(props);
    this.state = {
      images: []
    }
  }
  componentDidMount() {
    const API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5';
    const API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.interestingness.getList&api_key=${API_KEY}&format=json&nojsoncallback=1&per_page=5`;

    fetch(API_ENDPOINT).then((response) => {
      return response.json().then((json) => {
        const images = json.photos.photo.map(({farm, server, id, secret}) => {
            return `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg`
        });

        this.setState({images, selectedImage: images[0]});
      })
    })
  }
  handleThumbClick(selectedImage) {
    this.setState({
      selectedImage
    })
  }
  render() {
    const {images, selectedImage} = this.state;
    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
            <div key={index} onClick={this.handleThumbClick.bind(this,image)}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}

+export default connect()(Gallery)
复制代码

react-redux导入connect函数,能够在导出组件的时候把他变为连接组件(connected component).请注意,connect()(Gallery)代码把Gallery组件放在第二个形参中,这是由于connect()返回一个函数,这个函数接受一个React组件做为参数(argument).调用connect()函数时须要配置项.后面咱们将会传递配置咱们应用的actions和state参数. 咱们也把connect做为默认配置处处模块.这一点很是重要!如今当咱们import Gallery的时候,就不是一个单纯的React组件了,而是一个和Redux关联的组件了.

若是你观察咱们添加进构造器的console.log的输出,就能够看到Gallery组件的属性如今包括了一个dispatch函数.这个地方是connect为咱们的应用修改的,这个改动赋予了组件把本身的动做对象(action objects)派发reducers的能力.

export class Gallery extends Component {
  constructor(props) {
    super(props);
+    this.props.dispatch({type: 'TEST'});
    this.state = {
      images: []
    }
  }
[...]
复制代码

咱们能够在组件的构造器中调用派发功能.你能够在开发者的终端中看到来自reducer的日志声明.看到声明表示咱们已经派发了第一个action!.Actions是一个单一的javascript对象,必需有type属性.Actions能够拥有任意数量和种类的其余属性.可是type可让reducers理解这些动做究竟是作什么用的(意译,意思是只有拥有type属性,reducers才知道对state作什么样的修改).

export default function images(state, action) {
-  console.log(state, action)
+  switch(action.type) {
+    case 'TEST':
+      console.log('THIS IS ONLY A TEST')
+  }
  return state;
}
复制代码

总的reducers使用switch代码块过滤有关的消息,Switch语句使用actions的type属性,当一个actioncase分支吻合之后,相应的单个reducer就会执行他的具体工做.

咱们的应用如今关联到接收的动做.如今咱们须要把Redux-Store提供的state关联到应用中.

默认的应用状态(state)

reducer.js

const defaultState = {
  images: []
}

export default function images(state = defaultState, action) {
  switch(action.type) {
    case 'TEST':
-      console.log('THIS IS ONLY A TEST')
+      console.log(state, action)
+      return state;
+    default:
+      return state;
  }
-  return state;
}
 
复制代码

咱们建立一个defaultState对象,这个对象返回一个空数组做为images的属性.咱们把images函数的参数state设置为默认.若是在test分支中输出日志,将会看到state不是undefined(空数组不是undefined)!reducer须要返回应用的当前state.这点很重要!如今咱们没有作任何改变,因此仅仅返回state.注意咱们在case中添加了default分支,reducer必需要返回一个state.

Gallery组件中,咱们也能够把state作必定的映射(map)之后再链接到应用.

import React, {Component} from 'react'
import {connect} from 'react-redux';

export class Gallery extends Component {
  constructor(props) {
    super(props);
    this.props.dispatch({type: 'TEST'});
+    console.log(props);
-    this.state = {
-      images: []
-    }
  }
-  componentDidMount() {
-    const API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5';
-    const API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.interestingness.-getList&api_key=${API_KEY}&format=json&nojsoncallback=1&per_page=5`;-
-
-    fetch(API_ENDPOINT).then((response) => {
-      return response.json().then((json) => {
-        const images = json.photos.photo.map(({farm, server, id, secret}) => { 
-            return `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg`
-        });
-
-        this.setState({images, selectedImage: images[0]});
-      })
-    })
-  }
-  handleThumbClick(selectedImage) {
-    this.setState({
-      selectedImage
-    })
-  }
  render() {
-    const {images, selectedImage} = this.state;
+    const {images, selectedImage} = this.props;
    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
-            <div key={index} onClick={this.handleThumbClick.bind(this,image)}>
+            <div key={index}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}

+function mapStateToProps(state) {
+  return {
+    images: state.images
+    selectedImage: state.selectedImage
+  }
+}

-export default connect()(Gallery)
+export default connect(mapStateToProps)(Gallery)

复制代码

咱们将移除链接组件中的全部图片加载和交互逻辑代码,若是你注意看Gallery组件的底部代码,你会注意到,咱们建立了一个mapStateToProps函数,接收一个state做为参数,返回一个对象,把state.images映射为images属性.mapStateToProps作为参数传递给connect. 正如名字暗示的同样,mapStateToProps函数接收当前应用的state,而后把state转变为组件的属性(propertys).若是在构造器中输出props,将会看到images数组是reducer返回的默认state.

const defaultState = {
-  images: []
+  images: [
+    "https://farm2.staticflickr.com/1553/25266806624_fdd55cecbc.jpg",
+    "https://farm2.staticflickr.com/1581/25283151224_50f8da511e.jpg",
+    "https://farm2.staticflickr.com/1653/25265109363_f204ea7b54.jpg",
+    "https://farm2.staticflickr.com/1571/25911417225_a74c8041b0.jpg",
+    "https://farm2.staticflickr.com/1450/25888412766_44745cbca3.jpg"
+  ],
+  selectedImage: "https://farm2.staticflickr.com/1553/25266806624_fdd55cecbc.jpg"
}

export default function images(state = defaultState, action) {
  switch(action.type) {
    case 'TEST':
      console.log(state, action)
      return state;
    default:
      return state;
  }
}
复制代码

若是在defaultState中更新images数组,你将能够看到一些图片从新出如今gallery中!如今当用户点击缩略图的时候,咱们能够反馈选择动做,返回对应的大图.

更新state

怎么操做才能根据新选择的图片更新state? 须要配置reducer监听IMAGE_SELECTED动做,借助action携带的信息(payload,有的文章翻译为载荷,载荷怎么理解?手机载荷就是声音,短信和流量数据。若是是卡车就是拉的货物,若是是客车就乘载的乘客,action的载荷就是要让reducer明白你要干什么,须要什么)来更新state.

const defaultState = {
  images: [
    "https://farm2.staticflickr.com/1553/25266806624_fdd55cecbc.jpg",
    "https://farm2.staticflickr.com/1581/25283151224_50f8da511e.jpg",
    "https://farm2.staticflickr.com/1653/25265109363_f204ea7b54.jpg",
    "https://farm2.staticflickr.com/1571/25911417225_a74c8041b0.jpg",
    "https://farm2.staticflickr.com/1450/25888412766_44745cbca3.jpg"
  ],
  selectedImage: "https://farm2.staticflickr.com/1553/25266806624_fdd55cecbc.jpg"
}

export default function images(state = defaultState, action) {
  switch(action.type) {
-    case 'TEST':
    case 'IMAGE_SELECTED':
-      return state;
+      return {...state, selectedImage: action.image};
    default:
      return state;
  }
}
复制代码

如今reducer已经准备接收IMAGE_SELECTED action了.在IMAGE_SELECTED分支选项内,咱们在展开(spreading,ES6的对象操做方法),并重写selectedImage属性后,返回一个新state对象.了解更多的...state对象操做能够看ruanyifeng的书.

import React, {Component} from 'react'
import {connect} from 'react-redux';

export class Gallery extends Component {
-  constructor(props) {
-    super(props);
-    this.props.dispatch({type: 'TEST'});
-    console.log(props);
-  }
  render() {
-    const {images, selectedImage} = this.props;
+    const {images, selectedImage, dispatch} = this.props;

    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
-            <div key={index}>
+            <div key={index} onClick={() => dispatch({type:'IMAGE_SELECTED', image})}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}

function mapStateToProps(state) {
  return {
    images: state.images,
    selectedImage: state.selectedImage
  }
}

export default connect(mapStateToProps)(Gallery)
复制代码

Gallery组件中,咱们将会在组件的属性中定义dispatchonClick函数体中调用他,如今咱们从便利角度考虑把他们放在一块儿,可是二者功能是同样的.一旦咱们点击了缩略图,他将会经过reducer更新大图. 使用dispatch能够很方便的建立通用actions,可是很快咱们会须要重用命名好的actions.为了这样作,可使用”action creators”.

Action Creators

Action creators函数返回配置好的action对象.咱们在action.js中添加第一个action creator.

action.js

export const IMAGE_SELECTED = 'IMAGE_SELECTED';

export function selectImage(image) {
  return {
    type: IMAGE_SELECTED,
    image
  }
}
复制代码

这个方法通过export之后,能够直接在任何须要建立selectImage action地方导入!selectImage是纯函数,只能返回数据.他接收一个image做为参数,把image添加到action对象中,并返回.

注意:咱们正在返回一个单纯的javascript object,可是image的属性可能很古怪,若是你之前没有碰到这样的样式.从ES6的角度出发,若是你给一个对象传递一个相似这样的属性,隐含的意思是把image:'任何image包含的值'添加到最终返回的对象.超级好用!

import  * as GalleryActions from './actions.js';
[...]
onClick={() => dispatch(GalleryActions.selectImage(image))}
复制代码

this isn’t much than just using dispatch though.

幸运的是,这个模式很广泛,Redux在bindActionCreators函数里提供了一个更好的办法来完成这个功能.

import React, {Component} from 'react'
import {connect} from 'react-redux';
+ import {bindActionCreators} from 'redux';

+ import  * as GalleryActions from './actions.js';

export class Gallery extends Component {
  constructor(props) {
    super(props);
    this.props.dispatch({type: 'TEST'});
    console.log(props);
  }
  handleThumbClick(selectedImage) {
    this.setState({
      selectedImage
    })
  }
  render() {
-    const {images, selectedImage, dispatch} = this.props;
+    const {images, selectedImage, selectImage} = this.props;
    return (
      <div className="image-gallery">
        <div className="gallery-image">
          <div>
            <img src={selectedImage} />
          </div>
        </div>
        <div className="image-scroller">
          {images.map((image, index) => (
-            <div key={index} onClick={() => dispatch({type:'IMAGE_SELECTED', image})}>
+            <div key={index} onClick={() => selectImage(image)}>
              <img src={image}/>
            </div>
          ))}
        </div>
      </div>
    )
  }
}

function mapStateToProps(state) {
  return {
    images: state.images,
    selectedImage: state.selectedImage
  }
}

+function mapActionCreatorsToProps(dispatch) {
+  return bindActionCreators(GalleryActions, dispatch);
+}

-export default connect(mapStateToProps)(Gallery)
+export default connect(mapStateToProps, mapActionCreatorsToProps)(Gallery)
复制代码

咱们已经添加了mapActionCreatorsToProps函数,他接收dispatch函数做为参数.返回bindActionCreators的调用结果,GalleryActions做为bindActionCreators的参数.如今若是你输出属性日志,就看不到dispatch做为参数,selectImage直接可使用了.(这里至关于对dispatch和action进行了包装).

如今回顾一下,咱们作了几件事:

  • 建立了一个reducer包含应用的默认初始状态(initial state),而且监听actions的执行.
  • 建立了一个store,把reducer具体化,提供一个分发器(dispatcher)能够分发action.
  • 把咱们的Gallery组件关联到store的state.
  • 把store的state映射为属性(property),传递给Gallery.
  • 映射一个动做建立器,Gallery能够简单的调用selectImage(image),分发动做,应用状态将会更新.

那么,咱们怎么才能使用这些模式从远程资源加载数据呢?

这个过程将会很是有趣!

异步活动?

你可能在参加函数式编程的时候据说过”反作用”(side effects)这个名词,side effects是发生在应用的范围以外的东西.在咱们温馨的肥皂泡里,side effect根本不是问题,可是当咱们要到达一个远程资源,肥皂泡就被穿透了.有些事情咱们就控制不了了,咱们必须接受这个事实.(根据这段话,side effect 翻译为意想不到的事情,出乎意料的不受控制的事情更好)

在Redux里,reducer没有Side effects.这意味着reducers不处理咱们应用中的异步活动.咱们不能使用reducers加载远程数据,由于reducers是纯函数,没有side effects.

Redux很棒,若是你的应用里没有任何异步活动,你能够停下来,不用再往下看了. 若是你建立的应用比较大,可能你会从服务端加载数据,这时,固然要使用异步方式.

注意: Redux其中一个最酷的地方是他很是小巧.他试图解决有限范围内的问题.大多数的应用须要解决不少问题!万幸,Reduc提供中间件概念,中间件存在于action->reducer->store的三角关系中,经过中间件的方式,能够导入诸如远程数据异步加载相似的功能.

其中一个方法是使用thunks对象,在Redux中有 redux-thunk 中间件.Thunks很是厉害,可是可能会致使actions的序列很复杂,测试起来也是很大的挑战.

考虑到咱们的 图片浏览程序.当应用加载是,须要作:

  • 从服务器请求图片数组
  • 当图片加载完毕,显示提示消息
  • 当远程数据返回之后,选择初始图片显示
  • 处理可能出现的错误

这些事件都要在用户点击应用里的任何元素以前完成! 咱们该怎么作呢? redux-saga就是为此而诞生,为咱们的应用提供绝佳的服务.

redux-sage

redux-sage能够在Redux应用中操做异步actions.他提供中间件和趁手的方法使构建复杂的异步操做流程垂手可得.

一个saga是一个Generator(生成器),Generator函数是ES2015新添加的特性.多是你第一次遇到Generator函数,这样你会以为有点古怪,能够参考(ruanyifeng文章).不要苦恼,若是你对此仍然很抓耳挠腮.使用redux-sage你不须要javascript异步编程的博士学位.

由于使用了generators的缘故,咱们能建立一个顺序执行的命令序列,用来描述复杂的异步操做流程(workflows).整个图片的加载流程序列以下:

export function* loadImages() {
  try {
    const images = yield call(fetchImages);
    yield put({type: 'IMAGES_LOADED', images})
    yield put({type: 'IMAGE_SELECTED', image: images[0]})
  } catch(error) {
    yield put({type: 'IMAGE_LOAD_FAILURE', error})
  }
}

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
    yield call(loadImages);
  }
}  
复制代码

第一个saga

咱们将开始一个简单的saga实例,而后配置他链接到咱们的应用.在src建立一个文件 saga.js

export function* sayHello() {
  console.log('hello');
}
复制代码

咱们的saga是一个简单的generator函数.函数后面的*做为标志,他也被叫作”super star”.

如今在main.js文件中导入新函数,而且执行他.

import "babel-polyfill";

import React from 'react';
import ReactDOM from 'react-dom';

import Gallery from './Gallery';

import { createStore } from 'redux'
import {Provider} from 'react-redux';
import reducer from './reducer'

+import {sayHello} from './sagas';
+sayHello();

const store = createStore(reducer);

ReactDOM.render(
<Provider store={store}>
  <Gallery />
</Provider>,
document.getElementById('root')
);
复制代码

无论你盯住终端多长时间,“hello”永远不会出现. 这是由于sayHello是一个generator!Generator 不会当即执行.若是你把代码该为sayHello().next();你的“hello”就出现了.不用担忧,咱们不会老是调用next.正如Redux,redux-saga用来消除应用开发中的痛苦.

配置 redux-sage

import "babel-polyfill";

import React from 'react';
import ReactDOM from 'react-dom';

import Gallery from './Gallery';

-import { createStore } from 'redux'
+import { createStore, applyMiddleware } from 'redux'
+import createSagaMiddleware from 'redux-saga'
import {Provider} from 'react-redux';
import reducer from './reducer'

import {sayHello} from './sagas';
-sayHello()

-const store = createStore(reducer);
+const store = createStore(
+  reducer,
+  applyMiddleware(createSagaMiddleware(sayHello))
+);

ReactDOM.render(
  <Provider store={store}>
    <Gallery />
  </Provider>,
  document.getElementById('root')
);
复制代码

咱们已从Redux导入了applyMiddleware函数.从redux-saga导入createSagaMiddleware函数.当咱们建立store的时候,咱们须要经过中间件提供Redux须要的功能.在这个实例中,咱们会调用applyMiddleware函数,这个函数返回createSagaMiddleware(sayHello)的结果.在幕后,redux-saga加载sayHello函数,仪式性的调用next函数.

应该能够在终端中看到提示消息了. 如今让咱们构建加载图片的saga

经过Saga加载图片数据

咱们将删除出sayHello saga,使用loadImages saga

-export function* sayHello() {
-  console.log('hello');
-}

+export function* loadImages() {
+  console.log('load some images please')
+}
复制代码

不要忘了更新main.js

import "babel-polyfill";

import React from 'react';
import ReactDOM from 'react-dom';

import Gallery from './Gallery';

import { createStore, applyMiddleware } from 'redux'
import {Provider} from 'react-redux';
import createSagaMiddleware from 'redux-saga'
import reducer from './reducer'

-import {sayHello} from './sagas';
+import {loadImages} from './sagas';

const store = createStore(
  reducer,
-  applyMiddleware(createSagaMiddleware(sayHello))
+  applyMiddleware(createSagaMiddleware(loadImages))
);

ReactDOM.render(
  <Provider store={store}>
    <Gallery />
  </Provider>,
  document.getElementById('root')
);
复制代码

如今saga已经加载,在saga.js中添加fetchImages方法

const API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5';
const API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.interestingness.getList&api_key=${API_KEY}&format=json&nojsoncallback=1&per_page=5`;

const fetchImages = () => {
  return fetch(API_ENDPOINT).then(function (response) {
    return response.json().then(function (json) {
      return json.photos.photo.map(
        ({farm, server, id, secret}) => `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg`
      );
    })
  })
};

export function* loadImages() {
  const images = yield fetchImages();
  console.log(images)
}
复制代码

fetchImages方法返回一个promise对象.咱们将调用fetchImages,可是如今咱们要使用yield关键字.经过黑暗艺术和巫术,generators理解Promise对象,正如终端输出的日志显示,咱们已经收获了一个图片URLs的数组.看看loadImages的代码,他看起来像是典型的同步操做代码.yield关键字是秘制调味酱,让咱们的代码用同步格式执行异步操做活动.

封装咱们的异步API请求.

首先来定义一下须要使用的api.他没有什么特殊的地方,实际上他和早先加载Flickr images的代码是相同的.咱们建立flickr.js文件

const API_KEY = 'a46a979f39c49975dbdd23b378e6d3d5';
const API_ENDPOINT = `https://api.flickr.com/services/rest/?method=flickr.interestingness.getList&api_key=${API_KEY}&format=json&nojsoncallback=1&per_page=5`;

export const fetchImages = () => {
  return fetch(API_ENDPOINT).then(function (response) {
    return response.json().then(function (json) {
      return json.photos.photo.map(
        ({farm, server, id, secret}) => `https://farm${farm}.staticflickr.com/${server}/${id}_${secret}.jpg`
      );
    })
  })
};
复制代码

严格意义上来讲,不须要这么作,可是这会带来必定的好处.咱们处在应用的边缘(boundaries of our application,意思是说在这里的代码多是不少和远程服务器交互的代码,可能逻辑会很复杂),事情都有点乱.经过封装和远程API交互的逻辑,咱们的代码将会很整洁,很容易更新.若是须要抹掉图片服务也会出奇的简单.

咱们的saga.js看起来是这个样子:

import {fetchImages} from './flickr';

export function* loadImages() {
  const images = yield fetchImages();
  console.log(images)
}
复制代码

咱们仍然须要在saga外获取数据,而且进入应用的state(使用异步获取的远程数据更新state).为了处理这个问题,咱们将使用”effects”.

从saga来更新应用

咱们能够经过dispatch或者store做为参数来调用saga,可是这个方法时间一长就会给人形成些许的困扰.咱们选择采用redux-saga提供的put方法. 首先咱们更新reducer.js操做一个新的action类型IMAGES_LOADED.

const defaultState = {
+  images: []
}

export default function images(state = defaultState, action) {
  switch(action.type) {
    case 'IMAGE_SELECTED':
      return {...state, selectedImage: action.image};
+    case 'IMAGES_LOADED':
+      return {...state, images: action.images};
    default:
      return state;
  }
}     
复制代码

咱们添加了新的分支,并从defaultState中删除了硬编码的URLs数据.IMAGES_LOADED分支如今返回一个更新的state,包含action的image数据. 下一步咱们更新saga:

import {fetchImages} from './flickr';
+import {put} from 'redux-saga/effects';

export function* loadImages() {
  const images = yield fetchImages();
+  yield put({type: 'IMAGES_LOADED', images})
}
复制代码

导入put之后,咱们在loadImages添加另一行.他yield put函数调用的返回结果.在幕后,redux-saga 分发这些动做,reducer接收到了消息! 怎样才能使用特定类型的action来触发一个saga?

使用actions来触发saga工做流

Sagas变得愈来愈有用,由于咱们有能力使用redux actions来触发工做流.当咱们这样作,saga会在咱们的应用中表现出更大的能力.首先咱们建立一个新的saga.watchForLoadImages.

import {fetchImages} from './flickr';
-import {put} from 'redux-saga/effects';
+import {put, take} from 'redux-saga/effects';

export function* loadImages() {
  const images = yield fetchImages();
  yield put({type: 'IMAGES_LOADED', images})
}

+export function* watchForLoadImages() {
+  while(true) {
+    yield take('LOAD_IMAGES');
+    yield loadImages();
+  }
+}
复制代码

新的saga使用的是while来保持一直激活和等待调用状态.在循环的内部,咱们生成(yield)一个redux-sage调用方法:take.Take方法监放任何类型的actions,他也会使saga接受下一个yield.在上面的例子中咱们调用了一个方法loadImages,初始化图片加载.

import "babel-polyfill";

import React from 'react';
import ReactDOM from 'react-dom';

import Gallery from './Gallery';

import { createStore, applyMiddleware } from 'redux'
import {Provider} from 'react-redux';
import createSagaMiddleware from 'redux-saga'
import reducer from './reducer'

-import {loadImages} from './sagas';
+import {loadImages} from './watchForLoadImages';

const store = createStore(
  reducer,
-  applyMiddleware(createSagaMiddleware(loadImages))
+  applyMiddleware(createSagaMiddleware(watchForLoadImages))
);

ReactDOM.render(
  <Provider store={store}>
    <Gallery />
  </Provider>,
  document.getElementById('root')
);
复制代码

更新了main.js之后,应用再也不加载图片,咱们须要在action creators中添加loadImagesaction.

export const IMAGE_SELECTED = 'IMAGE_SELECTED';
+const LOAD_IMAGES = 'LOAD_IMAGES';

export function selectImage(image) {
  return {
    type: IMAGE_SELECTED,
    image
  }
}

+export function loadImages() {
+  return {
+    type: LOAD_IMAGES
+  }
+}
复制代码

由于咱们已经绑定了action creators(Action建立器),咱们只须要在Gallery组件中调用这个action就能够了.

block(阻塞)和no-blocking(非阻塞)效应

如今咱们的引用工做的足够好了,可是可能还有更多的问题须要考虑.watchForLoadImages saga包含 block effects.那么这究竟是什么意思呢?这意味着在工做流中咱们只能执行一次LOAD_IMAGES!在诸如咱们如今构建的小型应用同样,这一点不太明显,实际上咱们也仅仅加载了一次图片集. 实际上,广泛的作法是使用fork effect 代替 yield 来加载图片 .

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
-    yield loadImages();
+    yield fork(loadImages); //be sure to import it!
  }
}
复制代码

使用fork助手(helper)函数,watchForLoadImages就变成了非阻塞saga了,不再用考虑他是否是之前掉用过.redux-sagas 提供两个helpers,takeEverytakeLastest(takeEvery监听屡次action,不考虑是否是同一种action type,takeLatest只处理同一种action type的最后一次调用). ####选择默认的图片 Sagas按照队列来执行acitons,因此添加更多的saga也很容易.

import {fetchImages} from './flickr';
import {put, take, fork} from 'redux-saga/effects';

export function* loadImages() {
  const images = yield fetchImages();
  yield put({type: 'IMAGES_LOADED', images})
+  yield put({type: 'IMAGE_SELECTED', image: images[0]})
}

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
    yield fork(loadImages);
  }
}
复制代码

loadImages工做流上,咱们能够yield put函数调用,action type是IMAGE_SELECTED.发送咱们选择的图片(在这个例子中,发送的仅仅是图片的url的字符串).

错误处理

若是在saga循环内部出现错误,咱们要考虑提醒应用作出合理的回应.全部流程包装到try/catch语句块里就能够实现,捕获错误之后put一个提示信息做为IMAGE_LOAD_FAILURE action的内容.

import {fetchImages} from './flickr';
import {put, take, fork} from 'redux-saga/effects';

export function* loadImages() {
+  try {
    const images = yield fetchImages();
    yield put({type: 'IMAGES_LOADED', images})
    yield put({type: 'IMAGE_SELECTED', image: images[0]})
+  } catch(error) {
+    yield put({type: 'IMAGE_LOAD_FAILURE', error})
+  }
}

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
    yield fork(loadImages);
  }
}
复制代码

Sagas的测试

在应用中使用Redux,测试变得至关的舒服. 看看咱们的鹅蛋头系列课程,能够了解到不少React的测试技术. 使用Redux-saga在棒的一个方面就是异步代码测试很容易.测试javascript异步代码真是一件苦差事.有了saga,咱们不须要跳出引用的核心代码.Saga把javascript的痛点都抹掉了.是否是意味着咱们要写更多的测试?对的.

咱们会使用tape组件,首先作一些配置工做.

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

  assert.end();
});
复制代码

添加全部须要的组件,如今咱们添加一个测试.这个测试接收一个名称和一个函数做为形参.在测试的函数体内部代码块,咱们建立了一个saga生成器代码实例.在这个实例里面咱们尅是测试saga的每个动做.

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

+  assert.deepEqual(
+    generator.next().value,
+    false,
+    'watchForLoadImages should be waiting for LOAD_IMAGES action'
+  );

  assert.end();
});
复制代码

assert.deepEqual方法接收两个值,检查一下他们是否是深度相同(js对象的概念).第一行代码是generator.next().value的调用,这个调用使生成器从暂停中恢复,获得值.下一个值单单是一个false.我想看到他失败,最后一个参数描述了测试期待的行为. 在项目文件夹中命令行运行npm test看看结果:

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

+  assert.deepEqual(
+    generator.next().value,
+    false,
+    'watchForLoadImages should be waiting for LOAD_IMAGES action'
+  );

  assert.end();
});
复制代码

测试结果和预期的同样失败,结果有点意思.实际的结论是{TAKE:'LOAD_IMAGES'},这是咱们调用take('LOAD_IMAGES')受到的结果.实际上,咱们的saga’能够yield一个对象来代替调用take.可是take添加了一些代码,让咱们少敲些代码.

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

  assert.deepEqual(
    generator.next().value,
-    false
+    take('LOAD_IMAGES'),
    'watchForLoadImages should be waiting for LOAD_IMAGES action'
  );

  assert.end();
});
复制代码

咱们简单的调用take函数,就能够获得期待的结果了.

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

  assert.deepEqual(
    generator.next().value,
    take('LOAD_IMAGES'),
    'watchForLoadImages should be waiting for LOAD_IMAGES action'
  );

+  assert.deepEqual(
+    gen.next().value,
+    false,
+    'watchForLoadImages should call loadImages after LOAD_IMAGES action is received'
+  );

  assert.end();
});
复制代码

下一个测试使咱们确信loadImagessaga在流程的下一个阶段会被自动调用. 咱们须要一个 false来检查结果. 更新一下saga代码,yield一个loadImages saga:

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
+    yield loadImages();
-    yield fork(loadImages); //be sure to import it!
  }
}
复制代码

如今运行测试,将会看到下面结果:

✖ watchForLoadImages should call loadImages after LOAD_IMAGES action is received
---------------------------------------------------------------------------------
  operator: deepEqual
  expected: |-
    false
  actual: |-
    { _invoke: [Function: invoke] }
复制代码

哼!{ _invoke: [Function: invoke] }绝对不是咱们yield take想要的结果. 有问题.幸运的是redux-saga可使用诸如fork同样的effects来解决这个问题.fork,take和其余的effect方法返容易知足测试要求的简单对象.这些effects返回的对象是一个指导redux-saga进行任务执行的集合.这一点对于测试来讲很是的优雅,由于咱们不用担忧相似远程服务请求的反作用.有了redux-saga,咱们把注意点放到请求执行的命令上. 下面让咱们更新一下saga,再一次使用fork.

export function* watchForLoadImages() {
  while(true) {
    yield take('LOAD_IMAGES');
-    yield loadImages();
+    yield fork(loadImages);

  }
}
复制代码

这里使用yield fork(loadImages)直接代替loadImages.须要注意的是咱们尚未执行loadImages,而是做为参数传递给fork. 再次运行npm test.

✖ watchForLoadImages should call loadImages after LOAD_IMAGES action is received
---------------------------------------------------------------------------------
  operator: deepEqual
  expected: |-
    false
  actual: |-
    { FORK: { args: [], context: null, fn: [Function: loadImages] } }
复制代码

结果获得了一个单纯对象而不是一个函数调用.函数在浏览器端也同时加载了,可是咱们如今能够轻松的在saga 工做流里测试这个步骤.

import test from 'tape';
import {put, take} from 'redux-saga/effects'
import {watchForLoadImages, loadImages} from './sagas';
import {fetchImages} from './flickr';

test('watchForLoadImages', assert => {
  const generator = watchForLoadImages();

  assert.deepEqual(
    generator.next().value,
    take('LOAD_IMAGES'),
    'watchForLoadImages should be waiting for LOAD_IMAGES action'
  );

  assert.deepEqual(
    generator.next().value,
-    false,
+    yield fork(loadImages),
    'watchForLoadImages should call loadImages after LOAD_IMAGES action is received'
  );

  assert.end();
});
复制代码

测试loadImagessaga是同样的,只须要把yield fetchImages更新为yield fork(fetchImages).

test('loadImages', assert => {
  const gen = loadImages();

  assert.deepEqual(
    gen.next().value,
    call(fetchImages),
    'loadImages should call the fetchImages api'
  );

  const images = [0];

  assert.deepEqual(
    gen.next(images).value,
    put({type: 'IMAGES_LOADED', images}),
    'loadImages should dispatch an IMAGES_LOADED action with the images'
  );

  assert.deepEqual(
    gen.next(images).value,
    put({type: 'IMAGE_SELECTED', image: images[0]}),
    'loadImages should dispatch an IMAGE_SELECTED action with the first image'
  );

  const error = 'error';

  assert.deepEqual(
    gen.throw(error).value,
    put({type: 'IMAGE_LOAD_FAILURE', error}),
    'loadImages should dispatch an IMAGE_LOAD_FAILURE if an error is thrown'
  );

  assert.end();
});
复制代码

特别注意最后一个assert.这个断言测试使用异常捕获代替生成器函数的next方法.另外一个很是酷的地方是:能够传值.注意看代码,咱们建立了images常量,而且传递到next函数.saga能够在接下来的任务序列中使用传递的值. 太棒了,这种方法是测试异步编程的程序员求之不得的技术.

接下来作什么?

你能够fork一下这个例子的代码.

若是你想扩充这个应用,能够作一下几个方面的工做.

  • 作一个幻灯显示下一张要显示的图片
  • 容许使用者搜索Flickr图片
  • 添加其余提供图片的API
  • 容许用户选择喜欢的API进行搜索.

咱们仅仅和生成器碰了一下面,可是即使如此,但愿在联合使用redux-saga library,Redux和React的时候给你一些帮助.

相关文章
相关标签/搜索