---
title: 添加容器组件
---
# 添加容器组件

import { Tabs, Tab as TabItem } from "@theme";

上一章节中，我们初步引入**业务模型**，从 UI 组件中拆分出这部分逻辑。`page.tsx` 中不再包含 UI 无关的业务逻辑实现细节，只需要使用 Model，就能实现同样的功能。

这一章节中，我们要进一步利用 Model 中实现的业务逻辑，让 `page.tsx` 和 `archived/page.tsx` 获取同一份数据。并实现 Archive 按钮，点击按钮能把联系人归档，只显示在 Archives 列表里，不显示在 All 列表里。

## 使用完整 Model

因为两个页面需要共用同一套状态（联系人列表数据、联系人是否被归档），都需要包含加载初始数据的逻辑，因此我们需要在更上一层完成数据获取。

Modern.js 支持在 `layout.tsx` 通过 Data Loader 获取数据，我们先数据获取这部分代码移动到 `src/routes/layout.tsx` 中：

```ts title="src/routes/layout.data.ts"
export type LoaderData = {
  code: number;
  data: {
    name: string;
    avatar: string;
    email: string;
  }[];
};

export const loader = async (): Promise<LoaderData> => {
  const data = new Array(20).fill(0).map(() => {
    const firstName = name.firstName();
    return {
      name: firstName,
      avatar: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${firstName}`,
      email: internet.email(),
    };
  });

  return {
    code: 200,
    data,
  };
};
```

```tsx title="src/routes/layout.tsx"
import { name, internet } from 'faker';
import {
  Outlet,
  useLoaderData,
  useLocation,
  useNavigate,
} from '@modern-js/runtime/router';
import { useState } from 'react';
import { Radio, RadioChangeEvent } from 'antd';
import { useModel } from '@modern-js/runtime/model';
import contacts from '../models/contacts';
import 'tailwindcss/base.css';
import 'tailwindcss/components.css';
import 'tailwindcss/utilities.css';
import '../styles/utils.css';
import type { LoaderData } from './layout.data';

export default function Layout() {
  const { data } = useLoaderData() as LoaderData;
  const [{ items }, { setItems }] = useModel(contacts);
  if (items.length === 0) {
    setItems(data);
  }

  const navigate = useNavigate();
  ...
}
```

在 `src/routes/page.tsx` 中，直接使用 Model，获取数据：

```tsx
import { Helmet } from '@modern-js/runtime/head';
import { useModel } from '@modern-js/runtime/model';
import { List } from 'antd';
import Item from '../components/Item';
import contacts from '../models/contacts';

function Index() {
  const [{ items }, { archive }] = useModel(contacts);

  return (
    <div className="container lg mx-auto">
      <Helmet>
        <title>All</title>
      </Helmet>
      <List
        dataSource={items}
        renderItem={info => (
          <Item
            key={info.name}
            info={info}
            onArchive={() => {
              archive(info.email);
            }}
          />
        )}
      />
    </div>
  );
}

export default Index;
```

同样在 `archived/page.tsx` 中，删除原本的 `mockData` 逻辑，使用 Model 中 computed 的 `archived` 值作为数据源：

```tsx
import { Helmet } from '@modern-js/runtime/head';
import { useModel } from '@modern-js/runtime/model';
import { List } from 'antd';
import Item from '../../components/Item';
import contacts from '../../models/contacts';

function Index() {
  const [{ archived }, { archive }] = useModel(contacts);

  return (
    <div className="container lg mx-auto">
      <Helmet>
        <title>Archives</title>
      </Helmet>
      <List
        dataSource={archived}
        renderItem={info => (
          <Item
            key={info.name}
            info={info}
            onArchive={() => {
              archive(info.email);
            }}
          />
        )}
      />
    </div>
  );
}

export default Index;
```

执行 `pnpm run dev`，访问 `http://localhost:8080/`，点击 Archive 按钮后，可以看到按钮置灰：

![display](https://lf3-static.bytednsdoc.com/obj/eden-cn/nuvjhpqnuvr/modern-website/tutorials/c07-contacts-all.png)

接下来点击顶部导航，切换到 Archives 列表，可以发现刚才 **Archive** 的联系人已经出现在列表当中：

![display](https://lf3-static.bytednsdoc.com/obj/eden-cn/nuvjhpqnuvr/modern-website/tutorials/c07-contacts-archives.png)

## 抽离容器组件

前面章节中，我们把项目中的业务逻辑拆分成了两个 layer，一个是**视图组件**，另一个是**业务模块**。前者负责 UI 展示、交互等，后者负责实现 UI 无关的业务逻辑，专门管理状态。

像 `src/routes/page.tsx` 和 `src/routes/archives/page.tsx` 这样使用了 `useModel` API 的组件，负责把 View 和 Model 这两个 layer 连接起来，类似传统 MVC 架构中 Controller 的角色，在 Modern.js 里我们沿用习惯，把它们称作**容器组件（Container）**。

容器组件推荐放在专门的 `containers/` 目录里，我们执行以下命令，创建新的文件：

<Tabs>
<TabItem value="macOS" label="macOS" default>

```bash
mkdir -p src/containers
touch src/containers/Contacts.tsx
```

</TabItem>
<TabItem value="Windows" label="Windows">

```powershell
mkdir -p src/containers
ni src/containers/Contacts.tsx
```

</TabItem>
</Tabs>

我们将原本两个 `page.tsx` 中公共的部分抽离出来，`src/containers/Contacts.tsx` 的代码如下：

```tsx
import { Helmet } from '@modern-js/runtime/head';
import { useModel } from '@modern-js/runtime/model';
import { List } from 'antd';
import Item from '../components/Item';
import contacts from '../models/contacts';

function Contacts({
  title,
  source,
}: {
  title: string;
  source: 'items' | 'archived';
}) {
  const [state, { archive }] = useModel(contacts);

  return (
    <div className="container lg mx-auto">
      <Helmet>
        <title>{title}</title>
      </Helmet>
      <List
        dataSource={state[source]}
        renderItem={info => (
          <Item
            key={info.name}
            info={info}
            onArchive={() => {
              archive(info.email);
            }}
          />
        )}
      />
    </div>
  );
}

export default Contacts;
```

修改 `src/routes/page.tsx` 和 `src/routes/archives/page.tsx` 的代码：

```tsx title="src/routes/page.tsx"
import Contacts from '../containers/Contacts';

function Index() {
  return <Contacts title="All" source="items" />;
}

export default Index;
```

```tsx title="src/routes/archives/page.tsx"
import Contacts from '../../containers/Contacts';

function Index() {
  return <Contacts title="Archives" source="archived" />;
}

export default Index;
```

重构完成，现在的项目结构是：

```bash
.
├── README.md
├── dist
├── modern.config.ts
├── node_modules
├── package.json
├── pnpm-lock.yaml
├── src
│   ├── components
│   │   ├── Avatar
│   │   │   └── index.tsx
│   │   └── Item
│   │       └── index.tsx
│   ├── containers
│   │   └── Contacts.tsx
│   ├── models
│   │   └── contacts.ts
│   ├── modern-app-env.d.ts
│   ├── routes
│   │   ├── archives
│   │   │   └── page.tsx
│   │   ├── layout.tsx
│   │   └── page.tsx
│   └── styles
│       └── utils.css
└── tsconfig.json
```

`components/` 里的**视图组件**，都是目录形式，如 `Avatar/index.tsx`。而 `containers/` 里的**容器组件**，都是单文件形式，如 `contacts.tsx`。**这是我们推荐的一种最佳实践**。

在 ​ [添加 UI 组件（Component）](./c02-component.mdx) 章节提到过，视图组件用目录形式，是因为视图组件负责实现 UI 展示和交互细节，可以演变的复杂。用目录形式，可以方便增加子文件，包括专用的资源（图片等）、专用子组件、CSS 文件等。在这个目录内部可以随意重构，只考虑最小局部。

而容器组件只负责连接，是一个胶水层，复杂的业务逻辑和实现细节都交给 View 层和 Model 层去实现。容器组件自己应该保持简单清晰，不应该包含复杂实现细节，所以不应该有内部结构。采用单文件形式不但更简洁，也能起到约束作用，提醒开发者不要把容器组件写复杂。
