背景与主要场景
本人目前在某短视频 广告平台实习,在此期间有幸参加了内部xx系统B端的某模块重构,特此总结记录下,从中学习到的知识与自己的理解,可能有部分理解不到位,或者未得其精髓,欢迎各位大佬批评指出。
因为是内部平台。所以在借助 Antd design Pro上找了一些相似场景的管理系统图片。方便大家理解。主要场景如下图所示(本文示例基于 antd4.x 完成)。
第一个是列表页。主要提供数据的展示,筛选,查询
第二是一个表单的场景。但是我们的场景要相对复杂很多。基本上可以说是我们系统中最主要的场景
老系统中痛点
针对我们之前的分层 可以简单分为两部分
// index.tsx
import xx from 'xxxx'
interface Iprops{
}
// 常量定义
const xx = []
export default function(props:Iprops){
const showTarget = ()=>{
const target = (
<div>
展示内容
</div>
)
if( xxx == xxx.xx){
if(xxx === xx){
return target
}
}
}
return (
<div>
<div>
展示内容
</div>
{
xxxx.xx && (
<div>
判断内容展示
</div>
)
}
{
showTarget()
}
</div>
)
}
// model.ts 这部分主要定义数据 于api 的交互 不做介绍
因为我们场景会存在各式各样的业务逻辑判断。这样的分层。会导致一个问题。我们的index.tsx
文件会变的非常庞大(即使把一些大组件提出去 做Common Compoents
,但依旧抵不住上千的代码)。逻辑错综复杂,且不利于维护。
重构后设计
在旧系统中。我们可以从上述的简单描述中了解到,我们目前主要的痛点是
- 动辄上千行的代码。对开发造成及其不友好的体验
- 视图与逻辑紧密结合。代码臃肿于不利于维护
- 数据各自定义问题(没有描述)
基于上面的问题。 我们重新设计了我们的分层结构。如下图所示
具体实现,我们可以针对具体的场景来进行定义
如何定义结构?
List场景
//目录结构
// xxxList
// -List
//. -SearchForm
//. -application.ts
//. -index.tsx
//xxxModel.ts
按照上图的第一个场景来看。就是一个简单的 搜索+ 列表。我们把它分为数据 与 UI两部分。
为什么把 List
的展示也看作UI状态
呢?在我们的理解中。List
只用于展示数据,至于一些操作也是建立在本条数据之上,上面的searchForm
并不需要关心这个状态。在代码中的表达为
UI
// List/index.tsx
import xx from 'xxx'
export default function(){
const { state, bizAction } = useContext(xxxListModel);
const tableCol = [] // 定义列
const onChange = (currentPage: number, pageSize?: number)=>{
bizAction.querySubmit({
pageInfo: {
pageSize,
currentPage,
},
});
}
return (
<div>
{
权限控制。&& Button 展示
}
<Table
dataSource={state.uiState.list}
loading={state.uiState.listLoading}
columns={tableCol}
pagination={{
current: state.uiState.pageInfo.currentPage,
pageSize: state.uiState.pageInfo.pageSize,
total: state.uiState.pageInfo.totalCount,
onChange
}}
/>
</div>
);
}
// SearchForm/index.tsx
import xx from 'xxx'
export default function(){
const { from, bizAction } = useContext(xxxListModel);
const onSearch = (values: any) => {
bizAction.querySubmit({
condition: {
...values,
},
});
};
// 参考代码
// https://ant.design/components/form-cn/ 高级搜索部分
return (
<Form
onFinish={onSearch}
>
<!-- 吧 formItem 展现。这里不做展示 有需要参考上述地址-->
<Row gutter={24}>{getFields()}</Row>
<Row>
<Col span={24} style={{ textAlign: 'right' }}>
<Button type="primary" htmlType="submit">
查询
</Button>
<Button
style={{ margin: '0 8px' }}
onClick={() => {
form.resetFields();
}}
>
清空
</Button>
</Col>
</Row>
</Form>
);
}
Application
// Applications.ts
export const xxxListModel = () => {
const { xxxEntity: xxxState, xxxAction } = useXxxEntityModel();
const { uiState, dispatchUi } = useListUiState(); // 参考后面独立UI状态
// 其他自定义状态或者逻辑
class bizAction{
static changeState(value: any) {
xxxAction.dispatchXxx({
type: xxxAction.SetXxx,
payload: value,
});
}
// 利用class. 使用装饰器做统一处理。 不用再写tryCatch 简单高效
@Catch(Error, () => {})
static async querySubmit(params: any) {
dispatchUi({ type: ListActionType.LodingStart });
let condition: any = {
pageInfo: params.pageInfo || uiState.pageInfo,
};
const conditionParams = params.condition
if (params.condition) {
condition.pageInfo = {
pageSize: 10,
currentPage: 1,
};
}
// 整合数据
const queryCondition = hanldeYourDate(conditionParams)
// 调用底层能力
const { list, pageInfo } = await xxxAction.queryXXXList(queryCondition);
// 改变UI 状态 展示数据
dispatchUi({
type: ListActionType.LoadingEnd,
payload: {
pageInfo,
list,
},
});
}
}
return {
state: {
uiState,
},
bizAction,
};
}
export const xxxListModel = createContext({});
// xxxModel.ts 定义原子化数据
import xx from 'xxx'
export interface xxxStateType {
// 定义物品属性。搜索页和searchFrom 详情页等都基于这里面属性
xxxName:string;
}
export enum XxxAction {
ResetXxx,
SetXxx,
}
export const useXxxEntityModel = () => {
const xxxReducer = (state: xxxStateType, action: IAction<XxxAction>) => {
// 数据之间的联动 类型转化等
switch (action.type) {
case XxxAction.ResetXxx:
return {
xxxName:'achen'
} as xxxStateType;
case XxxAction.SetXxx:
state.xxxName = action.payload.xxxName ?? state.xxxName;
// ......
break;
default:
}
};
const [xxxEntity, dispatchXxx] = useImmerReducer<
xxxStateType,
IAction<XxxAction>
>(xxxReducer, {} as xxxStateType);
class action {
// 状态改变
static dispatchXxx = dispatchXxx;
@Catch(Error, () => {})
static resetXxx() {
dispatchXxx({
type: XxxAction.ResetXxx,
});
}
@Catch(Error, (err) => {
message.error(err.errorMsg);
})
static async submitXXXX(params: XxxParams) { // XxxParams为后台数据返回定义
// 数据转换、组装、数据校验、接口请求、返回处理
const result = await apis.xxxT({
XxxParams: params,
});
return result;
}
}
return {
xxxEntity,
dispatchXxx: action,
};
}
PageEdit 场景
通过最开始的介绍,已经说明过我们的场景为 多表单提交,联动等。所以基于list
的设计上。我们利用抽象类定义一些属性(类似抽离的Vue?)方便我们开发
export function Control() {
abstract class ControlClass {
// UI 状态
public static uiState?: any;
// form
public static form?: any;
// 计算属性
public static computedState?: Record<string, any>;
// 监听 自定义hooks
public static sideEffect?: Record<string, any>;
// 表单确定
public static onFormConfirm?: Function;
}
return ControlClass;
}
form
针对form
,我们想做的事在application
统一管理form
和表单属性
const { formInstances, formFieldsName, formsName } = getForm({ // 传入 formName和字段名称 返回form实例 名称 属性值
x: ['1', '2', '3', '4'],
xx: ['5', '6'],
xxx: ['7'],
});
export {
form:{
formInstances,
formFieldsName,
formsName
}
}
// 使用的过程中
<Form
form={formInstances.xxxBase}
name={formsName.xxxBase}
>
<FormItem
label="姓名"
name={formFieldsName.name}
rules={[
{ max: 20, message: '你是名字是不是长的有点离谱?' },
]}
>
<Input placeholder="请输入姓名" />
</FormItem>
</Form>
至于 这个getForm
其实就是包了Form.useForm
在导出。
// 用来给对象赋值key
export function strEnum<T extends string>(o: Array<T>): { [K in T]: K } {
return o.reduce((res, key) => {
res[key] = key;
return res;
}, Object.create(null));
}
export function getForm<T extends string, U extends string>(opt: Record<T, U[]>) {
const formsName = strEnum(Object.keys(opt) as T[]);
const formInstances = {} as Record<T, FormInstance>;
let formFieldsName = {} as { [K in U]: K };
Object.keys(opt).forEach((k) => {
[formInstances[k as T]] = useForm();
formFieldsName = {
...formFieldsName,
...strEnum(opt[k as T]),
};
});
return {
formInstances,
formsName,
formFieldsName,// 所有formFields name
};
}
computedState
用来处理一些复杂计算。把这部分抽离出来 减轻index.tsx
的逻辑
//
class a extends Control(){
static get computedState(){
return {
xxxList(){
const xxlist = formInstances.xxx.getFieldValue(formFieldsName.xxxlist)
// ....
let res
// 进行处理比较 获得一个值
return res
}
}
}
}
sideEffect
这个简单理解,就是自定义hooks,都写在一起方便管理。用的时候调用即可
state Or reducer
我们在平时使用的定义状态的时候,通常都会使用useState
来进行状态定义。针对简单场景来看,这是比较灵活。但是就是因为太过于灵活。导致了我们在开发过程中,在任何一个有需要的地方都可以调用setState
。导致了我们逻辑分散在各处。对开发和维护的效率都有一定的降低。
而使用useReducer
,通过dispatch
传递对应的action
。在一处地方定义 监管所有的状态变化。更方便的维护。逻辑也更加清楚
例子就不再提供了,建议自行写一下,感受一下两者差别。
独立UI状态
为什么要把UI状态
独立?我相信,有一部分的开发者的model
的层中,都会有一个Loading
的状态。不同的page 有不同的 loading。既然都有,为什么不独立出来呢?既然打算独立了。为什么不做一个更通用的呢?针对我们的B端的场景,我们做了如下两种
列表的独立UI
这部分感觉还是可以做一些事情。只是在重构时考虑到尽量的简化,就保证了单一性。
enum ListActionType {
LodingStart,
LoadingEnd,
UpdatePage,
}
const useListUiState = <T>()=>{
// 定义 list 基本的数据类型,可根据需要进行添加
const defalutUiState = {
list:T[],
listLoading:false,
pageInfo:{
current:1,
pageSize:10,
}
}
// 定义 reducer。至于为什么是reducer 而不是 useState。上文已经简单介绍了
const uiReducer = (state: typeof defalutUiState, action: ListActionType)=>{
if (!action) return;
switch (action.type) {
case ListActionType.LodingStart: {
state.listLoading = true;
break;
}
case ListActionType.LoadingEnd: {
state.listLoading = false;
state.list = action.payload.list;
state.pageInfo = action.payload.pageInfo;
break;
}
case ListActionType.UpdatePage: {
if (action.payload) {
state.pageInfo = action.payload;
}
break;
}
default:
}
}
// useImmerReducer 其实就是 借助immer 做一个数据持久化
const [uiState, dispatchUi] = useImmerReducer<typeof defalutUiState, IAction<ListActionType>>(
uiReducer,
defalutUiState,
);
return {
uiState,
disPatchUi
}
}
表单的独立UI
相对于list
,表单的场景更多会在page
整体考虑。因为我们有很多的 新建,编辑,更新等场景,所以在一定程度上我们会吧一些无关的状态尽量的整合,达到外部无感知的目的
// 定义
import { useHistory } from 'react-router';
export enum EditorActionType {
LoadingStart,
LoadingEnd,
}
const usePageUiReducer = () => {
let status = '';
const history = useHistory();
if (history.location.pathname.includes('create')) {
status = 'create';
} else if (history.location.pathname.includes('update')) {
status = 'update';
} else if (history.location.pathname.includes('copy')) {
status = 'copy';
}
const [id] = window.location.hash.split('/').slice(-1);
const defaultUiState = {
isUpdated: status === 'update',
isCreated: status === 'create',
isCopied: status === 'copy',
loading: false,
id: Number.isNaN(parseInt(id, 10)) ? undefined : id,
};
const uiReducer = (state: typeof defaultUiState, action: EditorActionType) => {
if (!action) return;
switch (action) {
case EditorActionType.LoadingStart:
state.loading = true;
break;
case EditorActionType.LoadingEnd:
state.loading = false;
break;
default:
}
};
const [uiState, dispatchUi] = useImmerReducer(uiReducer, defaultUiState);
return {
uiState,
dispatchUi,
};
};
这样外部在使用的过程中,只需要取出对应的状态进行判断即可。无需关心状态的获取。
使用场景就不列举了。跟list
的使用差不多,根据各位的需要来增删。
发现的问题
在实际的开发过程中,我们的xxxModel
在某种意义上变成了api
的调用层,目前我们没出现在 前后在字段不一致等状态。也可能我学艺不精,还没理解到这层的作用。
枚举,什么才是最好的形式
前后各一套
即前端有一套自己定义的枚举,后台也有一套自己定义的枚举。如果有一需求需要新增加枚举值,这样前后太都需要增加对应的枚举,即把一个逻辑分散在了两处地方。
好处
- 前后端 可能会根据枚举的定义,整合一些特殊逻辑,新增一些枚举做判断。在接口返回/提交的过程中多一层转化,转化为前后统一规定的枚举。
不太好的地方
上述已经说了,相当于一个逻辑的定义分散在两处。
使用时接口拉取
利于接口文档生成的对应ts
文件,在需要用到的时候去接口拉去枚举定义,
const select = ()=>{
const[enum,setEnum] = useState([])
useEffect(()=>{
const enum = await apis.XxxEnumGet() // 获取到所需的enum
setEnum(enum)
},[])
return (
<Select
placeholder="请选择"
>
{enum.map((item) => (
<Select.Option
title={item.label}
key={item.value}
value={item.value}
>
{item.label}
</Select.Option>
))}
</Select>
);
}
上述时一个简答的 demo,但是也会存在一个问题。如果我们需要利用枚举进行判断的时候(比如在提交的时候做校验等),需要在各处需要判断的地方都要发一次请求。 虽然可以放到全局state
中。但是感觉这个放在全局中,不太合适
生成对应的文件存放
写了一个js
脚本,服务启动时 执行命令。自动去后台拉去对应的接口。再生成对应的文件存放在对应的const
下。这样我们只需要在需要的时候执行此命令即可。另外写了一些EnumToOption
,EnumMap
等,做一些 枚举转换map Options等共用函数,进一步提高效率
// 类似这种 比较简单。就不展示其他了
interface IItem {
value: number;
name: string;
}
const enumToOptions = (enums: IItem[] = []) => {
return enums?.map((val) => ({
label: val.name,
value: val.value,
}));
};
其他
如果还有其他方式欢迎安利~
收获
我不过是一个普通的实习生,以上很多都是学习组内各位大佬的代码 最后获得自己的提炼。对我来讲,这已经是打开了新世界大门,组内大佬还给我推荐《重构,改善既有的代码设计》,《JavaScript设计模式与开发实践》
等书籍,奈何道行尚浅,现在还只是看看,未能实战。
组内还在进行其他项目,有技术,有业务。等到学习完他们的代码,可能会有其他感想或体会,到时候再做记录。
再次感谢组内大佬对我各种xx问题的耐心回答。