集成数据
到目前为止,我们已经创建了独立的无状态组件——非常适合 Storybook,但直到我们为它们提供一些应用程序中的数据,它们最终都没有多大用处。
本教程不侧重于构建应用程序的细节,因此我们在这里不会深入探讨这些细节。但是,我们将花一些时间来看一个将数据集成到连接组件中的常见模式。
连接的组件
我们当前编写的 TaskList 组件是“展示性”的,这意味着它不与自身实现之外的任何东西进行通信。我们需要将其连接到数据提供者,以便从中获取数据。
本示例使用 Redux Toolkit,这是使用 Redux 构建应用程序存储数据最有效的工具集,来为我们的应用程序构建一个简单的数据模型。但是,这里使用的模式同样适用于其他数据管理库,如 Apollo 和 MobX。
使用以下命令将必要的依赖项添加到你的项目中
yarn add @reduxjs/toolkit react-redux
首先,我们将构建一个简单的 Redux store,它响应一个文件 store.ts 中改变任务状态的操作,该文件位于 src/lib 目录下(有意保持简单)
/* A simple redux store/actions/reducer implementation.
* A true app would be more complex and separated into different files.
*/
import type { TaskData } from '../types';
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TaskBoxState {
tasks: TaskData[];
status: 'idle' | 'loading' | 'failed' | 'succeeded';
error: string | null;
}
/*
* The initial state of our store when the app loads.
* Usually, you would fetch this from a server. Let's not worry about that now
*/
const defaultTasks: TaskData[] = [
{ id: '1', title: 'Something', state: 'TASK_INBOX' },
{ id: '2', title: 'Something more', state: 'TASK_INBOX' },
{ id: '3', title: 'Something else', state: 'TASK_INBOX' },
{ id: '4', title: 'Something again', state: 'TASK_INBOX' },
];
const TaskBoxData: TaskBoxState = {
tasks: defaultTasks,
status: 'idle',
error: null,
};
/*
* The store is created here.
* You can read more about Redux Toolkit's slices in the docs:
* https://toolkit.redux.js.cn/api/createSlice
*/
const TasksSlice = createSlice({
name: 'taskbox',
initialState: TaskBoxData,
reducers: {
updateTaskState: (
state,
action: PayloadAction<{ id: string; newTaskState: TaskData['state'] }>
) => {
const task = state.tasks.find((task) => task.id === action.payload.id);
if (task) {
task.state = action.payload.newTaskState;
}
},
},
});
// The actions contained in the slice are exported for usage in our components
export const { updateTaskState } = TasksSlice.actions;
/*
* Our app's store configuration goes here.
* Read more about Redux's configureStore in the docs:
* https://toolkit.redux.js.cn/api/configureStore
*/
const store = configureStore({
reducer: {
taskbox: TasksSlice.reducer,
},
});
// Define RootState and AppDispatch types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
然后,我们将更新我们的 TaskList 组件,以连接到 Redux store 并渲染我们感兴趣的任务。
import Task from './Task';
import { useDispatch, useSelector } from 'react-redux';
import { updateTaskState, RootState, AppDispatch } from '../lib/store';
export default function TaskList() {
// We're retrieving our state from the store
const tasks = useSelector((state: RootState) => {
const tasksInOrder = [
...state.taskbox.tasks.filter((t) => t.state === 'TASK_PINNED'),
...state.taskbox.tasks.filter((t) => t.state !== 'TASK_PINNED'),
];
const filteredTasks = tasksInOrder.filter(
(t) => t.state === "TASK_INBOX" || t.state === 'TASK_PINNED'
);
return filteredTasks;
});
const { status } = useSelector((state: RootState) => state.taskbox);
const dispatch = useDispatch<AppDispatch>();
const pinTask = (value: string) => {
// We're dispatching the Pinned event back to our store
dispatch(updateTaskState({ id: value, newTaskState: 'TASK_PINNED' }));
};
const archiveTask = (value: string) => {
// We're dispatching the Archive event back to our store
dispatch(updateTaskState({ id: value, newTaskState: 'TASK_ARCHIVED' }));
};
const LoadingRow = (
<div className="loading-item">
<span className="glow-checkbox" />
<span className="glow-text">
<span>Loading</span> <span>cool</span> <span>state</span>
</span>
</div>
);
if (status === "loading") {
return (
<div className="list-items" data-testid="loading" key="loading">
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className="list-items" key="empty" data-testid="empty">
<div className="wrapper-message">
<span className="icon-check" />
<p className="title-message">You have no tasks</p>
<p className="subtitle-message">Sit back and relax</p>
</div>
</div>
);
}
return (
<div className="list-items" data-testid="success" key="success">
{tasks.map((task) => (
<Task
key={task.id}
task={task}
onPinTask={pinTask}
onArchiveTask={archiveTask}
/>
))}
</div>
);
}
现在我们有了一些实际数据填充我们的组件,这些数据是从 Redux store 获取的,我们可以将其连接到 src/App.tsx 并在那里渲染该组件。但目前,我们暂缓这样做,继续我们的组件驱动之旅。
不用担心。我们将在下一章处理它。
使用装饰器提供上下文
由于我们的 Tasklist 现在是一个连接的组件,因为它依赖于 Redux store 来检索和更新我们的任务,所以我们的 Storybook stories 在进行此更改后已停止工作。

我们可以使用各种方法来解决这个问题。不过,由于我们的应用程序相当直接,我们可以依赖一个装饰器,就像我们在 上一章 中所做的那样,并在我们的 Storybook stories 中提供一个模拟的 store。
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { TaskData } from '../types';
import { Provider } from 'react-redux';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
// A super-simple mock of the state of the store
export const MockedState = {
tasks: [
{ ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
{ ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
{ ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
{ ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
{ ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
{ ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
] as TaskData[],
status: 'idle',
error: null,
};
// A super-simple mock of a redux store
const Mockstore = ({
taskboxState,
children,
}: {
taskboxState: typeof MockedState;
children: React.ReactNode;
}) => (
<Provider
store={configureStore({
reducer: {
taskbox: createSlice({
name: "taskbox",
initialState: taskboxState,
reducers: {
updateTaskState: (state, action) => {
const { id, newTaskState } = action.payload;
const task = state.tasks.findIndex((task) => task.id === id);
if (task >= 0) {
state.tasks[task].state = newTaskState;
}
},
},
}).reducer,
},
})}
>
{children}
</Provider>
);
const meta = {
component: TaskList,
title: 'TaskList',
decorators: [(story) => <div style={{ margin: '3rem' }}>{story()}</div>],
tags: ['autodocs'],
excludeStories: /.*MockedState$/,
} satisfies Meta<typeof TaskList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [
(story) => <Mockstore taskboxState={MockedState}>{story()}</Mockstore>,
],
};
export const WithPinnedTasks: Story = {
decorators: [
(story) => {
const pinnedtasks: TaskData[] = [
...MockedState.tasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
return (
<Mockstore
taskboxState={{
...MockedState,
tasks: pinnedtasks,
}}
>
{story()}
</Mockstore>
);
},
],
};
export const Loading: Story = {
decorators: [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
status: 'loading',
}}
>
{story()}
</Mockstore>
),
],
};
export const Empty: Story = {
decorators: [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
tasks: [],
}}
>
{story()}
</Mockstore>
),
],
};
💡 excludeStories 是一个 Storybook 配置字段,用于防止我们的模拟状态被视为一个 story。你可以在 Storybook文档 中阅读有关此字段的更多信息。
成功!我们回到了起点,我们的 Storybook 现在可以正常工作了,并且我们可以看到如何将数据提供给连接的组件。在下一章中,我们将把我们在这里学到的东西应用到一个页面上。