组装复合组件

上一章我们构建了第一个组件; 本章 我们学习 扩展构建TaskList的任务列表. 让我们将 组件组合 在一起,看看在引入更多复杂性时会发生什么.

任务列表

Taskbox 通过将 固定任务 置于默认任务之上 来强调 固定任务. 这产生了两种变体TaskList您需要为以下内容创建故事: 默认项目 以及 默认和 固定项目.

default and pinned tasks

Task可以异步发送数据,我们 需要在没有连接的情况下 loading 渲染 右图. 此外,当没有任务时,需要 空状态 左图.

empty and loading tasks

获取设置

复合组件与 其包含的基本组件没有太大区别. 创建一个TaskList组件和 对应的故事文件: src/components/TaskList.jssrc/components/TaskList.stories.js.

从粗略的实现开始TaskList. 你需要导入早期的Task组件,并将 属性和操作 作为输入传递.

  1. import React from 'react';
  2. import Task from './Task';
  3. function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  4. const events = {
  5. onPinTask,
  6. onArchiveTask,
  7. };
  8. if (loading) {
  9. return <div className="list-items">loading</div>;
  10. }
  11. if (tasks.length === 0) {
  12. return <div className="list-items">empty</div>;
  13. }
  14. return (
  15. <div className="list-items">
  16. {tasks.map(task => <Task key={task.id} task={task} {...events} />)}
  17. </div>
  18. );
  19. }
  20. export default TaskList;

接下来创建Tasklist故事文件中的测试状态.

  1. import React from 'react';
  2. import { storiesOf } from '@storybook/react';
  3. import TaskList 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', () => <TaskList tasks={defaultTasks} {...actions} />)
  20. .add('withPinnedTasks', () => <TaskList tasks={withPinnedTasks} {...actions} />)
  21. .add('loading', () => <TaskList loading tasks={[]} {...actions} />)
  22. .add('empty', () => <TaskList tasks={[]} {...actions} />);

addDecorator()允许我们为每个任务的渲染添加一些"上下文". 在这种情况下,我们在列表周围添加 填充-padding,以便更容易进行 可视化验证.

Decorators-装饰器 是一种为 故事 提供任意包装的方法。 在这种情况下,我们使用装饰器来添加样式。 它们还可以用于包装故事在 "providers" - 设置 React上下文 的库组件.

task提供一个Task的形状,这是通过我们创建和导出的Task.stories.js文件. 同样的,actions定义Task组件期望的操作 (模拟回调) ,其中TaskList也需要.

现在查看 Storybook的新内容TaskList故事.

建立状态

我们的组件仍然很粗糙,但现在我们已经了解了 要努力的故事. 你可能会想到.list-items包装过于简单化. 你是对的 - 在大多数情况下,我们不会只是添加一个包装器来创建一个新的组件. 但是 真正的复杂性TaskList组件在边缘情况下会显示withPinnedTasks,loading,和empty.

  1. import React from 'react';
  2. import Task from './Task';
  3. function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  4. const events = {
  5. onPinTask,
  6. onArchiveTask,
  7. };
  8. const LoadingRow = (
  9. <div className="loading-item">
  10. <span className="glow-checkbox" />
  11. <span className="glow-text">
  12. <span>Loading</span> <span>cool</span> <span>state</span>
  13. </span>
  14. </div>
  15. );
  16. if (loading) {
  17. return (
  18. <div className="list-items">
  19. {LoadingRow}
  20. {LoadingRow}
  21. {LoadingRow}
  22. {LoadingRow}
  23. {LoadingRow}
  24. {LoadingRow}
  25. </div>
  26. );
  27. }
  28. if (tasks.length === 0) {
  29. return (
  30. <div className="list-items">
  31. <div className="wrapper-message">
  32. <span className="icon-check" />
  33. <div className="title-message">You have no tasks</div>
  34. <div className="subtitle-message">Sit back and relax</div>
  35. </div>
  36. </div>
  37. );
  38. }
  39. const tasksInOrder = [
  40. ...tasks.filter(t => t.state === 'TASK_PINNED'), //< ==== 固定顶部
  41. ...tasks.filter(t => t.state !== 'TASK_PINNED'),
  42. ];
  43. return (
  44. <div className="list-items">
  45. {tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)}
  46. </div>
  47. );
  48. }
  49. export default TaskList;

添加的标记会产生以下UI:

请注意列表中 固定项 的位置. 我们希望固定项目在 列表顶部 呈现,以使其成为我们用户的优先事项.

数据要求和props

随着组件的增长,输入要求也在增长. 要求定义TaskListprops. 因为Task是一个子组件,请确保提供 正确形状的数据 来呈现它. 为了节省时间和头痛,请重用您定义的早期Task的propTypes.

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. function TaskList() {
  4. ...
  5. }
  6. TaskList.propTypes = {
  7. loading: PropTypes.bool,
  8. tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  9. onPinTask: PropTypes.func.isRequired,
  10. onArchiveTask: PropTypes.func.isRequired,
  11. };
  12. TaskList.defaultProps = {
  13. loading: false,
  14. };
  15. export default TaskList;

自动化测试

在上一章中,我们学习了如何使用 Storyshots快照测试 故事. Task测试没有太多的复杂性,已然够用了. 而TaskList增加了另一层复杂性,我们希望 以 自动测试 的方式验证 某些输入产生某些输出. 为此,我们将使用创建单 元测试jest-笑话再加上测试渲染器等Enzyme.

Jest logo

用Jest进行单元测试

Storybook故事 与 手动可视化测试 和 快照测试 (见上文) 相结合,可以避免 UI错误. 如果故事 涵盖了 各种各样的组件用例,并且我们使用的工具可以确保 人员检查故事的任何变化,那么错误的可能性就大大降低.

然而,有时候魔鬼是在细节中. 需要一个明确有关这些细节的测试框架. 这让我们进行了单元测试.

在我们的例子中,我们希望我们的TaskList,在传递 不固定tasks 之前,呈现所有固定tasks. 虽然我们有一个故事 (withPinnedTasks) 测试这个确切的场景; 但是如果组件停止对 这样的任务 进行排序,那么就人类看着来说,这可能是不明确的,因为只看到表面与操作, 这是一个bug. 它肯定不会尖叫 "错误!" 直怼眼睛.

因此,为了避免这个问题,我们可以使用Jest 将故事呈现给DOM,并运行一些DOM查询代码,来验证输出的显着特征.

创建一个名为的测试文件TaskList.test.js. 在这里,我们将构建我们的测试,对输出进行断言.

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import TaskList 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(<TaskList 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. });

TaskList test runner

请注意,我们已经能够重用withPinnedTasks故事 和 单元测试中的任务列表;通过这种方式,我们可以继续 以越来越多的方式 利用现有资源 (代表组件的有趣配置的示例) .

另请注意,此测试非常脆弱. 随着项目的成熟,以及项目的确切实现,这都可能是Task的更改 - 可能使用 不同的类名或textarea而不是一个input- 测试将失败,需要更新. 这不一定是一个问题,但使用UI的 单元测试 要小心的指示. 它们不容易维护. 替代的是依靠视觉,快照和视觉回归 (参见测试章节) 的 Storybook测试.