返回Storybook 簡介
章節
  • 開始使用
  • 簡單元件
  • 複合元件
  • 資料
  • 畫面
  • 部署
  • 視覺化測試
  • Addons
  • 結論
  • 貢獻

串接資料

學習如何將資料串接到你的 UI 元件

到目前為止,我們已經建立隔離的無狀態元件——這對 Storybook 來說很棒,但最終在我們為它們提供應用程式中的一些資料之前,並沒有太大幫助。

本教學課程不著重於建構應用程式的細節,因此我們不會在此深入探討這些細節。但是我們將花一些時間來看看將資料串接到已連接元件的常見模式。

已連接元件

我們目前撰寫的 `TaskList` 元件是「呈現型」的,因為它不與自身實作以外的任何事物對話。我們需要將其連接到資料提供者以將資料導入其中。

這個範例使用 Redux Toolkit,這是用於開發應用程式以使用 Redux 儲存資料的最有效工具組,為我們的應用程式建構一個簡單的資料模型。然而,此處使用的模式同樣適用於其他資料管理函式庫,如 ApolloMobX

使用以下命令將必要的依賴項新增到你的專案中

複製
yarn add @reduxjs/toolkit react-redux

首先,我們將在 `src/lib` 目錄中名為 `store.ts` 的檔案中建構一個簡單的 Redux store,以回應更改任務狀態的操作 (故意保持簡單)。

複製
src/lib/store.ts
/* 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';
  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://redux-toolkit.dev.org.tw/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://redux-toolkit.dev.org.tw/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 並呈現我們感興趣的任務。

複製
src/components/TaskList.tsx
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` 並在那裡呈現該元件。但是現在,讓我們暫緩執行此操作,並繼續我們的元件驅動旅程。

別擔心。我們將在下一章處理它。

使用裝飾器提供上下文

我們的 Storybook 故事因為這項變更而停止運作,因為我們的 Tasklist 現在是一個已連接的元件,因為它依賴 Redux store 來檢索和更新我們的任務。

Broken tasklist

我們可以使用各種方法來解決這個問題。儘管如此,由於我們的應用程式非常簡單,我們可以像在上一章中所做的那樣,依賴裝飾器,並在我們的 Storybook 故事中提供一個模擬的 store。

複製
src/components/TaskList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';

import type { TaskData } from '../types';

import TaskList from './TaskList';

import * as TaskStories from './Task.stories';

import { Provider } from 'react-redux';

import { configureStore, createSlice } from '@reduxjs/toolkit';

// 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 設定欄位,可防止我們的模擬狀態被視為故事。你可以在 Storybook 文件中閱讀更多關於此欄位的資訊。

💡 別忘了使用 git 提交你的變更!

成功!我們回到了開始的地方,我們的 Storybook 現在可以運作了,而且我們可以看到我們如何將資料供應到已連接的元件中。在下一章中,我們將運用我們在這裡學到的知識並將其應用於畫面。

讓你的程式碼與本章保持同步。在 GitHub 上檢視 f9eaeef。
發推文以示讚賞,並幫助其他開發人員找到它。
下一章
畫面
使用元件建構畫面
✍️ 在 GitHub 上編輯 – 歡迎提交 PR!
加入社群
6,721位開發人員及持續增加中
為何為何選擇 Storybook元件驅動的 UI
開放原始碼軟體
Storybook - Storybook 繁體中文

特別感謝 Netlify CircleCI