搭建基于 model 的卡片列表页面

本章节中我们的目标是:创建一个卡片列表页,同时包含一个按钮可以添加新卡片。完整的 demo 代码请访问 https://github.com/ant-design/react-tutorial

最简单的卡片列表页

我们先显示一个最简单的卡片列表页,只有卡片,不做添加操作。src/page 目录下建立页面文件 puzzlecards.js,并把它加入到路由。

首先,建立页面文件。

import React, { Component } from 'react';
import { Card } from 'antd';

export default class PuzzleCardsPage extends Component {
  constructor(props) {
    super(props);
    this.state = {
      cardList: [
        {
          id: 1,
          setup: 'Did you hear about the two silk worms in a race?',
          punchline: 'It ended in a tie',
        },
        {
          id: 2,
          setup: 'What happens to a frog\'s car when it breaks down?',
          punchline: 'It gets toad away',
        },
      ],
    }
  }
  render() {
    return (
      <div>
        {
          this.state.cardList.map(card => {
            return (
              <Card key={card.id}>
                <div>Q: {card.setup}</div>
                <div>
                  <strong>A: {card.punchline}</strong>
                </div>
              </Card>
            );
          })
        }
      </div>
    );
  }
}

其次,配置文件 config/config.js 内增加一条路由规则。

export default {


  routes: [
    {
      path: '/',
      component: '../layout',
      routes: [

+       { path: 'puzzlecards', component: './puzzlecards' },

      ]
    }
  ],


};

启动应用,看到如下页面:

image.png | left | 747x351

"添加卡片" 按钮

在上文的基础上,我们添加一个按钮,并在上面绑定一个处理点击事件的回调函数。思路是在回调函数中向 cardList 中添加一个新卡片数据。

最终我们的页面文件变成如下样子:

import React, { Component } from 'react';
import { Card, Button } from 'antd';

export default class PuzzleCardsPage extends Component {
  constructor(props) {
    super(props);
    this.counter = 100;
    this.state = {
      cardList: [
        {
          id: 1,
          setup: 'Did you hear about the two silk worms in a race?',
          punchline: 'It ended in a tie',
        },
        {
          id: 2,
          setup: 'What happens to a frog\'s car when it breaks down?',
          punchline: 'It gets toad away',
        },
      ],
    }
  }

  addNewCard = () => {
    this.setState(prevState => {
      const prevCardList = prevState.cardList;
      this.counter += 1;
      const card = {
        id: this.counter,
        setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,',
        punchline: 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
      };
      return {
        cardList: prevCardList.concat(card),
      };
    });
  }

  render() {
    return (
      <div>
        {
          this.state.cardList.map(card => {
            return (
              <Card key={card.id}>
                <div>Q: {card.setup}</div>
                <div>
                  <strong>A: {card.punchline}</strong>
                </div>
              </Card>
            );
          })
        }
        <div>
          <Button onClick={this.addNewCard}> 添加卡片 </Button>
        </div>
      </div>
    );
  }
}

虽然每次添加的卡片内容都相同,但是不要紧,这里只是演示用法,但是注意唯独 id 不能相同。为了产生唯一的 id,我们在组件中新加了一个 counter 成员,它只是为了产生唯一 id,并不是真的为了计数,所以初始值不重要(我们这里给了 100)。

新的页面如下:

image.png | left | 747x604

到这里我们其实已经完成了想要的页面,也并没有用 dva,那 dva 到底有什么用 ?

这里陈述几个需求:

  • 在实际的前端开发中,像 cardList 中包含的那些数据,一般都是通过发起异步 http 请求从后端服务中获得。
  • 我们希望把数据逻辑(cardList 相关逻辑)和视图逻辑(PuzzleCardsPage)分开管理在不同的模块中,「关注分离」使得代码更加健壮,同时易于调试。
  • 我们希望这些数据在需要的时候,可以提供给不同的组件使用:也即数据共享。

而 dva 就是用来满足这些需求的:

  • 通过把状态上提到 dva model 中,我们把数据逻辑从页面中抽离出来。
  • 通过 effect 优雅地处理数据生成过程中的副作用,副作用中最常见的就是异步逻辑。
  • dva model 中的数据可以注入给任意组件。
  • 另外,dva 允许把数据逻辑再拆分(「页面」常常就是分隔的标志),以 namespace 区分。当你觉得有必要时,不同的 namespace 之间的 state 是可以互相访问的。

如果你熟悉 React 中最基本的两个概念 props 和 state,一定知道 props 和 state 对于一个组件来讲都是数据的来源,而 state 又可以通过 props 传递给子组件,这像是一个鸡生蛋蛋生鸡的问题:到底谁是数据的源头 ?答案是 state,而且是广义的 state:它可以是 react 组件树中各级组件的 state,也可以是 react 组件树外部由其他 js 数据结构表示的 state,而 dva 管理的就是 react 组件树之外的 state: Redux。归根结底,props 是用来传导数据的,而 state 是数据改变的源泉。

基于 dva 的简单卡片列表页:使用 connect 对接静态的 dva model

如果你已经对 React 开发比较熟悉,就会知道子组件的 state 可以上提 (state hoisting),由父组件来管理:

  • 子组件间接回调到父组件的 setState 的方法来改变父组件的 state;
  • 新的 state 通过 props 的形式把再次被子组件获悉。

而 dva 可以帮助我们把 state 上提到 所有 React 组件之上,过程是相似的:

  • 页面通过调用 dispatch 函数来驱动 dva model state 的改变;
  • 改变后的 dva model state通过 connect 方法注入页面。

所谓「注入」从本质上是 控制反转 的一种实现,这种思想在许多的语言框架中都有体现,最著名的莫过于基于 Java 语言的 Spring。组件不再负责管理数据,组件只是通过 connect 向 dva 声明所需数据。

本节中我们只做状态上提,我们只需要定义一个基本的 dva model 和使用 connect。首先,我们在 src/model 目录下创建一个 dva model 文件:puzzlecards.js

export default {
  namespace: 'puzzlecards',
  state: [
    { id: 1,
      setup: 'Did you hear about the two silk worms in a race?',
      punchline: 'It ended in a tie',
    },
    {
      id: 2,
      setup: 'What happens to a frog\'s car when it breaks down?',
      punchline: 'It gets toad away',
    },
  ],
};

其次,修改之前的页面文件:

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

const namespace = 'puzzlecards';

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

@connect(mapStateToProps)
export default class PuzzleCardsPage extends Component {
  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>
          <Button onClick={this.addNewCard}> 添加卡片 </Button>
        </div> */}
      </div>
    );
  }
}

首先,注意 dva model 的定义。一个基本的 dva model 最少具备两个成员:namespace 和 state。namespace 来作为一个 model 的唯一标识,state 中就是该 model 管理的数据。

其次,看页面文件的变化:我们删除了组件本身的 state,同时添加了 @connect(mapStateToProps)connect 是连接 dva 和 React 两个平行世界的关键,一定要理解。

  • connect 让组件获取到两样东西:1. model 中的数据;2. 驱动 model 改变的方法。
  • connect 本质上只是一个 javascript 函数,通过 @ 装饰器语法使用,放置在组件定义的上方;
  • connect 既然是函数,就可以接受入参,第一个入参是最常用的,它需要是一个函数,我们习惯给它命名叫做 mapStateToProps,顾名思义就是把 dva model 中的 state 通过组件的 props 注入给组件。通过实现这个函数,我们就能实现把 dva model 的 state 注入给组件。

mapStateToProps 这个函数的入参 state 其实是 dva 中所有 state 的总合。对于初学 js 的人可能会很疑惑:这个入参是谁给传入的呢?其实你不用关心,你只需知道 dva 框架会适时调用 mapStateToProps,并传入 dva model state 作为入参,我们再次提醒:传入的 state 是个 "完全体",包含了 所有 namespace 下的 state!我们自己定义的 dva model state 就是以 namespace 为 key 的 state 成员。所以 const namespace = 'puzzlecards' 中的 puzzlecards 必须和 model 中的定义完全一致。dva 期待 mapStateToProps 函数返回一个 对象,这个对象会被 dva 并入到 props 中,在上面的例子中我们取到数据后,把它改名为 cardList 并返回( 注意返回的不是 cardList 本身,而是一个包含了 cardList 的对象! ),cardList 就可以在子组件中通过 props 被访问到了。

注意 render 函数中通过 this.props.cardList 取到了数据,数据已经不再由组件自己管理,我们得到了第一步中的页面样子:

image.png | left | 747x266

这里我们同时利用 Redux DevTools 展示了 Dva 中 state 的内容,证明了我们定义的 model 确实生效了。如果想时刻洞察 model 中的内容,强烈建议安装 Redux DevTools

image.png | left | 747x323

"添加卡片" 按钮:使用 dispatch 和 reducer 改变 dva model

我们上面的例子中只是移植了 state,但是没有移植按钮和按钮上的行为。React 有一个基本的哲学:数据映射到视图。无论什么途径,我们点击按钮后,本质上都是去触发 state 的改变,state 的改变再映射回视图。所以我们这里的目标就是使得每次点击按钮,触发 dva model 的中卡片数据再添加一条。而在 dva 的语境中,是统一通过 dispatch 函数来做这件事情。

修改 model 文件,加入 reducers 部分:

export default {
  namespace: 'puzzlecards',
  state: {
    data: [
      {
        id: 1,
        setup: 'Did you hear about the two silk worms in a race?',
        punchline: 'It ended in a tie',
      },
      {
        id: 2,
        setup: 'What happens to a frog\'s car when it breaks down?',
        punchline: 'It gets toad away',
      },
    ],
    counter: 100,
  },
  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,
      };
    }
  },
};

修改页面文件,注入新的方法:

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 {
    onClickAdd: (newCard) => {
      const action = {
        type: `${namespace}/addNewCard`,
        payload: newCard,
      };
      dispatch(action);
    },
  };
};

@connect(mapStateToProps, mapDispatchToProps)
export default class PuzzleCardsPage extends Component {
  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>
          <Button onClick={() => this.props.onClickAdd({
            setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
            punchline: 'here we use dva',
          })}> 添加卡片 </Button>
        </div>
      </div>
    );
  }
}

于是得到新的页面,

withDvaReducer.png | center | 747x525

接下来我们解释一下都干了什么事情。

使用 mapDispatchToProps 和 dispatch

通过使用这两者,我们可以给组件注入方法,组件使用这些方法能给 dva model 发「消息」。this.props.onClickAdd 就是被注入的方法。

() => this.props.onClickAdd({
  setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
  punchline: 'here we use dva',
})

注意不要写成

this.props.onClickAdd({
  setup: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
  punchline: 'here we use dva',
})

区别是上面定义了一个 click 事件的回调函数,而下面是直接调用函数。回调函数在点击时被调用,又立刻调用 onClickAdd。如果直接写成 this.props.onClickAdd({}),就变成 render 函数执行到此处时直接调用 onClickAdd 函数了。

onClickAdd 是怎么被注入的呢 ?答案就在于我们给 connect 传入了第二个函数:mapDispatchToProps。我们习惯用这个名字是因为它精炼地说明了这个函数的作用:以 dispatch 为入参,返回一个挂着函数的对象,这个对象上的函数会被 dva 并入 props,注入给组件使用。

我们在 onClickAdd 函数中调用 dispatch 派发了一个 action,action 包含 onClickAdd 传递过来的内容 { setup, punchline } 作为 payload,action 的 type 是 puzzlecards/addNewCardaddNewCard 在这个例子中是 reducer 的名字,这个我们下面会讲到。dispatch 函数就是和 dva model 打交道的唯一途径。 dispatch 函数接受一个 对象 作为入参,在概念上我们称它为 action,唯一强制要包含的是 type 字段,string 类型,用来告诉 dva 我们想要干什么。我们可以选择给 action 附着其他字段,这里约定用 payload字段表示额外信息。

我们把想做的事情通过 action 描述出来,并通过 dispatch 告诉 dva model,而对这个消息的处理就是 dva 的事情了。如果深入了解 React 的读者,一定觉得这句话似曾相识。是的,dva 和 React 哲学一脉相承,React 也是遵循这个原理工作的,在组件中总要写一个 render 函数,这个函数就是向 React 描述你想要渲染出的内容,作为开发者你并不会去直接操作 DOM,而 React 负责把 render 函数的返回值转化成 DOM 元素,并由 React 最终决定渲染 DOM 的时机和流程(React 渲染引擎的执行是个异步的过程)。

接下来的问题是:派发出的 action 怎样被 dva 识别并执行 "添加卡片" 的逻辑呢 ?

定义 reducer

dva model 中可以定义一个叫做 reducers 的成员用来响应 action 并修改 state。每一个 reducer 都是一个 function,action 派发后,通过 action.type 被唯一地匹配到,随后执行函数体逻辑,返回值被 dva 使用作为新的 state。state 的改变随后会被 connect 注入到组件中,触发视图改变。

reducer 的样子大概是:

someReducer(state /* old state */, { payload }) {
  // ... do calculation
  return {
    // ... build a new object as next state and return it
  };
}

reducer 应该是一个 "纯函数",它的返回值作为新的 state。dva 会注入旧的 state 和 action 中的 payload,是否使用完全根据需要决定;返回值必须是一个新构造对象,绝不能把旧 state 的引用返回!

reducer 干的事情和 React 中 setState(prevState => { ... }) 很像,都要返回一个新构造的对象,但区别是:reducer 的返回值会 整个取代 (Replace) 老的 state,而 setState 中回调函数的返回值是会 融合(Merge) 到老的 state 中去。

下图标示了由 dva 驱动的整个过程,

reducers_data_flow.png