前言
最近了解了下最新的 React 相关的技术栈,搭建了下 React 项目开发框架,这里记录下搭建过程中遇到的一些坑。作为 Vue 开发老鸟,感觉很多地方没有 Vue 来的简洁,用起来多少有些蹩脚。
技术栈组合:React 18.x + Vite + Redux + TypeScript + Antd Design 5.x
快速创建
这里使用 vite 来创建初始模板:
npm create vite
> npx
> create-vite
√ Project name: ... react-demo
√ Select a framework: » React
√ Select a variant: » TypeScript
Scaffolding project in D:\work\react-demo...
Done. Now run:
cd react-demo
npm install
npm run dev
Select a variant: » TypeScript 这里不要选择 React Router v7,要不然生成的是全栈框架,这里只需要SPA框架。
关键补充
以下标记 新增 的为开发过程中遇到坑后补充的。
package.json
{
"name": "react-project",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.5.2",
"@reduxjs/toolkit": "^2.5.0",
"antd": "^5.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.2.0",
"react-router": "^7.1.3",
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.10.7", --->新增,要不然在 vite.config.ts 中使用 import.meta 会报错
"@types/nprogress": "^0.2.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"sass": "^1.83.4", --->新增,开启 scss 样式编写
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
}
}
tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"verbatimModuleSyntax": true, --->新增,import 类型时开启 type 标识校验
"paths": { --->新增,跨层级 import 时,直接 import xx from '@/xxx',需配合 vite.config.ts 的 resolve.alias 配置
"@/*": [
"./src/*"
]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src",
"types/env.d.ts", --->以下新增,env.d.ts 为定义环境变量时的拓展类型定义
"types/global.d.ts", ---> 自定义的全局类型声明
]
}
tsconfig.node.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": [
"ES2023"
],
"module": "ESNext",
"skipLibCheck": true,
"types": [ --->(新增),这里默认会将 @types 的依赖包含进来,如果定义了则以定义的为准
"node"
],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}
vite.config.ts
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig(() => {
return {
plugins: [react()],
resolve: {
alias: { --->新增,配合 tsconfig.app.json 中的 paths 设置,主要在编译阶段起作用
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
};
});
routes 动态生成
实际开发过程中,routes 需要根据菜单数据动态生成。
初始框架是在 main.tsx 中创建根节点的:
// main.tsx
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
我们需要在这里调用菜单接口,拿到数据后动态生成 routes:
// main.tsx
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import "./styles/main.scss";
import { queryMenu } from "./api/global";
import store from "./stores";
import { storeMenu } from "./stores/global";
// 获取菜单数据
queryMenu().then(async (res) => {
store.dispatch(storeMenu(res)); // 这里将拿到的菜单数据存储起来,方便在其他模块使用
const router = (await import("./router")).default; // 这里是动态生成 router 的模块
createRoot(document.getElementById("root")!).render(
<RouterProvider router={router} /> // 这里需要使用 RouterProvider 来注入生成的 router
);
});
我们来看一下 router 的实现:
// router/index.tsx
import { lazy, StrictMode } from "react";
import { Provider } from "react-redux";
import { createBrowserRouter, Navigate } from "react-router";
import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import App from "../App";
import store from "../stores";
import { MainLayout } from "../layout";
import { generateRoutes } from "./utils";
const routes = generateRoutes(store.getState().global.menu); // 根据菜单数据生成 routes,注意确保在拿到数据后调用
const router = createBrowserRouter([
{
path: "/",
element: (
<StrictMode>
<Provider store={store}>
{/* 这里关于 Antd Design 的主题设置可以参考 https://ant-design.antgroup.com/docs/react/customize-theme-cn */}
<ConfigProvider
locale={zhCN}
theme={{
cssVar: true, // 开启 css 变量,方便在任意地方使用
hashed: false, // 一个项目一般只会有一个主题,这里关闭 hashed,避免不必要的性能消耗
token: {
// 自定义主题色
// colorPrimary: "#00b96b",
},
}}
>
<App />
</ConfigProvider>
</Provider>
</StrictMode>
),
children: [
{
path: "/login",
Component: lazy(() => import("../views/login/Login.tsx")),
},
{
element: <MainLayout />,
children: routes.length
? [
{ index: true, element: <Navigate to={routes[0].path!} /> }, // 这里实现了类似 redirect 重定向的功能
...routes, // 动态生成的 routes
]
: [],
},
{ path: "*", Component: lazy(() => import("../views/404/404.tsx")) },
],
},
]);
export default router;
来看一下 generateRoutes 方法:
// router/utils.tsx
import { lazy } from "react";
import { Navigate, type RouteObject } from "react-router";
import { dash, last, pascal, title } from "radash";
const transPath = (path: string) => {
const arr = path
.split("/")
.filter((e) => !!e)
.map((e) => dash(title(e)).toLowerCase());
return `${arr.join("/")}/${pascal(last(arr)!)}`;
};
// 注意!!!这里要用 import.meta.glob 来匹配所有的视图组件
const matchViewPaths: Record<string, any> = import.meta.glob(
"../views/**/*.tsx"
);
export const generateRoutes = (menuData: VsMenuDataItem[]) => {
return menuData.map((item) => {
const route: RouteObject = {
path: item.path,
};
if (item.menuType === 2 /** 菜单 */) {
// 使用上面的 matchViewPaths,避免使用 lazy(() => import('../views/xx/xxx.tsx')),会报错!!!
route.Component = lazy(
matchViewPaths[`../views/${transPath(item.path)}.tsx`]
);
}
if (item.menuType === 1 && item.children?.length /** 模块,且有子集 */) {
route.children = generateRoutes(item.children);
route.children.unshift({
index: true,
element: <Navigate to={route.children[0].path!} />, // 这里实现了类似 redirect 重定向的功能
});
}
return route;
});
};
到这里,动态生成 routes 的实现已完成。
redux 配置使用
最好是使用官方推荐的范例,要不然会提示你 o(╥﹏╥)o,给个 url 以免迷路: TypeScript 快速开始 | Redux 中文文档。
安装依赖:
pnpm add @reduxjs/toolkit react-redux
开发过程中肯定需要用到一些全局共享数据,例如用户信息、菜单数据等:
// stores/global.ts
import { createSlice } from "@reduxjs/toolkit";
export interface GlobalState {
userInfo: Record<string, any>;
menu: VsMenuDataItem[];
}
// 创建全局切片
export const globalSlice = createSlice({
name: "global", // 命名,作为 action types 的命名空间
initialState: { // 数据初始化
userInfo: {},
menu: [],
} as GlobalState,
reducers: {
// 存储用户信息
storeUserInfo: (state, { payload }) => {
state.userInfo = payload;
},
// 存储菜单数据
storeMenu: (state, { payload }) => {
state.menu = payload;
},
},
});
// 莫要忘了导出~
export const { storeUserInfo, storeMenu } = globalSlice.actions;
export default globalSlice.reducer;
创建了切片,需要将其配置到 store 中:
// stores/index.ts
import { configureStore } from "@reduxjs/toolkit";
import globalReducer from "./global";
const store = configureStore({
reducer: {
global: globalReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
注意!!!这里导出的 RootState 和 AppDispatch 类型是为了创建 useAppDispatch 和 useAppSelector hooks 时使用。
为何要创建 RootState、AppDispatch、useAppDispatch、useAppSelector 可以看下官方的解释:
Hook 只能在组件函数中使用,在组件中使用时的写法:
import { useAppSelector, useAppDispatch } from "@/hooks";
import { decrement } from "./counterSlice";
export default function Counter() {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
dispatch(decrement(count ))
}
如果想要在 main.tsx 等其他非组件中使用,写法有所不同:
import store from "@/stores";
import { storeUserInfo } from "@/stores/global";
const menu= store.getState().global.menu;
store.dispatch(storeUserInfo(res));
Antd Design 踩坑
Antd Design 5.x 相比于 4.x 变化还是挺多的,4.x 的很多写法在 5.x 里会有警告提示,迁移成本应该不会小o(╥﹏╥)o,但对于一直用 Vue + Element Plus 的老鸟肯定没啥影响(* ̄︶ ̄)
- 写动态菜单渲染时,发现 5.x 对于 Menu 的子集 Menu.Item 的写法已经废弃,会有警告提示,只能通过 items 配置来渲染了,但是菜单中的 icon 需要动态加载,看了下 icon 定义的类型是 React.ReactNode 类型,React.createElement((Icons as any)[name]) 虽然能动态创建 icon,但是是 JSX.Element 类型,无法直接配置到 Menu item 的 icon 属性上,那只能用个笨方法了,建一个 icon map 对象,如下:
// ant-icon-map.tsx
import {
AppstoreOutlined,
SettingOutlined,
FormOutlined,
AntDesignOutlined,
FireOutlined,
...
} from "@ant-design/icons";
export default {
AppstoreOutlined: <AppstoreOutlined />,
SettingOutlined: <SettingOutlined />,
FormOutlined: <FormOutlined />,
AntDesignOutlined: <AntDesignOutlined />,
FireOutlined: <FireOutlined />,
...
} as Record<string, any>;
可以通过 antIconMap[name] 的方式给 icon 属性赋值,可以参考下菜单渲染的代码片段:
const generateMenuItems = (data: VsMenuDataItem[]) => {
return data.map((item) => {
const obj: MenuItemType | SubMenuType = {
key: item.path,
label: item.menuName,
};
if (item.menuType === 1 /** 模块 */) {
if (item.icon) {
obj.icon = antIconMap[item.icon]; // 重点!!!
}
if (item.children?.length /** 有子集 */) {
(obj as SubMenuType).children = generateMenuItems(item.children);
}
} else if (item.menuType === 2 /** 菜单 */) {
obj.icon = item.icon
? antIconMap[item.icon]
: antIconMap.FireOutlined;
}
return obj;
});
};
那么还有个问题,icon map 不能一个个手动导入吧,为了能一次性把所有的 antd icon 都导入,自动生成 map 对象,需要写一个 nodejs 脚本自动生成,这里就不实现了,应该也不难。
React 18.x 框架搭建纪要的分享先到这了,后续如果有新的内容会逐步补充进来!!!
github源码:github.com/vsdeeper/re…