--- 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> } </> ); }; ```