在 model 中请求服务端数据

在前面的章节中,我们体验了 model 的使用。如果说 model,namespace,connect,dispatch,action,reducer 这些是 dva 的基石,那 dva 的精髓就体现在于 effect。本章中涉及的 demo 代码请访问 https://github.com/ant-design/react-tutorial

一点点理论:Effect 和 Generator function

在之前的例子中,我们没有在 reducer 中做任何的异步操作,但在实际的开发中,计算新 state 时常常需要异步操作配合,比如说强制延时、异步网络请求数据(比如 ajax)等等。但是 reducer 需要是个纯函数,我们不能在 reducer 中写这些逻辑,破坏了这个机制后 dva 将无法工作。在 dva 框架下,effect 就是专门处理这些具有 "副作用" 的操作的执行单元。

那么 effect 到底是什么呢 ?effect 是一个 dva 语境中的名词。和 reducers 类似,我们也可以在 dva model 中定义一个 effects 成员。

export default {
  namespace: 'some_namespace',
  state: {},
  effects: { // 定义 effects 成员
    'someEffect': function*() {},
    'someOtherEffect': function*() {},
    // ...
  },
  reducers: {
    // ...
  },
}

局部上看 effect 就是一个一个的 generator function。宏观上看,effect 是一层中间件。

effect 和 middleware

我们先展示 effect 是怎样作为中间件存在的。中间件是一种程序架构和分布式系统架构上的思想。目前来讲还没有个标准定义,笔者认为起码在程序架构领域下面一句话还是比较准确精炼的。

" Middleware is some code you can put between the framework receiving a request, and the framework generating a response. "

在上一章中 action 被 dispatch 之后就能够 直接 到达 reducer。为了保证 reducer 的纯粹性,但同时又能够处理副作用,就需要打破「直接」这个特性。effect 充当了这么一个中间层,当 action 被 dispatch 之后,会先到达 effect 处理副作用,然后该 effect 最终会促使新的 action 发送出去,这个新的 action 可能被其他的 effect 再捕获继续处理,也可能被 reducer 捕获并结束,无论怎样,最终处理逻辑的终点都将是 reducer。

在上一章节中,我们知道 action.type 的构造是 namespace 名称 + / + reducer 名称,事实上 action.type 也可以是 namespace 名称 + / + effect 名称。对于视图层来讲,其实并不会感知 effect 和 reducer 的区别。视图层只是通过 action 描述想做什么,至于这个 action 之后是直接被 reducer 处理还是通过 effect 再到 reducer,视图层并不感知,也不应该关心。这样我们就做到了数据逻辑和视图逻辑的分离处理。

Generator function

我们再解释为什么 generator function 可以用来处理异步逻辑。其实 generator function 处理异步逻辑并不是 dva 的专利,在许多 js 框架中都用到了,最著名的就是 co。使用 generator function 处理异步也不是对语言特性的乱用,而是说 generator function 天然地就具备处理异步的特质。dva 中一个典型的 effect 的写法是:

getData: function* ({ payload }, { call, put }) {
  const data = yield call(SomeService.getEndpointData, payload, 'maybeSomeOtherParams');
  yield put({ type: 'getData_success', payload: data });
}

先说结论:当这个 generator function 被执行时,执行的流程 看上去 会是同步的!入参有两个对象,第一个对象就是匹配这个 effect 的 action 对象,因此可以取到约定的 payload 这个字段,第二个对象是 effect 原语集,其中 call, put 最为常用。generator function 入参中的两个对象都是在运行时由 dva 注入到 generator function 中的。call 其实是一个函数,和 yield 关键字配合使用处理异步逻辑,call 第一个参数是一个函数,要求函数返回 Promise,之后的参数是该函数调用时的入参。yield call 调用后就阻塞了,Promise 被解析后,得到异步调用的结果,存储到 data 中,然后程序才能继续进行。看到下面一行又执行了 put。put 也是一个函数,put 和 yield 配合使用,用来派发一个 action,和 dispatch 的功能 一模一样!只不过是在 effect 函数中使用而已。

注意:yield put 派发的 action 如果是为了触发 同 model 中的其他 effect/reducer 执行,不需要指定 namespace 名称。

再聊聊 generator function 是如何优雅地把异步变得像「同步」一样。我们都知道在古老的 js 开发中,都是使用层层回调来处理异步逻辑的。但结果是:

image.png | left | 747x349

这就是所谓的 callback hell! 程序变得很难以理解和维护。异步的实质是事件发生促使程序的执行点来回跳转。我们使用 callback 本质上是描述跳转的一种手段。generator function 并没有改变异步的本质,只是改变了描述的方式,使得程序看起来像是同步一样。

一个 generator function 在执行时有 两方。一方是 generator function 本身,另一方是 generator function 的句柄持有者,而这一般都是框架所持有。我们姑且称这个句柄为 genStub。当框架调用 genStub.next() 时,generator function 会执行到下一个 yield 然后暂停,并把 yield 后面表达式的计算值返还给框架,同时把程序执行权交给框架。框架拿到值后做处理,比如就是异步处理,处理结束拿到结果,再次调用 genStub.next(),返还值给 generator function 同时驱动它恢复执行。当恢复执行时,你可以认为 返回的处理结果会整体替换 yield <expression>,然后程序继续执行到下一个 yield。

yield 这个单词用在这里特别形象:yield 本身有「让步」的意思,也有「产出」的意思。

「generator function yield 到外部的值」和「外部返还给 generator function 的值」不是一回事!!!

generator function 定义了流程,并在每次 yield 节点上报想做的事情。而异步的真正执行逻辑由 generator function 句柄的持有者代为执行。对应到 dva 上也是一样的。拿 call 做例子,call 其实是一个特别简单的函数。call 的返回值只是一个 plain javascript object:

{
  CALL: {
    fn: SomeService.getEndpointData,
    args: [payload, 'maybeSomeOtherParams']
  }
}

我们通过 call 向 dva 描述了想做的事情:请帮我执行这个函数,Promise 解析后通知我继续执行,并把 Promise 的解析值返回给我。

一个"活"的 dva model:使用 Effect 获取真实数据源数据

接下来我们要改造我们的代码,我们将从真实的服务端数据源获取卡片数据。

首先是页面文件代码:

import React, { Component } from 'react';
import { Card /* ,Button */ } from 'antd';
import { connect } from 'dva';

const namespace = 'puzzlecards';

const mapStateToProps = (state) => {
  const cardList = state[namespace].data;
  return {
    cardList,
  };
};

const mapDispatchToProps = (dispatch) => {
  return {
    onDidMount: () => {
      dispatch({
        type: `${namespace}/queryInitCards`,
      });
    },
  };
};

@connect(mapStateToProps, mapDispatchToProps)
export default class PuzzleCardsPage extends Component {
  componentDidMount() {
    this.props.onDidMount();
  }
  render() {
    return (
      <div>
        {
          this.props.cardList.map(card => {
            return (
              <Card key={card.id}>
                <div>Q: {card.setup}</div>
                <div>
                  <strong>A: {card.punchline}</strong>
                </div>
              </Card>
            );
          })
        }
      </div>
    );
  }
}

然后是 model 的代码:

import request from '../util/request';  // request 是 demo 项目脚手架中提供的一个做 http 请求的方法,是对于 fetch 的封装,返回 Promise

const delay = (millisecond) => {
  return new Promise((resolve) => {
    setTimeout(resolve, millisecond);
  });
};

export default {
  namespace: 'puzzlecards',
  state: {
    data: [],
    counter: 0,
      },
  effects: {
    *queryInitCards(_, sagaEffects) {
      const { call, put } = sagaEffects;
      const endPointURI = 'https://08ad1pao69.execute-api.us-east-1.amazonaws.com/dev/random_joke';

      const puzzle = yield call(request, endPointURI);
      yield put({ type: 'addNewCard', payload: puzzle });

      yield call(delay, 3000);

      const puzzle2 = yield call(request, endPointURI);
      yield put({ type: 'addNewCard', payload: puzzle2 });
    }
  },
  reducers: {
    addNewCard(state, { payload: newCard }) {
      const nextCounter = state.counter + 1;
      const newCardWithId = { ...newCard, id: nextCounter };
      const nextData = state.data.concat(newCardWithId);
      return {
        data: nextData,
        counter: nextCounter,
      };
    }
  },
};

下面同时展示了 ../util/request 的代码,同样的代码您也可以在 demo 代码仓库中找到。

function checkStatus(response) {
  if (response.status >= 200 && response.status < 300) {
    return response;
  }

  const error = new Error(response.statusText);
  error.response = response;
  throw error;
}

/**
 * Requests a URL, returning a promise.
 *
 * @param  {string} url       The URL we want to request
 * @param  {object} [options] The options we want to pass to "fetch"
 * @return {object}           An object containing either "data" or "err"
 */
export default async function request(url, options) {
  const response = await fetch(url, options);
  checkStatus(response);
  return await response.json();
}

得到新的页面:

image.png | left | 747x250

我们看到卡片数据不再是写死的,而真的是从服务端获取的,我们故意打开了Chrome DevTool 中的 Network 面板用来印证。

说明:

  • fetch 是一个 W3C 标准下的基于 Promise 的做 ajax 请求的函数,但是并不那么好用。我们这里的 request 是对它的一层封装。总而言之,你在 demo 项目中可以使用 request 做 ajax 请求,该函数返回 Promise。
  • request 函数不指定 http verb 时默认是 GET 请求。这里仅仅演示了 HTTP GET 请求。但其他类型的异步请求也是同理可以处理。

我们来分析一下都做了哪些改变,先从 model 说起。我们看到首先添加了一个 effect queryInitCards,根据我们上面的分析,如果派发了一个 type 为 puzzlecards/queryInitCards action,那么接下来会先到达 queryInitCards 这个 effect 来处理。接下来的流程是:

  • const puzzle = yield call(request, endPointURI); 获取服务端数据。
  • yield put({type: 'addNewCard', payload: puzzle }); 添加一个卡片数据。这个会触发 reducer 的执行。于是会看到视图上添加了一个新卡片。
  • yield call(delay, 3000); 暂停 3 s.
  • const puzzle2 = yield call(request, endPointURI); 第二次获取服务端数据。
  • yield put({type: 'addNewCard', payload: puzzle }); 再添加一个卡片数据。这个又会触发 reducer 的执行。于是看到第二个卡片添加到视图上去。

再看看页面的改变。我们通过 mapDispatchToProps 给页面注入方法 onDidMount。页面在 mount 完毕后调用该方法。它发送一个 puzzlecards/queryInitCards 的 action,这个请求被 puzzlecards 中的 queryInitCards 这个 effects 所处理。这样子数据的流转就完整了。

在 React 16 中,页面初始化时的异步请求必须只能在 componentDidMount 中做,不能在 constructor, UNSAFE_componentWillMount, UNSAFE_componentWillReceiveProps, getDerivedStateFromProps 中做。

整体的数据流向见下图:

effects_data_flow.png | center | 747x455

代理请求

如果做到这一步,你应该非常欣喜。新技能 get! 但是这里有一个问题被回避了:跨域问题。

请重新审视我们的请求:

const endPointURI = 'https://08ad1pao69.execute-api.us-east-1.amazonaws.com/dev/random_joke';

这里我们直接调用了一个「非本地」地址。在实际开发中是比较罕见的。这里能够成功,是因为被调用的 API 做了额外的人为设置,允许一个「非同域」的 ajax 请求。跨域资源共享 CORS 涉及的知识点比较多。我们这里做简单的介绍,目的是让你知道如何在本地开发服务器上增加代理请求的功能。

我们在浏览器中看到的页面是从一个本地开发服务器所伺服的。这个本地开发服务器的地址就是 http://localhost:8000/ 。当我们调用 getRandomPuzzle 时,此时发送 ajax 请求页面的域就是 http://localhost:8000,但是请求的数据在另外一台服务器 https://08ad1pao69.execute-api.us-east-1.amazonaws.com。一个是 http 一个是 https,路径也不同,端口也不同(https 是 443)。任意这三个东西有一个不同,就认为是资源请求「跨域」了。http 的 默认 安全规则是不允许「跨域」请求。

值得注意的是,发送 ajax 请求的是你的浏览器,所谓 User Agent,而「跨域」的限制 仅仅在浏览器和服务器之间。我们不能强制远程服务器都像例子中那样允许「跨域」请求,所以我们能做的就是不要使用浏览器发送请求。所以在前端开发中,一种常见的规避跨域的方法就是:把 ajax 请求发送到你的本地开发服务器,然后本地开发服务器再把 ajax 请求转发到远端去,从网络拓扑上看本地开发服务器起着「反向代理」的作用。本地服务器和远端服务器是「服务器和服务器间的通信」,就不存在跨域问题了。

配置代理也很简单,只需要您在配置文件 config/config.js 中与 routes 同级处增加 proxy 字段,代码如下,

   routes: [
   // ...
   ],

+  proxy: {
+    '/dev': {
+      target: 'https://08ad1pao69.execute-api.us-east-1.amazonaws.com',
+      changeOrigin: true,
+    },
+  },

配置的含义是:去往本地服务器 localhost:8000 的 ajax 调用中,如果是以 /dev 开头的,那么就转发到远端的 https://08ad1pao69.execute-api.us-east-1.amazonaws.com 服务器当中,/dev 也会保留在转发地址中。

比如:

/dev/random_joke 就会被转发到 https://08ad1pao69.execute-api.us-east-1.amazonaws.com/dev/random_joke

所以 end point URI 也需更改为:

const endPointURI = '/dev/random_joke';

重启 dev server。我们的页面功能没有任何变化,但是发送的 http request 变化了,

proxy.png | center | 747x333

下图展示了配置代理和不配置代理时请求的路径,

proxy_server