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