React 18.x 框架搭建纪要

379 阅读6分钟

前言

最近了解了下最新的 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…