---
robots: noindex, nofollow
tags: React
---
# 在 ASP.NET MVC 中使用 React 元件 (3)
隨著將佈告欄改版成 React 版,專案結構漸漸清晰了起來。按一開始的規劃,我希望提供的是一個可以逐漸改善的結構,而不是將現有的程式和商業邏輯直接拋棄。
要完整利用 ReactJS.NET 的好處, ASP.NET 要提供:
* 靜態 i18n 資料
* 靜態 app settings 資料
* API endpoints
* 第一次繪製時的 React 程式內部狀態
如果是一個全新的案子,這些資料(不管在 frontend 或 backend ),都能透過 API 取得。
但為了配合 MVC 架構,會在 `cshtml` View 裡準備好 i18n, app settings, API endpoints 資料,並按不同的 route ,準備不同的 React 程式內部狀態。
## 設計方向
剛開始規劃時,希望可以靠 TypeScript 在 IDE 中提示的訊息輔助開發,在幾次重構嘗試後,決定讓 ASP.NET 暴露出四種資料。也就是前面提到的 i18n 資料、 app settings 資料、 API endpoints ,和第一次繪製 React 程式時的內部狀態(在程式碼裡面叫做 `InitialStore` )。
其中 i18n 資料、 app settings 資料、 API endpoints 資料,多半是單純的 key-value dictionary ,所以在最上層的 `src/contexts/` 內定義了它們基本的形狀,還有用來操作它們的函數。
到了各個 app 中,再根據每個 app 的環境,補上額外的限制。
### i18n
例如我只知道 i18n 資料是 `string` 對應到 `string` 的 dictionary ,還不知道有哪些具體的 key ,於是 `src/contexts/i18n.ts` 中,只寫明:
```typescript=
type I18nObject = ObjectMap<string>;
```
並準備帶參數 `T` 的 `Translation` interface ,和加上了字串替換功能的 `useTranslation` ,讓各個 app 自行擴充:
```typescript=
export interface Translation<T> {
t: (key: T, interpolation?: InterpolationObject | Array<string>) => string;
}
export const useTranslation = <T extends string>(): Translation<T> => {
const i18nMap = useContext(I18nContext);
const t = useCallback((key: T, interpolation?: InterpolationObject | Array<string>) => {
let value = i18nMap[key];
if (!value) return key;
if (!interpolation) return value;
const obj = interpolation as InterpolationObject;
// 根據 interpolation object 或是 interpolation array 提供的資訊,替換 i18n 字串中的 {0} 或是 {foobar}
for (let key in obj) {
const fragment = obj[key];
value = value.replace(new RegExp(`\\{${key}\\}`, 'g'), fragment);
}
return value;
}, [i18nMap]);
// 模仿 i18next 的介面
return useMemo(() => ({ t }), [t]);
};
```
曝露出來的 `t` ,盡可能貼近 i18next 與 ASP.NET 現在的介面,讓未來有機會把 i18n 資料,都搬到 React 前端 app 這裡來。
在 React app 中,再引用準備好的 `I18nKey` ,來替 `useTranslation` 設下額外限制:
```typescript=
export const useTranslation: () => C.Translation<I18nKey> = C.useTranslation;
```
### app settings
同理 `src/contexts/app-settings.ts` 也只提供了最基本的形狀:
```typescript=
export type AppSettings = ObjectMap<any>;
```
再讓 React app 的 `useAppSettings` 補上更多資訊:
```typescript=
export const useAppSettings: () => AppSettings = C.useAppSettings() as AppSettings;
```
### API endpoints
ASP.NET 只會提供 React app API endpoints URL ,並不包含該怎麼呼叫各個 API ,但在 React app 中,我們希望可以直接使用已經用 type 描述好輸入和輸出的 API 函數,於是分成兩部分處理。
先在 `src/contexts/actions.ts` 裡面描述怎麼把 API URL 和 API 函數對應函數 `boilerplate: (x: T) => U` 組合起來:
```typescript=
export const useActions = <T extends Actions, U>(boilerplate: (x: T) => U) => {
const actions = useContext(ActionsContext) as T;
return boilerplate(actions);
};
```
到了 React app 自己的 `contexts/` ,再組合出可以直接取用 API 函數的 `useActions` :
```typescript=
export const boilerplate = (actions: Actions) => ({
read: read(actions.read),
list: list(actions.list),
listCurrent: listCurrent(actions.listCurrent),
create: createOrUpdate(actions.create),
update: createOrUpdate(actions.update),
remove: remove(actions.remove),
removeList: removeList(actions.removeList),
removeFile: removeFile(actions.removeFile),
getAttachmentUrl: getAttachmentUrl(actions.fileAttachment),
});
export const useActions = () => C.useActions(boilerplate);
```
### initial store
每個 React app 第一次繪製時需要的狀態都不同,無法在 `src/contexts/` 內先大概描述它的形狀、之後再擴充。
這部分請直接參閱 `src/apps/AnnouncementApp/dotnet.ts` 內的 `InitialStore` 定義,每個欄位都有對應的註解。
## ASP.NET 該暴露些什麼?
決定好 React app 該收到什麼資料後,就可以準備 ASP.NET 該暴露出什麼資料給 React app 元件。
### i18n
只要條列當前 React app 需要的 i18n key-value dictionary 即可:
```csharp=
var i18nKeys = new List<string>
{
"Announcement.All",
"Announcement.PublishContext",
// 略
"StoreLicenseModel.OutOfStorage",
};
var i18n = i18nKeys.ToDictionary(key => key, key => key.ToI18n());
```
### app settings
暴露 React app 需要的 object 即可:
```csharp=
var appSettings = new
{
storeSiteName = LogicalThreadUtility.StoreSiteName,
partyName = partyName,
dateTimeFormat = new {
shortDatePattern = DateTimeFormat.ShortDatePattern,
shortTimePattern = DateTimeFormat.ShortTimePattern,
shortDateNoYearPattern = DateTimeFormat.ShortDateNoYearPattern,
},
gridPageSize = AppSettings.GridPageSize,
};
```
### API endpoints
取得每個 action 的 URL 後,一樣暴露 object 即可:
```csharp=
var actions = new
{
read = Url.Action("Get", "Announcement"),
list = Url.Action("_AjaxBinding", "Announcement"),
listCurrent = Url.Action("CurrentList", "Announcement"),
create = Url.Action("Create", "Announcement"),
update = Url.Action("Edit", "Announcement"),
remove = Url.Action("Delete", "Announcement"),
removeList = Url.Action("MultiDelete", "Announcement"),
removeFile = Url.Action("DeleteUploadedFile", "Announcement"),
fileAttachment = Url.Action("DownloadAttachmentFile", "System"),
};
```
### initial store
目前直接將 React app 頁面的 view model 當成 initial store 。
最後將上述資料整理好,一次交給 React app 元件:
```csharp=
@Html.ReactRouter(
"AnnouncementApp",
new {
i18n,
appSettings,
actions,
store = Model,
}
)
```
## 內部狀態
上述資料都備齊後,會根據它們產生出 React 真正使用到的內部狀態 `Store` 。
以公佈欄為例,整個 app 用到的 `Store` ,是在 `src/apps/AnnouncementApp/App.tsx` 裡的 `AppContext` 元件中組合完成的。組合時會按需要正規化一部分資料,避免不同的 React 元件重複儲存同樣的資料。
除了內部狀態,也一併暴露了操作這些狀態用的 function ,例如: `setSearch` 或是 `refreshList` 。
```typescript=
interface AppContextProps {
store: InitialStore,
children?: ReactNode,
}
const AppContext = ({ store, children }: AppContextProps) => {
const actions = useActions();
const appSettings = useAppSettings();
const [search, setSearch] = useState(store.Search);
useSearchConditions(search); // 同步 search conditions 到 URL query string 上
const [query, setQuery] = useState({
...emptyDataQuery,
pageSize: appSettings.gridPageSize,
});
// Id 公告對應表
const [announcements, setAnnouncements] = useState<ObjectMap<Announcement>>(store.Announcements);
// 準備一個可以用公告列表更新公告對應表的函數
const updateAnnouncements = useCallback((dataSource: DataSource<Announcement>) => {
const newMap = objectMapFromDataSource(dataSource);
setAnnouncements({ ...announcements, ...newMap });
}, [announcements]);
const [idList, setIdList] = useState(store.IdList);
const list = useMemo(
() => mapDataSource(idList, (id) => announcements[id] ?? emptyAnnouncement),
[idList, announcements],
);
const setList = useCallback((list: DataSource<Announcement>) => {
const idList = mapDataSource(list, (anno) => anno.Id!);
updateAnnouncements(list);
setIdList(idList);
}, [updateAnnouncements, setIdList]);
// 準備一個自動帶入 search 和 query 好更新公告列表的函數
const refreshList = useCallback(async () => {
const list = await actions.list(search, query);
setList(list);
return list;
}, [actions, search, query]);
const s = {
announcements,
updateAnnouncements,
search,
setSearch,
query,
setQuery,
list,
setList,
setIdList,
refreshList,
};
return (
<StoreContext.Provider value={s}>
{children}
</StoreContext.Provider>
);
};
```
## 實際案例
下面是公佈欄的 `Show` 頁面,這個頁面會從 URL 得到公告 id ,並呈現公告內容。
可以看到它如何靠 `useTranslation` 的 `t` 來提供 i18n 內容。
靠 `useActions` 取得刪除公告用的 API function `actions.remove` 。
還有以 `useStore` 取得 React 內部狀態,與操作內部狀態的 `store.refreshList` 。
```typescript=
import { useState, useCallback } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { Dialog, DialogActionsBar } from '@progress/kendo-react-dialogs';
import dayjs from 'dayjs';
import { AnnouncementDetails } from '../bulletin-board';
import { ShowHeader as Header } from './ShowHeader';
import { OverlayLoader } from '../../../components';
import { Body, DialogContent } from '../Layout';
import * as Anno from '../../../types/announcement';
import { useTranslation, useActions, useStore } from '../contexts';
import { useAnnouncement } from '../hooks';
function isEmpty<T>(x: T | null | undefined): x is T {
return x === null || x === undefined;
}
export const ShowPage = () => {
const history = useHistory();
const params = useParams<{ aid: string }>();
const { t } = useTranslation();
const actions = useActions();
const store = useStore();
const [isLoading, setLoading] = useState(false);
const data = useAnnouncement(params.aid);
const {
Info: {
CreateDate = '',
CreateUser = '',
IsModified = false,
ModifyDate = '',
ModifyUser = '',
} = {},
CloudId = null,
PublishDate = undefined,
FromDate = '',
ThruDate = '',
Importance = Anno.ImportanceType.Low,
Title = '',
Content = '',
FileAttachments = [],
} = data ?? {};
// 公告尚未過期且非來自雲端, 可修改
const isEditable =
dayjs().isBefore(dayjs(ThruDate, Anno.DATE_FORMAT)) && isEmpty(CloudId);
const [showRemoveDialog, setRemoveDialog] = useState(false);
const handleRemove = useCallback(async () => {
setRemoveDialog(false);
setLoading(true);
try {
await actions.remove(params.aid);
setLoading(false);
history.push('../Index');
store.refreshList();
} catch (err) {
console.error(err);
setLoading(false);
}
}, [history, actions.remove, params.aid]);
return (
<>
<Header
editable={isEditable}
onCreate={() => history.push('../Create')}
onEdit={() => history.push(`../Edit/${params.aid}`)}
onCopy={() => history.push(`../Copy/${params.aid}`)}
onDelete={() => setRemoveDialog(true)}
onCancel={() => history.push('../Index')}
/>
<Body>
<AnnouncementDetails
cloudId={CloudId}
publishAt={PublishDate}
startsAt={FromDate}
endsAt={ThruDate}
importance={Importance}
title={Title}
content={Content}
createdBy={CreateUser}
createdAt={CreateDate}
editedBy={IsModified ? ModifyUser : undefined}
editedAt={IsModified ? ModifyDate : undefined}
attachments={FileAttachments}
getAttachmentUrl={actions.getAttachmentUrl}
/>
{isLoading && <OverlayLoader />}
</Body>
{showRemoveDialog &&
<Dialog
title={t('Com.Delete')}
onClose={() => setRemoveDialog(false)}
>
<DialogContent>
<p>{t('Com.DeleteConfirm')}</p>
</DialogContent>
<DialogActionsBar>
<button
className="k-button"
onClick={() => setRemoveDialog(false)}
>
{t('Com.Cancel')}
</button>
<button
className="k-button"
onClick={handleRemove}
>
{t('Com.Delete')}
</button>
</DialogActionsBar>
</Dialog>
}
</>
);
};
```