---
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 文件