React 脚手架搭建
这里项目需要规范化所以采用前端目前常用的技术栈, 这里查看时候打算采用 material-ui 处理.
基本上了解基础的 html+css+jss 和 react 基础就可以了, 其他就是日常积累或者不常用的.
这里需要说明目前不推荐
React 19直接搭建, 有的核心组件暂时不支持, 千万不要盲目更新到最新版本.
这里采用从零手动搭建 Login+Dashboard 并且过程会补充说明用于加深印象, 首先是创建项目根目录:
# 首先创建目录作为项目根目录
mkdir fusion-devops
cd fusion-devops
# 进入之后初始化项目
npm init
命令按照自身需要选择就行了, 最后生成 package.json 文件如下:
{
"name": "fusion-devops",
"version": "2025.1.10",
"private": true,
"description": "后台界面",
"main": "index.js",
"keywords": [
"fusion",
"react",
"material-ui"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "MeteorCat",
"license": "MIT"
}
name: 项目名称version: 项目版本, 我喜欢采用Y.m.d做编号出版本private: 是否为私有开源项目description: 项目描述内容main: 启动入口脚本, 这是早期只有CommonJS模块规范指定项目入口的唯一属性(可以删除)keywords: 项目关键字
默认构建不带 src|public 目录, 所以这里需要创建脚本目录:
mkdir src # 源代码目录
mkdir public # 对外开放目录
OK, 现在就搭建好没有任何依赖引入最基础的框架, 这里直接安装命令会默认生成版本锁定文件:
# package-lock.json 这个文件可以不进版本库监控
npm install
React
这里需要引入 React 18, 目前这个时间 material-ui 官方依赖有的没有更新到 19 大版本会导致报错:
# 官方 React18 版本锁定, 目前 React18 最新版
npm install [email protected] [email protected] --save
# 脚本命令就不需要锁版本, 因为本身命令行兼容性都挺不错
npm install react-scripts --save
# 追加官方性能监控软件, 只需要开发时候采用的所以采用 --save-dev
npm install web-vitals --save-dev
这里需要补充点兼容性处理和命令行脚本执行配置:
{
"name": "fusion-devops",
"version": "2025.1.10",
"private": true,
"description": "后台界面",
"keywords": [
"fusion",
"react",
"material-ui"
],
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"author": "MeteorCat",
"license": "MIT",
"dependencies": {
"react": "^18.3.1",
"react-scripts": "^5.0.1"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
现在 npm 支持命令 npm start|build 运行开发环境和打包, 但是执行 npm start 会提示找不到依赖文件:
Could not find a required file.
Name: index.html
这里就创建的首页入口 index.html:
# 注意这里文件入口是基于 public
touch public/index.html
这里直接用官方的主页模板文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
favicon.ico|logo192.png|manifest.json这些资源可以从官方下载的拿, 也可以直接删除引入(后台私有项目不太需要这种配置)
再次运行 npm start 又报错, 提示内容如下:
Could not find a required file.
Name: index.js
这里就是 React 的脚本入口文件, 这时候就需要构建该文件:
# 注意这里文件入口是基于 src
touch src/index.js
这里的入口文件内容也很简单:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
这里需要添加个 reportWebVitals.js 内置性能监控文件:
/// 引入性能监控库
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
再次运行 npm start, 还是出现了报错:
Module not found: Error: Can't resolve './App'
目前虽然报错但已经顺利启动页面服务端, 只是 App.js 没找到而已.
注意: 这里会提示 @babel/plugin-proposal-private-property-in-object 有的接口依赖废弃, 可以直接安装以下工具修复:
npm install @babel/plugin-proposal-private-property-in-object --save-dev
这里直接构建 App.js 应用入口, 这里是 src/App.js 的文件内容:
/**
* 所有应用启动入口
* @constructor
*/
function App() {
return (
<>
<h1>Hello.World!</h1>
</>
)
}
export default App;
最后能够看到项目已经正确启动了, 这就是我们最基础的 React 脚手架, 建议有版本库管理的话现在就可以设个版本提交了.
注意: 后面篇章必须对
React设计和使用有基础性了解和使用, 如果不了解建议官方教程之后再继续.
确立需求
目前项目要求早期需求基本上围绕两点:
Dashboard: 首页渲染面板Login: 后台登录页面
但是在此之前需要确定好目录结构, 这里最基础的目录需求, 可以按照以下目录规划:
src/components: 组件目录, 一些公用组件防止其中(Header|Footer工具, 不允许防止页面 )src/pages: 布局页面, 对应渲染的整个页面(Dashboard|Login功能 )src/assets: 需要引入的静态资源目录src/styles: 需要引入的样式文件目录src/utils: 全局工具类目录
目前暂时先这样处理, 著需要这些关键目录:
mkdir src/components
mkdir src/pages
mkdir src/assets
mkdir src/styles
mkdir src/utils
这里初步规划目录已经完成, 回到最初我们需要 Dashboard 和 Login 页面, 所以我们创建两个入口页面:
# 创建登录首页
touch src/pages/Login/index.js
# 创建首页面板
touch src/pages/Dashboard/index.js
需求页面内容如下( Dashboard/index.js ):
import React from "react";
/**
* 函数组件
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Dashboard(props) {
return (
<>
<h1>Welcome(Dashboard), This is {props.title || "Who?"}</h1>
</>
);
}
另外界面文件内容( Login/index.js ):
import React from "react";
/**
* 登录布局页面
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Login(props) {
return (
<>
<h1>Welcome(Login), This is {props.title || "Who?"}</h1>
</>
)
}
之后再 App.js 当中引入渲染页面看看:
import Dashboard from "./pages/Dashboard";
import Login from "./pages/Login";
/**
* 所有应用启动入口
* @constructor
*/
function App() {
return (
<>
<Dashboard/>
<Login/>
</>
)
}
export default App;
启动应用之后, Dashboard 和 Login 一起渲染出来, 目前项目已经初步设置完成.
路由处理
目前直接引入了页面但是全部渲染处理, 也没办法达到访问 /dashboard 和 /login 只渲染对应页面的目的,
所以现在就需要引入路由管理部件:
# 安装路由组件
npm install react-router-dom --save
这里路由访问有两种:
BrowserRouter: 路径路由,URL直接映射路径, 比如直接访问/login直接访问映射Login组件HashRouter: 哈希路由, 对路径哈希哈希, 访问路径带上#, 直接访问/#/login才能映射到组件
我个人习惯性采用
BrowserRouter, 让路径看起来更加自然
这里主要用到的 Api, 只用到最基本后续高级特性可以查阅官方文档:
<HashRouter/>,<BrowserRouter/>: 最顶级路径路由标签, 最重要基本组件, 需要放置最外层<Routes/>: 路由表配置列表, 用于防止路由列表<Route/>: 路由配置当项, 设置跳转组件<Navigate/>: 导航跳转组件, 用于内部指定跳转到某个组件或者路径const navigate = useNavigate(): 全局跳转组件, 可以实现点击之后跳转路由
之后就是简单实现现在的路由分表:
import Dashboard from "./pages/Dashboard";
import Login from "./pages/Login";
import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom";
/**
* 所有应用启动入口
* @constructor
*/
function App() {
// 路由分表
return (
<BrowserRouter>
<Routes>
{/* 默认首页自动跳转 /dashboard 路径 */}
<Route path="/" element={<Navigate to="/dashboard" component={Dashboard}/>}/>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/login" element={<Login/>}/>
</Routes>
</BrowserRouter>
)
}
export default App;
再次渲染就能看到只需要出 Dashboard 组件, 这里尝试在追加个按钮点击跳转到 Login 测试路径跳转:
import React from "react";
import {useNavigate} from "react-router-dom";
/**
* 函数组件
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Dashboard(props) {
const navigate = useNavigate()
return (
<>
<h1>Welcome(Dashboard), This is {props.title || "Who?"}</h1>
<button onClick={() => {
navigate("/login")
}}>Jump Login
</button>
</>
);
}
启动后默认渲染 Dashboard 并多出跳转按钮, 点击之后就会默认跳转到登陆页面了, 接下来就是开始准备页面UI编写.
UI框架
现在基本脚手架已经搭建完成, 准备要配置界面组件( UI Component ) 的设计,
最开始本来在 ant-d(Ant Design) 和 mui(Material UI) 抉择,
最后打算采用简单点的 mui 处理.
具体UI框架采用可以参照 wappalyzer 查看排名选项
PS:
Bootstrap目前以 66%(2025年1月) 名列前茅, 当年大部分项目选择Bootstrap基本上可以用到老, 选型实在太明智了
言归正传直接按照官网按照引入就行了:
# 目前(2025年1月), mui 已经支持 React19, 但是扩展的高级 mui-x 支持还没跟上, 所以最好保持 React18
npm install @mui/material @emotion/react @emotion/styled
# 安装 Robot 字体
npm install @fontsource/roboto
# 安装 Icon 图标
npm install @mui/icons-material
对于字体如果想要引入直接采用官方提供方式即可:
// 这里 xxx.css, 代表字体权重和主题, 可以直接手动切换查看不同字体效果选择
// Material UI 的默认排版配置仅依赖 300、400、500 和 700 字体权重
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
另外需要注意一点就是样式初始化组件, 为了抹平各个设备当中的样式( 比如 Chrome|FireFox|Edge 其实渲染样式默认值不同 ),
需要在在入口先引入处理 <CssBaseline/>:
// 在 App.js 文件中最开始追加样式初始化
import {CssBaseline} from "@mui/material";
/**
* 所有应用启动入口
* @constructor
*/
function App() {
return (
<>
<CssBaseline/>
{/* 其他略 */}
</>
)
}
export default App;
这里编写 Login 页面查看是否正确引入 mui:
// Login/index.js 处理
import React from "react";
import {Button} from "@mui/material";
/**
* 登录布局页面
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Login(props) {
return (
<>
<h1>Welcome(Login), This is {props.title || "Who?"}</h1>
<Button variant="contained">Hello world</Button>
</>
)
}
启动后能够看到界面显示 mui 风格按钮就代表引入成功了.
Login页面
引入好 UI 框架现在先构建个登录页面, 为后续的 api 请求等铺路先, 这里采用官方的 Sign 样例.
官方样例:
GitHub
官方样例里几个组件做好了功能划分:
AppTheme: 全局应用主题设置, 强烈推荐引入ColorModeSelect|ColorModeIconDropdown: 日间|夜间模式切换组件ThemePrimitives: 组件原始主题初始化样式
以上两个建议直接复制 src/components 目录来使用, 内部采用 prop-types 强声明类型组件, 这个组件需要安装:
# 用于声明传入的类型
npm install prop-types --save
首先就是主题配置组件( src/components/AppTheme.js ):
import {createTheme, ThemeProvider} from "@mui/material";
import {useMemo} from "react";
import PropTypes from "prop-types";
import {colorSchemes, typography, shadows, shape} from "./ThemePrimitives";
/**
* 全局主题组件
* @param props
* @returns {JSX.Element}
* @constructor
*/
function AppTheme(props) {
const {children, themeComponents} = props;
const theme = useMemo(() => {
return createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme',
cssVarPrefix: 'template',
},
// Include global styles
colorSchemes,
typography,
shadows,
shape,
// Include custom components
components: {}
})
}, [themeComponents])
// 返回主题节点
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
)
}
/**
* 强依赖参数组件类型
* @type {{themeComponents: PropTypes.Requireable<object>, children: PropTypes.Requireable<PropTypes.ReactNodeLike>, disableCustomTheme: PropTypes.Requireable<boolean>}}
*/
AppTheme.propTypes = {
children: PropTypes.node,
themeComponents: PropTypes.object,
};
export default AppTheme;
官方默认提供的初始化部件初始化样式, 这里直接引入 ThemePrimitives 即可:
import {createTheme, alpha} from '@mui/material/styles';
const defaultTheme = createTheme();
const customShadows = [...defaultTheme.shadows];
export const brand = {
50: 'hsl(210, 100%, 95%)',
100: 'hsl(210, 100%, 92%)',
200: 'hsl(210, 100%, 80%)',
300: 'hsl(210, 100%, 65%)',
400: 'hsl(210, 98%, 48%)',
500: 'hsl(210, 98%, 42%)',
600: 'hsl(210, 98%, 55%)',
700: 'hsl(210, 100%, 35%)',
800: 'hsl(210, 100%, 16%)',
900: 'hsl(210, 100%, 21%)',
};
export const gray = {
50: 'hsl(220, 35%, 97%)',
100: 'hsl(220, 30%, 94%)',
200: 'hsl(220, 20%, 88%)',
300: 'hsl(220, 20%, 80%)',
400: 'hsl(220, 20%, 65%)',
500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 20%, 25%)',
800: 'hsl(220, 30%, 6%)',
900: 'hsl(220, 35%, 3%)',
};
export const green = {
50: 'hsl(120, 80%, 98%)',
100: 'hsl(120, 75%, 94%)',
200: 'hsl(120, 75%, 87%)',
300: 'hsl(120, 61%, 77%)',
400: 'hsl(120, 44%, 53%)',
500: 'hsl(120, 59%, 30%)',
600: 'hsl(120, 70%, 25%)',
700: 'hsl(120, 75%, 16%)',
800: 'hsl(120, 84%, 10%)',
900: 'hsl(120, 87%, 6%)',
};
export const orange = {
50: 'hsl(45, 100%, 97%)',
100: 'hsl(45, 92%, 90%)',
200: 'hsl(45, 94%, 80%)',
300: 'hsl(45, 90%, 65%)',
400: 'hsl(45, 90%, 40%)',
500: 'hsl(45, 90%, 35%)',
600: 'hsl(45, 91%, 25%)',
700: 'hsl(45, 94%, 20%)',
800: 'hsl(45, 95%, 16%)',
900: 'hsl(45, 93%, 12%)',
};
export const red = {
50: 'hsl(0, 100%, 97%)',
100: 'hsl(0, 92%, 90%)',
200: 'hsl(0, 94%, 80%)',
300: 'hsl(0, 90%, 65%)',
400: 'hsl(0, 90%, 40%)',
500: 'hsl(0, 90%, 30%)',
600: 'hsl(0, 91%, 25%)',
700: 'hsl(0, 94%, 18%)',
800: 'hsl(0, 95%, 12%)',
900: 'hsl(0, 93%, 6%)',
};
export const getDesignTokens = (mode) => {
customShadows[1] =
mode === 'dark'
? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
return {
palette: {
mode,
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
...(mode === 'dark' && {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
}),
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
...(mode === 'dark' && {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
}),
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
...(mode === 'dark' && {
light: orange[400],
main: orange[500],
dark: orange[700],
}),
},
error: {
light: red[300],
main: red[400],
dark: red[800],
...(mode === 'dark' && {
light: red[400],
main: red[500],
dark: red[700],
}),
},
success: {
light: green[300],
main: green[400],
dark: green[800],
...(mode === 'dark' && {
light: green[400],
main: green[500],
dark: green[700],
}),
},
grey: {
...gray,
},
divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && {default: gray[900], paper: 'hsl(220, 30%, 7%)'}),
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
...(mode === 'dark' && {
primary: 'hsl(0, 0%, 100%)',
secondary: gray[400],
}),
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
...(mode === 'dark' && {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
}),
},
},
typography: {
fontFamily: 'Inter, sans-serif',
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
},
shape: {
borderRadius: 8,
},
shadows: customShadows,
};
};
export const colorSchemes = {
light: {
palette: {
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
},
error: {
light: red[300],
main: red[400],
dark: red[800],
},
success: {
light: green[300],
main: green[400],
dark: green[800],
},
grey: {
...gray,
},
divider: alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
},
baseShadow:
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
},
},
dark: {
palette: {
primary: {
contrastText: brand[50],
light: brand[300],
main: brand[400],
dark: brand[700],
},
info: {
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
},
warning: {
light: orange[400],
main: orange[500],
dark: orange[700],
},
error: {
light: red[400],
main: red[500],
dark: red[700],
},
success: {
light: green[400],
main: green[500],
dark: green[700],
},
grey: {
...gray,
},
divider: alpha(gray[700], 0.6),
background: {
default: gray[900],
paper: 'hsl(220, 30%, 7%)',
},
text: {
primary: 'hsl(0, 0%, 100%)',
secondary: gray[400],
},
action: {
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
},
baseShadow:
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
},
},
};
export const typography = {
fontFamily: 'Inter, sans-serif',
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
};
export const shape = {
borderRadius: 8,
};
const defaultShadows = [
'none',
'var(--template-palette-baseShadow)',
...defaultTheme.shadows.slice(2),
];
export const shadows = defaultShadows;
首先就是夜间模式组件( src/components/ColorModeSelect.js ):
import * as React from 'react';
import {useColorScheme} from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
/**
* 夜间模式切换选择
* @param props
* @returns {React.JSX.Element|null}
* @constructor
*/
export default function ColorModeSelect(props) {
const {mode, setMode} = useColorScheme();
if (!mode) {
return null;
}
return (
<Select
value={mode}
onChange={(event) => setMode(event.target.value)}
SelectDisplayProps={{
'data-screenshot': 'toggle-mode',
}}
{...props}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
);
}
首先就是夜间模式下拉组件( src/components/ColorModeIconDropdown.js ):
import * as React from 'react';
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightModeRounded';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import {useColorScheme} from '@mui/material/styles';
/**
* 夜间模式下拉组件
* @param props
* @returns {Element}
* @constructor
*/
export default function ColorModeIconDropdown(props) {
const {mode, systemMode, setMode} = useColorScheme();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMode = (targetMode) => () => {
setMode(targetMode);
handleClose();
};
if (!mode) {
return (
<Box
data-screenshot="toggle-mode"
sx={(theme) => ({
verticalAlign: 'bottom',
display: 'inline-flex',
width: '2.25rem',
height: '2.25rem',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
})}
/>
);
}
const resolvedMode = systemMode || mode;
const icon = {
light: <LightModeIcon/>,
dark: <DarkModeIcon/>,
}[resolvedMode];
return (
<React.Fragment>
<IconButton
data-screenshot="toggle-mode"
onClick={handleClick}
disableRipple
size="small"
aria-controls={open ? 'color-scheme-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
{...props}
>
{icon}
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
slotProps={{
paper: {
variant: 'outlined',
elevation: 0,
sx: {
my: '4px',
},
},
}}
transformOrigin={{horizontal: 'right', vertical: 'top'}}
anchorOrigin={{horizontal: 'right', vertical: 'bottom'}}
>
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
System
</MenuItem>
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
Light
</MenuItem>
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
Dark
</MenuItem>
</Menu>
</React.Fragment>
);
}
这几个组件可以先从官方复制过来备用, 后续用到的可能性很大, 现在回过头来就是 Login 页面的入手处理:
import React from "react";
import {
Box,
Button,
Checkbox, CssBaseline,
FormControl, FormControlLabel,
FormLabel, Link,
Stack,
styled,
TextField,
Typography
} from "@mui/material";
import MuiCard from '@mui/material/Card';
import AppTheme from "../../components/AppTheme";
import ColorModeSelect from "../../components/ColorModeSelect";
/**
* 重写 Card 组件, 让其背景板居中
*/
const Card = styled(MuiCard)(({theme}) => ({
display: 'flex',
flexDirection: 'column',
alignSelf: 'center',
width: '100%',
padding: theme.spacing(4),
gap: theme.spacing(2),
margin: 'auto',
[theme.breakpoints.up('sm')]: {
maxWidth: '450px',
},
boxShadow:
'hsla(220, 30%, 5%, 0.05) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.05) 0px 15px 35px -5px',
...theme.applyStyles('dark', {
boxShadow:
'hsla(220, 30%, 5%, 0.5) 0px 5px 15px 0px, hsla(220, 25%, 10%, 0.08) 0px 15px 35px -5px',
}),
}));
/**
* 重写登录容器
*/
const LoginContainer = styled(Stack)(({theme}) => ({
height: 'calc((1 - var(--template-frame-height, 0)) * 100dvh)',
minHeight: '100%',
padding: theme.spacing(2),
[theme.breakpoints.up('sm')]: {
padding: theme.spacing(4),
},
'&::before': {
content: '""',
display: 'block',
position: 'absolute',
zIndex: -1,
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 50%, hsl(210, 100%, 97%), hsl(0, 0%, 100%))',
backgroundRepeat: 'no-repeat',
...theme.applyStyles('dark', {
backgroundImage:
'radial-gradient(at 50% 50%, hsla(210, 100%, 16%, 0.5), hsl(220, 30%, 5%))',
}),
},
}));
/**
* 登录布局页面
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Login(props) {
// 返回渲染节点
return (
<>
<AppTheme {...props}>
<CssBaseline enableColorScheme/>
<LoginContainer direction="column" justifyContent="space-between">
<ColorModeSelect sx={{position: 'fixed', top: '1rem', right: '1rem'}}/>
<Card variant="outlined">
<Typography
component="h1"
variant="h4"
sx={{width: '100%', fontSize: 'clamp(2rem, 10vw, 2.15rem)'}}
>
User Login
</Typography>
<Box
component="form"
onSubmit={() => {
}}
noValidate
sx={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: 2,
}}
>
<FormControl>
<FormLabel htmlFor="username">Username</FormLabel>
<TextField
error={''}
helperText={''}
id="username"
type="text"
name="username"
placeholder="Username"
autoComplete="off"
autoFocus
required
fullWidth
variant="outlined"
color={'error' /* 'primary'*/}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="password">Password</FormLabel>
<TextField
error={''}
helperText={''}
name="password"
placeholder="Password"
type="password"
id="password"
autoComplete="off"
autoFocus
required
fullWidth
variant="outlined"
color={'error' /*: 'primary'*/}
/>
</FormControl>
<FormControlLabel
control={<Checkbox value="remember" color="primary"/>}
label="Remember me"
/>
<Button
type="submit"
fullWidth
variant="contained"
onClick={() => {
}}
>
Login
</Button>
</Box>
</Card>
</LoginContainer>
</AppTheme>
</>
)
}
该功能还附带有夜间模式的切换处理
最后启动查看 http://localhost:3000/login 效果, 如果能够正常显示页面就说明已经完成授权页面的UI编写.
API设计
目前登录页面构建好了, 不过在正规项目开发的时候接口数据请求参数和响应结构, 现在开始需要需要依赖扩展网络库.
现在有两种处理网络 API 方式, 比较主流的就是以下依赖:
fetch: 现代浏览器自带网络请求API, 无需依赖可以直接就能调用, 有的老版本浏览器可能不兼容axios: 第三方网络库, 兼容性好可以兼容, 集成数据处理方案
以上方案都支持 异步(Promise) 处理, 但是一般后台为了兼容需求这里采用 axios 处理:
# 配置网络库安装
npm install axios --save
之后就是对网络库的二次封装, 之所以需要这样那是为了后续兼容性, 如果不套服务层后续如果请求函数变动或者不再直接每个引用地方都要替换, 在网上网络二次封装的服务层都有各自特有的命名方式, 这里提供几个常用的命名用来提供选择:
api: 最直观的命名query: 把查询行为直接作为命名service: 引用最多的, 把请求的行为为当作网络服务层行为
这里实际上为了项目比较简单, 所以习惯性语义化采用 query 作为命名, 所有指令都采用 行为 + Query.js 命名,
比如登录的话直接生成文件 src/query/LoginQuery.js 作为指令处理文件.
不过在此之前, 需要声明创建个统一的配置文件( src/config.js ):
// 请求api配置[路径]
const ProductionRequestURL = "https://api.meteorcat.com"; // 正式域名
const DevelopmentOpsRequestURL = "http://localhost"; // 开发域名
export const RequestURL = process.env.NODE_ENV === 'development' ? DevelopmentOpsRequestURL : ProductionRequestURL;
// 请求api配置[超时]
export const RequestTimeout = 5000; // 请求超时秒
默认开发环境返回的 development, 打包正式包之后就会变动, 之后就是将组件引入到工具当中( src/utils/Request.js ):
import axios from "axios";
import {RequestTimeout, RequestURL} from "../config";
/**
* 请求句柄
* @type {axios.AxiosInstance}
*/
const Request = axios.create({
baseURL: RequestURL,
timeout: RequestTimeout,
});
/**
* 请求拦截器
*/
Request.interceptors.request.use(
config => {
// 请求之前需要配置信息, 如 config.headers['Authorization'] = '你的token';
return config;
},
error => {
// 拦截处理请求
return Promise.reject(error);
}
);
/**
* 响应拦截器
*/
Request.interceptors.response.use(
response => {
// 处理数据
console.log(response.data);
return response.data;
},
error => {
console.log('err' + error); // for debug
return Promise.reject(error);
}
);
export default Request;
之后创建目录构建第一个请求文件查询文件:
# 把目录归类到 auth 之中
touch src/query/auth/LoginQuery.js
这里登录请求服务, 内容如下:
import Request from "../../utils/Request";
import PropTypes from "prop-types";
/**
* 请求登录
* @param props
* @returns {Promise<axios.AxiosInstance>}
* @constructor
*/
async function LoginQuery(props) {
return Request.request({
url: '/auth/login',
method: "POST",
data: {
username: props.username,
password: props.password,
}
});
}
/**
* 强制获取类型
* @type {{password: PropTypes.Validator<NonNullable<string>>, username: PropTypes.Validator<NonNullable<string>>}}
*/
LoginQuery.propTypes = {
username: PropTypes.string.isRequired,
password: PropTypes.string.isRequired,
};
export default LoginQuery
随便找个地方测试下请求服务:
LoginQuery({username: "MeteorCat", password: "MeteorCat"})
.then(res => console.log(res))
.catch(err => console.error(err));
这里会报错无法请求并 console 打印错误 errAxiosError: Network Error, 主要就是最常见的原因: 请求跨域;
但是到这里基本上已经完成脚手架的大部分功能了, 基本上现在就差登录业务实现返回和跳转等.
跨域处理可以先不处理, 主要一般服务器的时候会设置开放跨域设置, 目前脚手架还没到这个需求所以后面处理
Dashboard 主界面
现在开始编写主界面页面, 一般来说后台系统基本上只需要 Header(顶部栏) + Fooder(底部栏) + Nav(菜单栏),
这里去官方抽屉柜样例编写出来初级架构 Dashboard 页面:
import React from "react";
import {useNavigate} from "react-router-dom";
import AppTheme from "../../components/AppTheme";
import {
Box,
Button,
CssBaseline,
Divider,
List,
ListItem,
ListItemButton,
ListItemText,
styled,
Toolbar,
Typography
} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import MuiDrawer from '@mui/material/Drawer';
import MuiAppBar from '@mui/material/AppBar';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import InboxIcon from '@mui/icons-material/MoveToInbox';
import MailIcon from '@mui/icons-material/Mail';
import ListItemIcon from '@mui/material/ListItemIcon';
const drawerWidth = 240;
/**
* 打开扩展栏的动画
* @param theme
* @returns {{overflowX: string, width: number, transition: width}}
*/
const openedMixin = (theme) => ({
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: 'hidden',
});
/**
* 关闭扩展栏的动画
* @param theme
* @returns {{[p: string]: {width: string}, overflowX: string, width: string, transition: width}}
*/
const closedMixin = (theme) => ({
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
[theme.breakpoints.up('sm')]: {
width: `calc(${theme.spacing(8)} + 1px)`,
},
});
/**
* 抽屉柜Header
*/
const DrawerHeader = styled('div')(({theme}) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
}));
/**
* 重写AppBar菜单栏
*/
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open',
})(({theme}) => ({
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
variants: [
{
props: ({open}) => open,
style: {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
},
],
}));
/**
* 样式组件
* @type {StyledComponent<Pick<PropsOf<(props: DrawerProps) => React.JSX.Element>, keyof React.ComponentProps<(props: DrawerProps) => React.JSX.Element>> & MUIStyledCommonProps<Theme>, {}, {}>}
*/
const Drawer = styled(MuiDrawer, {shouldForwardProp: (prop) => prop !== 'open'})(
({theme}) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
variants: [
{
props: ({open}) => open,
style: {
...openedMixin(theme),
'& .MuiDrawer-paper': openedMixin(theme),
},
},
{
props: ({open}) => !open,
style: {
...closedMixin(theme),
'& .MuiDrawer-paper': closedMixin(theme),
},
},
],
}),
);
/**
* 函数组件
* @param props
* @returns {JSX.Element}
* @constructor
*/
export default function Dashboard(props) {
const navigate = useNavigate()
const [open, setOpen] = React.useState(true);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<AppTheme {...props}>
<CssBaseline enableColorScheme/>
<Box sx={{display: 'flex'}}>
<CssBaseline/>
<AppBar position="fixed" open={open}>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={[
{
marginRight: 5,
},
open && {display: 'none'},
]}
>
<MenuIcon/>
</IconButton>
<Typography variant="h6" noWrap component="div" sx={{flexGrow: 1}}>
Mini variant drawer
</Typography>
<Button color="inherit">Login</Button>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
<ChevronRightIcon/>
</IconButton>
</DrawerHeader>
<Divider/>
<List>
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
<ListItem key={text} disablePadding sx={{display: 'block'}}>
<ListItemButton
sx={[
{
minHeight: 48,
px: 2.5,
},
open
? {
justifyContent: 'initial',
}
: {
justifyContent: 'center',
},
]}
>
<ListItemIcon
sx={[
{
minWidth: 0,
justifyContent: 'center',
},
open
? {
mr: 3,
}
: {
mr: 'auto',
},
]}
>
{index % 2 === 0 ? <InboxIcon/> : <MailIcon/>}
</ListItemIcon>
<ListItemText
primary={text}
sx={[
open
? {
opacity: 1,
}
: {
opacity: 0,
},
]}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Divider/>
<List>
{['All mail', 'Trash', 'Spam'].map((text, index) => (
<ListItem key={text} disablePadding sx={{display: 'block'}}>
<ListItemButton
sx={[
{
minHeight: 48,
px: 2.5,
},
open
? {
justifyContent: 'initial',
}
: {
justifyContent: 'center',
},
]}
>
<ListItemIcon
sx={[
{
minWidth: 0,
justifyContent: 'center',
},
open
? {
mr: 3,
}
: {
mr: 'auto',
},
]}
>
{index % 2 === 0 ? <InboxIcon/> : <MailIcon/>}
</ListItemIcon>
<ListItemText
primary={text}
sx={[
open
? {
opacity: 1,
}
: {
opacity: 0,
},
]}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Drawer>
<Box component="main" sx={{flexGrow: 1, p: 3}}>
<DrawerHeader/>
<Typography sx={{marginBottom: 2}}>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus non
enim praesent elementum facilisis leo vel. Risus at ultrices mi tempus
imperdiet. Semper risus in hendrerit gravida rutrum quisque non tellus.
Convallis convallis tellus id interdum velit laoreet id donec ultrices.
Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl suscipit
adipiscing bibendum est ultricies integer quis. Cursus euismod quis viverra
nibh cras. Metus vulputate eu scelerisque felis imperdiet proin fermentum
leo. Mauris commodo quis imperdiet massa tincidunt. Cras tincidunt lobortis
feugiat vivamus at augue. At augue eget arcu dictum varius duis at
consectetur lorem. Velit sed ullamcorper morbi tincidunt. Lorem donec massa
sapien faucibus et molestie ac.
</Typography>
<Typography sx={{marginBottom: 2}}>
Consequat mauris nunc congue nisi vitae suscipit. Fringilla est ullamcorper
eget nulla facilisi etiam dignissim diam. Pulvinar elementum integer enim
neque volutpat ac tincidunt. Ornare suspendisse sed nisi lacus sed viverra
tellus. Purus sit amet volutpat consequat mauris. Elementum eu facilisis
sed odio morbi. Euismod lacinia at quis risus sed vulputate odio. Morbi
tincidunt ornare massa eget egestas purus viverra accumsan in. In hendrerit
gravida rutrum quisque non tellus orci ac. Pellentesque nec nam aliquam sem
et tortor. Habitant morbi tristique senectus et. Adipiscing elit duis
tristique sollicitudin nibh sit. Ornare aenean euismod elementum nisi quis
eleifend. Commodo viverra maecenas accumsan lacus vel facilisis. Nulla
posuere sollicitudin aliquam ultrices sagittis orci a.
</Typography>
</Box>
</Box>
</AppTheme>
);
}
启动 npm start 看下效果, 没问题的话现在 Login 和 Dashboard 已经编写完成;
现在就是进一步规划组件把必要组件拆分出来通用.
模块细化
- 模块需要拆分
main|header, 这里面就是页面子组件用来的嵌套路由渲染和固定头部栏 - 需要分出二级菜单, 一级菜单目前没办法满足基本需求
- 提供设置用户登出和修改密码入口, 这两个不需要在二级菜单当中
- 确定展示语言, 内部项目一般不需要国际化处理, 也就是不需要提供多个语言版本
- 全局配置定义, 比如平台名称等多次用到的数据都要静态配置文件(
config.js)配置好
这里实际推荐 官方模板 直接一步到位配置, 因为我本人侧重点在后端处理, 所以直接需要用最快速度搭建好可用后台, 最后考虑了下直接套用官方模板方便快速成型.
该模板需要安装比较多扩展组件:
npm install @mui/material, @mui/icons-material, @emotion/styled, @emotion/react, @mui/x-charts, @mui/x-date-pickers, @mui/x-data-grid, @mui/x-tree-view, dayjs
受限于篇幅限制说明, 所以暂时先告一段路, 主要后面还有些细节暂时无法完全覆盖说明, 只是先说明下工作流处理.
最后提供初版的脚手架样例: