---
# System prepended metadata

title: 在 ASP.NET MVC 中使用 React 元件 (3)
tags: [React]

---

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