添加容器组件

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

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

使用完整 Model

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

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

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,
  };
};
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,获取数据:

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 值作为数据源:

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

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

display

抽离容器组件

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

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

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

macOS
Windows
mkdir -p src/containers
touch src/containers/Contacts.tsx

我们将原本两个 page.tsx 中公共的部分抽离出来,src/containers/Contacts.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.tsxsrc/routes/archives/page.tsx 的代码:

src/routes/page.tsx
import Contacts from '../containers/Contacts';

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

export default Index;
src/routes/archives/page.tsx
import Contacts from '../../containers/Contacts';

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

export default Index;

重构完成,现在的项目结构是:

.
├── 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) 章节提到过,视图组件用目录形式,是因为视图组件负责实现 UI 展示和交互细节,可以演变的复杂。用目录形式,可以方便增加子文件,包括专用的资源(图片等)、专用子组件、CSS 文件等。在这个目录内部可以随意重构,只考虑最小局部。

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