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

栏目: 服务器 · 发布时间: 6年前

内容简介:图像长廊是一个简单的程序,从Flicker API 加载图片URLs,允许用户查看图片详情。后续我们会使用React,Redux和redux-saga.React作为核心框架,优势是虚拟dom(virtual-dom)的实现。Redux在程序内负责state的管理。最后,我们会使用redux-saga来执行javascript的异步操作步骤。我们会使用ES6(箭头函数,模块,和模板字符串),所以我们首先需要做一些项目的配置工作。

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

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

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

我们会使用ES6(箭头函数,模块,和模板字符串),所以我们首先需要做一些项目的配置工作。

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

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

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

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 安装程序需要的依赖项。

.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 中渲染的页面

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

现在我们来构建基础的 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 ,因为他非常便于使用.

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

这张图可能有点古怪,但是展示了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关联的组件了.

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

如果你观察我们添加进构造器的 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进行了包装).

现在回顾一下,我们做了几件事:

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();
});
复制代码

下一个测试使我们确信 loadImages saga在流程的下一个阶段会被自动调用. 我们需要一个 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();
});
复制代码

测试 loadImages saga是一样的,只需要把 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可以在接下来的任务序列中使用传递的值. 太棒了,这种方法是测试异步编程的 程序员 梦寐以求的技术.


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Shallows

The Shallows

Nicholas Carr / W. W. Norton & Company / 2011-6-6 / USD 15.95

"Is Google making us stupid?" When Nicholas Carr posed that question, in a celebrated Atlantic Monthly cover story, he tapped into a well of anxiety about how the Internet is changing us. He also crys......一起来看看 《The Shallows》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

UNIX 时间戳转换
UNIX 时间戳转换

UNIX 时间戳转换

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具