跳转到主文档

​完整使用 Model

上一章节中,我们初步引入客户端应用架构,从【 视图组件 】中拆分出【 业务模型(Model)】,AllContacts 中不再包含 UI 无关的业务逻辑实现细节,只需要使用 Model,就能实现同样的功能。

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

先改造 Item 组件,增加 Archive 按钮的交互实现:

src/contacts/components/Item/index.tsx
import Avatar from '../Avatar';

type InfoProps = {
avatar: string;
name: string;
email: string;
archived?: boolean;
};

const Item = ({
info,
onArchive,
}: {
info: InfoProps;
onArchive?: () => void;
}) => {
const { avatar, name, email, archived } = info;
return (
<div className="flex p-4 items-center border-gray-200 border-b">
<Avatar src={avatar} />
<div className="ml-4 custom-text-gray flex-1 flex justify-between">
<div className="flex-1">
<p>{name}</p>
<p>{email}</p>
</div>
<button
type="button"
disabled={archived}
onClick={onArchive}
className={`text-white font-bold py-2 px-4 rounded-full ${
archived
? 'bg-gray-400 cursor-default'
: 'bg-blue-500 hover:bg-blue-700'
}`}>
{archived ? 'Archived' : 'Archive'}
</button>
</div>
</div>
);
};

export default Item;

ArchivedContactsAllContacts 需要共用同一套状态(联系人列表数据、联系人是否被归档),并且由于 Archives 列表和 All 列表都可能是第一屏页面(从不同 URL 访问),这两个组件都需要包含加载初始数据的逻辑(如果客户端没有联系人列表数据,就请求 BFF),所以这类两个组件公用的实现逻辑应该合并到一起:

我们删除原有的两个组件,创建一个新的 Contacts 组件:

rm -r src/contacts/components/*Contacts
mkdir -p src/contacts/components/Contacts/
touch src/contacts/components/Contacts/index.tsx

修改components/Contacts/index.tsx ,内容如下:

src/contacts/components/Contacts/index.tsx
import { useEffect } from 'react';
import { useLocalModel } from '@modern-js/runtime/model';
import { List } from 'antd';
import contacts from '../../models/contacts';
import Item from '../Item';

const Contacts = ({ source }: { source: 'archived' | 'items' }) => {
const [state, actions] = useLocalModel(contacts);
const { items, error, pending } = state;
useEffect(() => {
if (!items.length && !error && !pending) {
actions.load();
}
});

const data = state.items.filter(item =>
source === 'archived' ? item.archived : true,
);

return (
(items.length && (
<List
dataSource={data}
renderItem={info => (
<Item
key={info.email}
info={info}
onArchive={() => {
actions.archive(info.email);
}}
/>
)}
/>
)) || (
<div className="p-4 items-center border-gray-200 border-b border-t custom-text-gray">
Pending...
</div>
)
);
};

export default Contacts;

由于 computed 功能还未提供,这里先在组件里将传入的数据做预处理。

最后改造 App.tsx,利用 Contacts 实现 Archives 列表和 All 列表:

src/contacts/App.tsx
import { useState } from 'react';
import { Radio, RadioChangeEvent } from 'antd';
import { Route, useHistory } from '@modern-js/runtime/router';
import { Helmet } from '@modern-js/runtime/head';
import 'tailwindcss/base.css';
import 'tailwindcss/components.css';
import 'tailwindcss/utilities.css';
import './styles/utils.css';
import Contacts from './components/Contacts';

function App() {
const history = useHistory();
const [currentList, setList] = useState(history.location.pathname || '/');
const handleSetList = (e: RadioChangeEvent) => {
const { value } = e.target;
setList(value);
history.push(value);
};

return (
<div className="container lg mx-auto">
<div className="h-16 p-2 flex items-center justify-center">
<Radio.Group onChange={handleSetList} value={currentList}>
<Radio value="/">All</Radio>
<Radio value="/archives">Archives</Radio>
</Radio.Group>
</div>
<Route path="/" exact={true}>
<Helmet>
<title>All</title>
</Helmet>
<Contacts source="items" />
</Route>
<Route path="/archives" exact={true}>
<Helmet>
<title>Archives</title>
</Helmet>
<Contacts source="archived" />
</Route>
</div>
);
}

export default App;

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

display

接下来点击顶部导航,切换到 Archives 列表,我们预期的时候能看到列表里显示刚才归档的联系人,但实际上列表是空的:

display7

出现这个问题的原因是,我们继续沿用了上一节的 useLocalModel API 来使用 Model,状态被保存到了组件内部的 state 里,而 All 列表和 Archives 列表中分别调用的 Contacts 组件,是两个各自独立的组件:

src/contacts/App.tsx
<Route path="/" exact={true}>
<Helmet>
<title>All</title>
</Helmet>
<Contacts source="items" />
</Route>
<Route path="/archives" exact={true}>
<Helmet>
<title>Archives</title>
</Helmet>
<Contacts source="archived" />
</Route>

所以它们有各自独立的内部 state,互相不共享状态,渲染 Archives 列表的时候,items 仍然是初始状态。

要解决这个问题,一种方式把 useLocalModel 的逻辑提升到父组件里,把状态分别传给两个 Contacts 组件。更清晰、完善的方式,是启用全局唯一的「 应用状态 」,两个 Contacts 组件「 连接 」应用状态。

在 Modern.js 里实现应用状态管理很简单,只需要把 useLocalModel 换成 useModel

修改 components/Contacts/index.tsx 的内容:

src/contacts/components/Contacts/index.tsx
import { useEffect } from 'react';
import { useModel } from '@modern-js/runtime/model';
import { List } from 'antd';
import contacts from '../../models/contacts';
import Item from '../Item';

const Contacts = ({ source }: { source: 'archived' | 'items' }) => {
const [state, actions] = useModel(contacts);
const { items, error, pending } = state;
useEffect(() => {
if (!items.length && !error && !pending) {
actions.load();
}
});

const data = state.items.filter(item =>
source === 'archived' ? item.archived : true,
);

return (
(items.length && (
<List
dataSource={data}
renderItem={info => (
<Item
key={info.email}
info={info}
onArchive={() => {
actions.archive(info.email);
}}
/>
)}
/>
)) || (
<div className="p-4 items-center border-gray-200 border-b border-t custom-text-gray">
Pending...
</div>
)
);
};

export default Contacts;

重新执行 pnpm run dev,重复刚才的操作,可以看到 Archives 列表能正常显示了:

display1

useModel API 还可以设置 Selector,只连接这个 Model 定义的状态中的局部。


本小节的代码可以在这里查看