添加容器组件
上一章节中,我们初步引入业务模型,从 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
中:
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 按钮后,可以看到按钮置灰:
接下来点击顶部导航,切换到 Archives 列表,可以发现刚才 Archive 的联系人已经出现在列表当中:
抽离容器组件
前面章节中,我们把项目中的业务逻辑拆分成了两个 layer,一个是视图组件,另一个是业务模块。前者负责 UI 展示、交互等,后者负责实现 UI 无关的业务逻辑,专门管理状态。
像 src/routes/page.tsx
和 src/routes/archives/page.tsx
这样使用了 useModel
API 的组件,负责把 View 和 Model 这两个 layer 连接起来,类似传统 MVC 架构中 Controller 的角色,在 Modern.js 里我们沿用习惯,把它们称作容器组件(Container)。
容器组件推荐放在专门的 containers/
目录里,我们执行以下命令,创建新的文件:
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.tsx
和 src/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 层去实现。容器组件自己应该保持简单清晰,不应该包含复杂实现细节,所以不应该有内部结构。采用单文件形式不但更简洁,也能起到约束作用,提醒开发者不要把容器组件写复杂。