连线数据

到目前为止,我们创建了孤立的无状态组件 - Storybook很棒,但作用不大,除非我们在应用程序中为他们提供一些数据.

本教程不关注构建应用程序的细节,因此我们不会在此处深入研究这些细节. 但我们将花点时间研究一下 与容器组件 连接数据 的常见模式.

容器组件

我们的TaskList目前编写的组件是"表现性的" (见这篇博文) 因为它不会与 其自身实现之外 的任何内容交谈. 为了获取数据,我们需要一个"容器".

这个例子使用Redux,最流行的React库,用于存储数据,为我们的应用程序构建一个简单的数据模型. 但是,此处使用的模式同样适用于其他数据管理库阿波罗MobX.

首先,我们将构建一个简单的Redux存储,它在一个src/lib/redux.js中定义改变任务状态的操作 (故意保持简单) :

  1. // 一个简单的 redux store/actions/reducer 实现。
  2. // 一个真正的应用程序将更复杂,并分为不同的文件.
  3. import { createStore } from 'redux';
  4. // 这些行为是可能发生的store变化的“名称”
  5. export const actions = {
  6. ARCHIVE_TASK: 'ARCHIVE_TASK',
  7. PIN_TASK: 'PIN_TASK',
  8. };
  9. // 动作创建者是将动作与 要求的数据捆绑在一起的方式
  10. export const archiveTask = id => ({ type: actions.ARCHIVE_TASK, id });
  11. export const pinTask = id => ({ type: actions.PIN_TASK, id });
  12. // 我们所有的Reducer都只是改变了一个任务的状态。
  13. function taskStateReducer(taskState) {
  14. return (state, action) => {
  15. return {
  16. ...state,
  17. tasks: state.tasks.map(
  18. task => (task.id === action.id ? { ...task, state: taskState } : task)
  19. ),
  20. };
  21. };
  22. };
  23. // reducer描述了 Store 中每个 action 如何改变内容
  24. export const reducer = (state, action) => {
  25. switch (action.type) {
  26. case actions.ARCHIVE_TASK:
  27. return taskStateReducer('TASK_ARCHIVED')(state, action);
  28. case actions.PIN_TASK:
  29. return taskStateReducer('TASK_PINNED')(state, action);
  30. default:
  31. return state;
  32. }
  33. };
  34. // 应用加载时我们Store 的初始状态。
  35. // 通常你会从服务器上获取它
  36. const defaultTasks = [
  37. { id: '1', title: 'Something', state: 'TASK_INBOX' },
  38. { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  39. { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  40. { id: '4', title: 'Something again', state: 'TASK_INBOX' },
  41. ];
  42. // 我们导出构造的 redux store
  43. export default createStore(reducer, { tasks: defaultTasks });

然后我们将更新默认导出TaskList组件连接到Redux存储,并呈现我们感兴趣的任务:

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import Task from './Task';
  4. import { connect } from 'react-redux';
  5. import { archiveTask, pinTask } from '../lib/redux';
  6. export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  7. /* 以前的 TaskList 实现 */
  8. }
  9. PureTaskList.propTypes = {
  10. loading: PropTypes.bool,
  11. tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  12. onPinTask: PropTypes.func.isRequired,
  13. onArchiveTask: PropTypes.func.isRequired,
  14. };
  15. PureTaskList.defaultProps = {
  16. loading: false,
  17. };
  18. export default connect(
  19. ({ tasks }) => ({
  20. tasks: tasks.filter(t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
  21. }),
  22. dispatch => ({
  23. onArchiveTask: id => dispatch(archiveTask(id)),
  24. onPinTask: id => dispatch(pinTask(id)),
  25. })
  26. )(PureTaskList);

在这个阶段,我们的 Storybook测试将停止工作,因为TaskList现在是一个容器,不再需要任何 props,而是连接到 Store 并设置PureTaskList包裹组件的props.

但是,我们可以通过简单地渲染PureTaskList来轻松解决这个问题 - 我们的 Storybook故事中的表现部分:

  1. import React from 'react';
  2. import { storiesOf } from '@storybook/react';
  3. import { PureTaskList } from './TaskList';
  4. import { task, actions } from './Task.stories';
  5. export const defaultTasks = [
  6. { ...task, id: '1', title: 'Task 1' },
  7. { ...task, id: '2', title: 'Task 2' },
  8. { ...task, id: '3', title: 'Task 3' },
  9. { ...task, id: '4', title: 'Task 4' },
  10. { ...task, id: '5', title: 'Task 5' },
  11. { ...task, id: '6', title: 'Task 6' },
  12. ];
  13. export const withPinnedTasks = [
  14. ...defaultTasks.slice(0, 5),
  15. { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
  16. ];
  17. storiesOf('TaskList', module)
  18. .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
  19. .add('default', () => <PureTaskList tasks={defaultTasks} {...actions} />)
  20. .add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
  21. .add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
  22. .add('empty', () => <PureTaskList tasks={[]} {...actions} />);

同样,我们需要使用PureTaskList在我们的Jest测试中:

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { PureTaskList } from './TaskList';
  4. import { withPinnedTasks } from './TaskList.stories';
  5. it('renders pinned tasks at the start of the list', () => {
  6. const div = document.createElement('div');
  7. const events = { onPinTask: jest.fn(), onArchiveTask: jest.fn() };
  8. ReactDOM.render(<PureTaskList tasks={withPinnedTasks} {...events} />, div);
  9. // 我们期望首先渲染标题为“任务6(固定)”的任务,而不是最后
  10. const lastTaskInput = div.querySelector('.list-item:nth-child(1) input[value="Task 6 (pinned)"]');
  11. expect(lastTaskInput).not.toBe(null);
  12. ReactDOM.unmountComponentAtNode(div);
  13. });