React 项目结构小结
简单的记录一下目前 React 项目用的依赖和实现
摸索了大半年了大概构建一套用起来还算轻松的体系……?基本上应该是说可以应对大部分的项目了
使用的依赖
目前项目还在 refactoring 的阶段,所以乱得很,这里是新建一个空的项目作为案例,package.json 中新增添的依赖如下:
{"dependencies": {"@reduxjs/toolkit": "^1.9.7","@ui-framework/keycloak-auth": "^4.0.0","axios": "^1.6.0","dayjs": "^1.11.10","lodash": "^4.17.21","react-redux": "^8.1.3","react-router-dom": "^6.18.0","redux-logger": "^3.0.6","styled-components": "^6.1.0","uuid": "^9.0.1"}
}
版本不一定是最新的,这个取决于我们的 nexux 库更新的有多勤快
主要归类如下:
-
状态
-
@reduxjs/toolkit
这是主要的管理部分,RTK(Redux Toolkit) 是迭代后的版本,
-
react-redux
RTK 的依赖
-
redux-logger(可选)
用来查看 state 变化的插件
-
-
API 相关
- axios
-
路由
其实可以的话是想找一下依赖,看看有没有把路由归并到 redux 管理的实现,之前看到的一些实现要么是 v5 的,v6 不支持,要么就是 v6 可以支持,但是比较 buggy,现在暂时没什么时间做这个,晚点再折腾
- react-router-dom
-
验证
-
keycloak-auth
这是一个登录的依赖,token、profile 等会通过 keycloak-auth 返回
-
-
UI
公司内部其实有实现自己的 UI 库,所以我就放两个底层用的依赖。其他关于 radio 之类的,都是项目内部实现的
-
styled-components
CSS-in-JS
-
react-table
react-table 本身是一个 headless 的库,需要具体实现 UI
-
react-select
这个比较方便的一点在于可以使用 async dropdown,即通过点击
load more
或者scroll
事件可以触发 API 调用搭配好 pagination 的 query 可以比较好的提升用户体验
-
-
util 相关
-
dayjs
取代 momentjs
-
uuid
主要用在 API 调用方面,有的情况后端返回的 id 是 UUID
-
lodash
-
底层用的还是 create-react-app
,主要是因为脚手架一来确实方便,升级也可以直接通过升级 react-script
进行集成管理。二来,我们的项目需求并没有复杂到需要将 webpack 单独拆出来,做对应的优化等
基础结构
目前想要实现的结构如下:
-
components
这里放置了一些封装好的 UI,也就是我们根据自己内部的业务需求实现的一些 wrapper,主要的类型有:button, modal, layout, table 等
-
constants
这里是一些共用的逻辑,这个里面的分类是按照 model 进行的分类
我们的项目是比较强类型的,而且是 2b 业务,所以主要就是表单+表格的功能,而每个表单/表格有需要有独立的 structure 传到 UI 库中形成对应的结构,因此每个 model 对应的 structure 可以保存在 constant 中
另外一些可以保存的常量有 model 的类型,这一块目前放到
types
里,不过建于types
对 TS 来说是生成.d.ts
的文件的地方,所以这个迟早是要修改的 -
pages
每一个渲染的页面
-
store
redux store 的相关管理
-
types
目前用来放
model
的 type,不过按照上面说的,想要移到const
中 -
utils
一些相关的 helpers,包括环境、date、entity(model) 之类的
一些实现
其实主要还是 redux 相关的部分的修改,其他部分要么就是之前已经写过笔记了,要么就是跟具体业务相关的,这里不太好记
react router dom
v5 的使用:[React 基础系列] React Router 的基本应用 和 v6 的升级:React Router DOM 升级到 v6 后的一些报错信息
就目前来说我们还是待在 v5 没有动,不过之后我需要对已经实现的 Router 进行一个重构,到时候会实现一下升级 v6,不过按照计划来说,这也是明年二/三月份之后的事情……
现在到明年二/三月份主要还是需要将所有的 page 转成 redux-based
redux
Redux 的使用其实之前也有写过,大概如下:
- Redux Toolkit 调用 API 的四种方式
- []async thunk 解决 API 调用的依赖问题(https://goldenaarcher.blog.csdn.net/article/details/129002505)
- Redux 错误处理
这里不会太进入细节,简单的说一下每个 slice 里面的简单实现,以及 pages 中的 component 怎么调用的,基本结构大致如下:
index.ts
这里代码其实没什么好变的,基本上是一个万能模板了:
import {configureStore,EnhancedStore,ThunkDispatch,AnyAction,Store,
} from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import logger from 'redux-logger';
import reducers from './slices';// reference: https://stackoverflow.com/questions/70143816/argument-of-type-asyncthunkactionany-void-is-not-assignable-to-paramete// 1. Get the root state's type from reducers
export type RootState = ReturnType<typeof store.getState>;// 2. Create a type for thunk dispatch
export type AppThunkDispatch = ThunkDispatch<RootState, any, AnyAction>;// 3. Create a type for store using RootState and Thunk enabled dispatch
export type AppStore = Omit<Store<RootState, AnyAction>, 'dispatch'> & {dispatch: AppThunkDispatch;
};export const store: EnhancedStore = configureStore({reducer: reducers,middleware: (getDefaultMiddleware) =>getDefaultMiddleware({serializableCheck: false,}).concat(logger),
});// you can also create some redux hooks using the above explicit types
export const useAppDispatch = () => useDispatch<AppThunkDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
useAppDispatch
和 useAppSelector
这个,因为类型检查的关系,使用 TS 的前提下几乎是必须的,我之前也有一篇笔记讨论过这个。
helper func
我们实现了一个 createAsyncThunk
的 wrapper,主要用来处理 AppThunkDispatch
需要重复提供类型问题,代码如下:
import { createAsyncThunk, createAction } from '@reduxjs/toolkit';
import { AppThunkDispatch, RootState } from '../';export const createAppAsyncThunk = createAsyncThunk.withTypes<{state: RootState;dispatch: AppThunkDispatch;rejectValue: string;extra: { s: string; n: number };
}>();
slice/index.ts
slice/index.ts 主要就是用来集成一堆的 reducer 让 store 去使用,同时导出所有的 actions,这样让其他地方的 import 干净一些,大致代码如下:
import { example } from './slices/exampleSlice';const reducers = {example,
};export default reducers;export * from './slices/exampleSlice';
slice
这里的 slice 主要是 API 的操作,代码大致如下:
export type IExampleSlice = {loading: boolean;data: any[];error: null | SerializedError;
};const initialState: IExampleSlice = {loading: false,data: [],error: null,
};const uri = '';const exampleSlice = createSlice({name: 'modal',initialState,reducers: {clearState() {return initialState;},},extraReducers(builder) {builder.addCase(fetchExample.pending, () => {return {...initialState,isLoading: true,};});builder.addCase(fetchExample.fulfilled, (state, { payload }) => {// process payload});builder.addCase(fetchExample.rejected, (state, action) => {console.error(action.error);state.error = action.error;});},
});export const fetchExample = createAppAsyncThunk<any,{ payload: Partial<any> },{ state: RootState }
>(`${uri}/post`, async ({ payload }, { dispatch, getState }) => {const res = await wrappedFetchMethod();return res;
});export const { clearState } = exampleSlice.actions;
export const example = exampleSlice.reducer;
其中 IExampleSlice 可以使用 generics 单独抽出来,如:
export type IGenericsSlice<T> = {loading: boolean;data: T[];error: null | SerializedError;
};type ExampleModel = {id: string;name: string;
};export type IExampleSlice = IGenericsSlice<ExampleModel>;
我们项目就是将 API 单独抽出来进行了封装,如果 slice 内有其他需要合并的属性,在定义 ISthSlice
的时候会使用 &
进行合并
其他 slice
其他的 slice 比较灵活,可以根据需求单独神明 type 并且进行返回。
每个 slice 有对应定义的 type 还是挺重要的,尤其是之后 Component 中调用的时候,可以比较方便的提供 intellisense
RTKQ
目前的项目因为数据量的关系(没有做 pagination,并且用户要求不做 pagination),所以决定不使用 RTKQ 在完成 CUD 操作后直接重新拉去数据
不过 RTK 可以 cache query,并且在对应的 query 中对应的功能
也就是一旦出发 CUD 操作,自动调用 retrieve 操作,不需要手动在 await
操作中实现。这一点对于原子性要求更高的 2c 项目中很有用,并且这个功能也取代了一些 redux-saga 可以实现的功能,这也是为什么我没有考虑引入 saga 的原因
组件调用 slice
大致如下:
import React, { useEffect } from 'react';import { useAppDispatch, useAppSelector, RootState } from '../store';
// 所有导出都通过 slices/index,因此相对而言 import 可以稍微干净一些
import { IExampleSlice, fetchExample } from '../store/slices';const Example = () => {const dispatch = useAppDispatch();const { data, error, loading } = useAppSelector<IExampleSlice>((state: RootState) => state.example);const apiCall = async () => {console.log(data, error, loading);const res = await dispatch(fetchExample({payload: {id: '',},}));if (res.meta.requestStatus === 'fulfilled') {// do sth}};useEffect(() => {apiCall();}, []);return <div>Example</div>;
};export default Example;
-
提供一些对于 payload 的检查还是有好处的,比如:
CRUD 中的操作主要都是对于对应的模型操作,因此将上面的 payload 定义改成 Example 的模型,TS 也可以自动对其进行静态检查:
-
useAppSelector 中的类型
主要也是为了 intellisense,如过不提供
<IExampleSlice>
,返回值如下:又或者是一些比较小的 typo,如果不提供类型,TS 是抓不出来的:
提供后:
-
导出方式
这里还是推荐使用
useAppSelector(state => state.someState)
的方式,这个是为了 performance,直接返回整个 state 可能会引起不必须的渲染: -
错误处理
这个其实之前的笔记有提,这里再说一下好了
如果是异步操作,通过 asnc/await 可以直接获取 request 的结果,这样就可以根据接过去具体更新 UI 了:
如:
-
操作成功关闭 modal
-
操作失败继续维持 modal 的开启状态
同时根据返回的 error,在 UI 生成对应的错误信息
-
总结
其实大体上主要还是 redux 的配置比较多一些,其他部分都挺灵活的,而且和业务绑定的比较多,我也不方便说,说了也不一定有参考意义
项目的结构是一个比较风格化的东西,我用的比较喜欢的风格 有点变态的说已经把这个项目变成了我喜欢的样子……? 并不代表这一定是一门项目会用的风格,这是 react 的优点也是缺点
完成 redux 部分的 refactor 之后,下一步考虑的就是使用 lerna 对项目升级,做成一个 micro-frontend 的项目。这也是基于项目本身的特性决定的,我们的项目是一个多地区使用的网页应用,并且不同地区对于想要渲染的数据、显示的页面会有不同的需求。目前的实现就是一旦打包了,所有的东西全都打包 ship 出去,不过操作起来还是不太方便
我的构想是:
- 将所有的 constant/util/components/slices 打包成一个共享的 module
- 每个地区根据不同的需求
- 调用不同的 reducer,生成只包含所需数据的 store
- 实现对应的页面
最后形成一个像 venn diagram 的结构:
而不是将所有的代码打包到一起 ship 出去