---
# System prepended metadata

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

---

---
robots: noindex, nofollow
tags: React
---

# 在 ASP.NET MVC 中使用 React 元件 (2)

接著要做到：

* 靠 TypeScript 描述資料間的關係
* 切分我們自己的 script 和來自第三方的 script
* 前端工程師仍可以啟動 Storybook 、和設計師一起準備 UI 元件
* 後端工程師可以準備 [server-side data][server-side-data] 讓 UI 元件取用

[server-side-data]: https://reactjs.net/tutorials/aspnet4.html#server-side-data

## 導入 TypeScript

安裝套件：

```shell=
yarn add --dev typescript @babel/preset-typescript @types/react @types/react-dom
```

再產生 `tsconfg.json` ：

```shell=
npx tsc --init
```

接著把現有的 `*.js`, `*.jsx` 改成 `*.ts`, `*.tsx` ，因為我們的元件還很簡單， TypeScript 應該可以自動推導出型別。

接著修改 `webpack.config.js` 的進入點：

```diff=
module.exports = {
   mode: 'development',
-  entry: './src/index.js',
+  entry: './src/index.ts',
   output: {
     path: webUIScriptPath,
     filename: 'client.bundle.js',
```

和 resolve 不同檔案的規則：

```diff=
   resolve: {
-    extensions: ['.js', '.jsx'],
+    extensions: ['.js', '.jsx', '.ts', '.tsx'],
   },
   module: {
     rules: [
+      {
+        test: /\.tsx?$/,
+        exclude: /node_modules/,
+        use: {
+          loader: 'babel-loader',
+          options: {
+            presets: [
+              '@babel/preset-env',
+              '@babel/preset-react',
+              '@babel/preset-typescript',
+            ],
+          },
+        },
+      },
       {
         test: /\.m?jsx?$/,
         exclude: /node_modules/,
```

## 靠 npm/yarn 管理 React 與 ReactDOM

為了避免 ReactJS.NET 在 SSR 時抱怨我們 import 的 external React, ReactDOM ，加上下一步我們會把產生的 js 分為「來自應用程式的 script 」與「第三方的 script 」兩塊，還是決定讓 npm/yarn 來管理 React 。

先安裝套件：

```shell=
yarn add react react-dom
```

再移除掉 `webpack.config.js` 中的 `externals` ：

```diff=
   },
-  externals: {
-    'react': 'React',
-    'react-dom': 'ReactDOM',
-  },
   resolve: {
```

然後到 `App_Start/ReactConfig.cs` 中，把 React 關掉：

```diff=
     ReactSiteConfiguration.Configuration
             .SetReuseJavaScriptEngines(true)
             .SetAllowJavaScriptPrecompilation(true)
+            .SetLoadReact(false)
             .SetLoadBabel(false)
             .AddScriptWithoutTransform("~/Scripts/client.bundle.js");
```

並在 `index.ts` 中暴露 React 相關套件：

```diff=
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactDOMServer from 'react-dom/server';
 import { Test } from './components';
 
+// setup global variables for server-side rendering
+global.React = React;
+global.ReactDOM = ReactDOM;
+global.ReactDOMServer = ReactDOMServer;
 
 // expose components
 export { Test };
```

這樣 ReactJS.NET 才能正確取用它們。

最後拿掉 view 中的 React script tags ：

```diff=
         @Html.React("VitalUI.Test", new {})
-        <script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
-        <script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
         @Scripts.Render("~/Scripts/client.bundle.js")
```

## 分割程式碼

要針對不同頁面（進入點），產生不同的 js ，首先得修改 `webpack.config.js` ：

```diff=
   mode: 'development',
-  entry: './src/index.ts',
+  entry: {
+    index: './src/index.ts',
+    settings: './src/settings.ts',
+  },
   output: {
     path: webUIScriptPath,
-    filename: 'client.bundle.js',
-    libraryTarget: 'global',
-    library: 'VitalUI',
+    filename: '[name].bundle.js',
+    library: 'ClientReact',
     globalObject: 'this',
   },
+  optimization: {
+    runtimeChunk: 'single',
+    splitChunks: {
+      chunks: 'initial',
+      cacheGroups: {
+        vendor: {
+          name: 'vendor',
+          test: /node_modules/,
+        },
+      },
+    },
+  },
   resolve: {
```

可以看見 `entry` 變成了兩個。

這樣改完後，每次 webpack 會幫我們生出給特定頁面用的 `index.bundle.js`, `settings.bundle.js` ，並生出共用的 `runtime.bundle.js` 和 `vendor.bundle.js` 。

其中 `runtime.bundle.js` 是 webpack 橋接不同的 script 檔用的，而 `vendor.bundle.js` 是 React, ReactDOM 等第三方函式庫。

但開啟 `SplitChunksPlugin` 後， webpack 無法幫我們自動暴露全域物件給 ReactJS.NET 用，所以關掉了 `library` 相關設定：


```diff=
   output: {
     path: webUIScriptPath,
-    filename: 'client.bundle.js',
-    libraryTarget: 'global',
-    library: 'VitalUI',
+    filename: '[name].bundle.js',
+    library: 'ClientReact',
     globalObject: 'this',
   },
```

並在 `index.ts` 等 entry points 裡面，自己暴露出要給 ReactJS.NET 用的物件：

```javascirpt=
// global.ts
import React from 'react';
import ReactDOM from 'react-dom';
import ReactDOMServer from 'react-dom/server';

// setup global variables for server-side rendering
global.React = React;
global.ReactDOM = ReactDOM;
global.ReactDOMServer = ReactDOMServer;
```

```javascript=
// index.ts
import './global';
import { Test } from './components';

(global as any).Test = Test;
```

現在我們可以回頭修改 `App_Start/ReactConfig.cs` ：

```diff=
         .SetAllowJavaScriptPrecompilation(true)
         .SetLoadReact(false)
         .SetLoadBabel(false)
-        .AddScriptWithoutTransform("~/Scripts/client.bundle.js");
+        .AddScriptWithoutTransform("~/Scripts/runtime.bundle.js")
+        .AddScriptWithoutTransform("~/Scripts/vendor.bundle.js")
+        .AddScriptWithoutTransform("~/Scripts/index.bundle.js");

     JsEngineSwitcher.Current.DefaultEngineName = V8JsEngine.EngineName;
     JsEngineSwitcher.Current.EngineFactories.AddV8();
```

與 view ：

```diff=
     <body>
-        @Html.React("VitalUI.Test", new {})
-        @Scripts.Render("~/Scripts/client.bundle.js")
+        @Html.React("Test", new {})
+        @Scripts.Render("~/Scripts/runtime.bundle.js")
+        @Scripts.Render("~/Scripts/vendor.bundle.js")
+        @Scripts.Render("~/Scripts/index.bundle.js")
         @Html.ReactInitJavaScript()
     </body>
```

## 安裝 Storybook

[Storybook][storybook] 的安裝工具十分強大，我們只要下：

```shell=
npx sb init
```

它就會自動偵測我們的 repo 中使用到的技術，來初始化 Storybook 。

Storybook 會產生 `.storybook/` 資料夾，放相關的設定，並產生 `src/stories/` 資料夾，放各種案例。

要注意的是，如果要 export types 給 Storybook 使用，記得寫成 `export type { Foobar }` ，讓 webpack 不會把該名稱當成 value 而試著 export 它。

```diff=
-import { TestProps, Test } from './Test';
+import { Test } from './Test';
+import type { TestProps } from './Test';

-export { TestProps, Test };
+export { Test };
+export type { TestProps };
```

[storybook]: https://storybook.js.org/

## 讓 React 元件可以參考到原專案的圖片檔

先安裝 `url-loader` ：

```shell=
yarn add --dev url-loader
```

接著把專案路徑整理到一個獨立的檔案，例如 `paths.js` ：

```javascript=
const path = require('path');

const webUIPath = path.resolve(__dirname, '../src/Store/Gss.Crm.Store.WebUI');

module.exports = {
  webUI: {
    scripts: path.resolve(webUIPath, 'Scripts'),
    images: path.resolve(webUIPath, 'Content/images'),
  },
};
```

然後修改 `webpack.config.js` 修正 build 出來的圖片路徑：

```diff=
     },
   },
   resolve: {
-    extensions: ['.js', '.jsx', '.ts', '.tsx'],
+    alias: {
+      images: paths.webUI.images,
+    },
+    extensions: ['.js', '.jsx', '.ts', '.tsx', '.png', '.jpg', '.jpeg', '.gif'],
   },
   module: {
     rules: [
@@ -58,6 +59,10 @@ module.exports = {
           },
         },
       },
+      {
+        test: /\.(png|jpg|jpeg|gif)$/,
+        loader: 'url-loader',
+      },
     ],
   },
 };
```

並修改 `.storybook/main.js` 讓 Storybook 能找到圖片：

```diff=
   "addons": [
     "@storybook/addon-links",
     "@storybook/addon-essentials"
-  ]
-}
\ No newline at end of file
+  ],
+  "webpackFinal": async (config, { configType }) => {
+    return {
+      ...config,
+      resolve:{
+        ...config.resolve,
+        alias: {
+          ...config.resolve.alias,
+          images: paths.webUI.images,
+        },
+      },
+    };
+  },
+}
```

最後修改 `tsconfig.json` ：

```diff=
     // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
-    // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+    "paths": {                                /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+      "images/*": ["../../src/Store/Gss.Crm.Store.WebUI/Content/images/*"]
+    },                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
```

並新增 `src/images.d.ts` ，讓 TypeScript 將 import 進來的 image URL 當成 `string` ：

```typescript=
declare module '*.png' {
  const content: string;
  export default content;
}

declare module '*.jpg' {
  const content: string;
  export default content;
}

declare module '*.jpeg' {
  const content: string;
  export default content;
}

declare module '*.gif' {
  const content: string;
  export default content;
}
```

## 使用 `styled-components`

`styled-components` 能讓我們以 CSS-in-JS 撰寫樣式，比起靠 BEM 或是 OOCSS 命名規範組織 CSS 更有彈性一些。

要配合 ReactJS.NET 使用 `styled-components` ，請先安裝：

```shell=
yarn add styled-components@5.1.1
yarn add --dev @types/styled-components
```

要注意的是，因為 `styled-components` v5.2.* 有著[忘記檢查 `window` 存不存在的 bug ][window-undefined-issue]，所以我們只能先用 v5.1.1 版。

[window-undefined-issue]: https://github.com/reactjs/React.NET/issues/1232

### render server styles

目前 `styled-components` 會在 runtime 掛載 CSS ，如果希望 SSR 時就有完整的樣式，先暴露 `ServerStyleSheet` ：

```diff=
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOMServer from 'react-dom/server';
+import { ServerStyleSheet } from 'styled-components';

 // setup global variables for server-side rendering
 global.React = React;
 global.ReactDOM = ReactDOM;
 global.ReactDOMServer = ReactDOMServer;
+(global as any).Styled = { ServerStyleSheet };
```

重新建置完 js 後，再修改 view ：

```diff=
 @using System.Web.Optimization
 @using React.Web.Mvc
+@using React.RenderFunctions

-@{Layout = null;}
+@{
+    Layout = null;
+    var styledComponentsFunctions = new StyledComponentsFunctions();
+    var chainedRenderFunctions = new ChainedRenderFunctions(styledComponentsFunctions);
+}

 <html>
     <head>
         <title>Hello React</title>
     </head>
     <body>
-        @Html.React("SettingBlock", new { })
+        @Html.React("SettingBlock", new { }, renderFunctions: chainedRenderFunctions)
         @Html.React("Test", new { initialCounter = Model.initialCounter })
+        @{
+            ViewBag.ServerStyles = styledComponentsFunctions.RenderedStyles;
+        }
+        @Html.Raw(ViewBag.ServerStyles)
         @Scripts.Render("~/Scripts/runtime.bundle.js")
```

目前的例子沒有使用到 shared layout ，所以 server styles 放在 `<body />` 中，如果用上了 shared layout ， server styles 可以放在 layout 的 `<head />` 內。

## 參考資料

* Storybook 的 [Webpack](https://storybook.js.org/docs/react/configure/webpack) 頁面 - 說明如何覆寫 Storybook 的 webpack 設定
* [TypeScript and webpack and Images](https://mattbatman.com/typescript-and-webpack-and-images) - 介紹了如何讓 TypeScript 正確處理 import 進來的圖片檔
* [CSS-in-JS](https://reactjs.net/features/css-in-js.html#styled-components) - ReactJS.NET 的 CSS-in-JS 文件