React 封装的一些总结
最近刚刚把重构推进一个段落,所以想整理一下这个项目里面用过的一些封装思路
API 的封装
这里主要指的是 axios,因为 axios 支持的比较好,所以一般项目利用的都是 axios 而不是 fetch,主要实现内容就是之前笔记里提过的:axios 的简易封装 和 封装一个 axios url encoding serialize util
我基本上就是沿用这个思路封装了 5 个函数:
-
基础函数
基础函数是 CRUD 调用的,大致实现方法如下:
export const request = async ({ apiMethod, uri, params = {}, data, }: ApiProps): Promise<AxiosResponse<any, any>> => { // 这里也会负责一些其他的操作,主要是对 headers 和 params 进行处理 // 这个对于所有的 CRUD 操作都是一样的,因此集中封装了一个函数进行处理 const headers = {}; // 封装的另一个原因也是在于 paramsSerializer 这一段不是很想重复好几遍 return await axios({ method: apiMethod, url: uri, data, params, headers, paramsSerializer: (params) => { return toQuery(params); }, }); };
主要抽出来的原因就是三个:
- header 的集中处理
- params 的集中处理
- paramsSerializer 的集中处理
-
Create
export const createSingleEntity = async ( state: RootState, uri: string, model: any, params: any = {} ) => { // 这里也是一个对 model 操作的地方 const data = processModel(model); return await request({ apiMethod: ApiCallTypes.POST, uri, data, params, }); };
这里抽出来的主要原因是需要对 model 进行另外的处理,将
{attr1: sth, attr2: str}
这样一个 object 的数据转成后台需要的类型,以我们项目来说,大概是{type: uri, data: model, id: 0}
这样的操作建于所有的 entity 都需要同样的处理,与其放在调用的地方,不如集中到
createSingleEntity
中进行处理 -
Retrieve
这里主要处理是需要对数据进行一个 pagination 的操作,这同样也是所有 entity 共通的,因此放在这里集中操作
-
Update
Update 的处理更加麻烦一些,主要是我们的 Update 用的是 jsonpatch,并且操作可以包含 CUD 的操作,因此需要调用不同的 wrapper 以生成后端需要的数据格式
-
Delete
Delete 也是操作
deleteSingleEntity
,如果是 batch 操作的话,会转到update
去操作至于为什么不直接调用
update
呢……?这个同样涉及到数据的处理操作,与其在每一个需要删除对象的地方,都额外包装一次数据,不如将其统一放到
delete
种操作,这样在处理每一个 endpoint 能够少写几行代码
模型的封装
TS 比较常用的是新建一个个性化对象,不过 Redux 要求的是一个可序列化对象——也就是基础的对象、数组类型,考虑过后这最终还是决定用一个 Type Definition+default object 的操作去做,如:
type ISomeObject = {
// type definition
};
const defaultSomeObject: ISomeObject = {
// initialize value
};
但是,如果没有 redux 这个 rehydrate 的需求,也可以直接创建一个 custom object:
export class SomeObject {
constructor(
public readonly x: number,
protected y: number,
private z: number
) {
// No body necessary
}
}
这样去进行操作,关于 parameter properties 的缩写部分,在 TypeScript 构造函数中解构参数属性 有提,包括找到的另一个操作方法。
另外关于模型,这里也做了一些其他的优化:
-
以前的数据会使用数据扁平化
也就是对象中包含对象的处理,之前遇到这种情况,会将对象中一些需要的数据抠出来进行单独存放,这一点因为 UI 方面也做了同步的升级,可以获取嵌套对象的数据,所以这一步就不需要了
-
移除了复合模型
这里同样也是 UI 方面做的优化,假设说当前页面的数据来源于
A
和B
,现在直接使用useMemo
,在 dependency array 中监听A[]
和B[]
的变化,然后自动完成数据拼接之前的操作则是先调用 API 获得
A[]
和B[]
,随后拼成组合模型C[]
,在页面中直接渲染C[]
。这样一些操作就非常的麻烦,因为需要同时更新A[]
+C[]
/B[]
+C[]
/A[]
+B[]
+C[]
这样的情况。这也说明 state 中出现了重复数据,才会出现既要更新单个模型(A/B),又要更新复合模型©的情况
Redux 的封装
React 项目结构小结 也提了一下 Redux 的基本实现,这里再细化一下 CRUD 相对的操作逻辑:
-
过滤方法
按照上面提到的,如果是用第一种,也就是用 type+default object 的方法,可以使用一个 lodash 的函数:
pick
,语法为_.pick(object, [paths])
,它会返回一个新的过滤好的对象:var object = { a: 1, b: '2', c: 3 }; _.pick(object, ['a', 'c']); // => { 'a': 1, 'c': 3 }
[paths]
可以使用Object.keys(defaultObject)
获得如果使用 custom class 的话,则可以用 spread operator 进行操作:
const newObj = new SomeObject(...data);
-
Create
这里主要是处理一下数据,将一些 UI 中多余的数据进行过滤——比如说应对一些验证可能会出现
isValid
这样的属性,这是后端不需要的。之后将处理过的对象还原成 API 所需要的格式,这点就交给封装好了的createSingleEntity
去做 -
Retrieve
Retrieve 的操作基本上是一样的,在数据类型定义的非常明确的情况下,这一块甚至可以单独的抽成一个函数去调用
-
Update
操作与 Create 的 case 相似,不过这里需要将数据稍微整理一下成
UpdateEntities
需要的数据格式,我主要将数据格式定义成下面这样:type IUpdatedEntities<T> = { updateEntities: { addEntity: T[]; updateEntity: Partial<T>[]; deleteEntity: T[]; }; uri: string; };
delete 主要后端只需要接受 id,但是 UI 的选项是不会做到只返回选择的 id,而是返回选择的对象。在考虑了一下之后,建于所有的对象都需要从
T[]
转成string[]
的操作,因此将其放到了UpdatedEntities
,而非每一次的删除操作中 -
Delete
操作基本一致,如果只选中一个对象,那么直接调用
delete/entityName/id
如果选中多个数据,则就接收到的
T[]
传到对应的deleteEntities
中
UI 的封装
UI 这一块基本上跟业务有关,这里只是简单地提供一下思路和与我们项目相关的一些实现。
这里还是以我们的项目为例,每一个页面都有对应的 CRUD 的操作,R 已经实现完毕了,对于 CUD 的操作主要通过 表格(table) 和 表单(form) 去实现。表格的东西其实主要通过 UI 库实现的,主要就是写一些 custom validator、custom filter 之类的,onChangeHandler 也是表单内部实现的,我们主要就是拿到修改过的数据,因此这里没什么好说的
表单则是重复比较多的重灾区,分析一下原因主要有这么几个问题:
- 每个页面都需要 form
- 每个页面都在重复 render form(modal 方式现实)
- form 的组成构造是一样的,所以方法的调用也是一致的,但是在具体实现的时候,重复会比较多
class based component 的案例如下:
// class based component
export class ExampleClass extends Component {
constructor(props) {
this.state = {
modalManager: {
isModalOpen: false,
modalID: '',
modalContent: emptyDiv,
headerText: emptyDiv,
toggleFn: emptyFunc,
submitFn: emptyFunc,
isDeleteModal: false,
},
};
}
// setModal & closeModal 是 class based component 重复的重灾区
setModal = ({
headerText,
modalContent,
toggleFn,
submitFn,
modalID = '',
isDeleteModal = false,
}) => {
this.setState({
modalManager: {
isModalOpen: true,
modalID,
modalContent,
headerText,
toggleFn,
submitFn,
isDeleteModal,
},
});
};
closeModal = () => {
this.setState({
modalManager: {
isModalOpen: false,
modalID: '',
modalContent: emptyDiv,
headerText: emptyDiv,
toggleFn: emptyFunc,
submitFn: emptyFunc,
isDeleteModal: false,
},
});
};
// CUD 这里就需要调用 setModal 3 次
render() {
return (
<>
{/* 省略其他的 render */}
<ModalWrapper
modalID={modalID}
size={'md'}
isOpen={isModalOpen}
headerText={headerText}
toggleFunc={toggleFn}
submitFunc={submitFn}
isDeleteModal={isDeleteModal}
>
{modalContent}
</ModalWrapper>
</>
);
}
}
functional component 的案例如下:
// 省略 import
export const Example = () => {
const {
modalManager: {
modalID,
isModalOpen,
modalContent,
headerText,
toggleFn,
submitFn,
isDeleteModal,
},
setModal,
closeModal,
} = useModalManager();
const setAddModal = () => {
setModal({
headerText,
modalID: 'add',
toggleFn: closeModal,
modalContent: <>some component here</>,
submitFn: addFunc,
isDeleteModal: false,
modalSize: 'md',
});
};
const setEditModal = () => {
setModal({
headerText,
modalID: 'edit',
toggleFn: closeModal,
modalContent: <>some component here</>,
submitFn: editFunc,
isDeleteModal: false,
modalSize: 'md',
});
};
const setDeleteModal = () => {
setModal({
headerText,
modalID: 'delete',
toggleFn: closeModal,
modalContent: <>some component here</>,
submitFn: deleteFunc,
isDeleteModal: true,
modalSize: 'md',
});
};
return (
<>
{/* 省略其他的 render */}
<ModalWrapper
modalID={modalID}
size={modalSize}
isOpen={isModalOpen}
headerText={headerText}
toggleFunc={toggleFn}
submitFunc={submitFn}
isDeleteModal={isDeleteModal}
>
{modalContent}
</ModalWrapper>
</>
);
};
functional component 虽然好了一些,但是重复次调用还是比较厉害的
这个实现有另外一个问题,是刚开始使用 hooks 没发现,但是挪到 redux 的时候就 gg 的问题——将 react component 存储到了 state 中去。之前使用 RTK 的时候就直接报错,说 mutate #object
之类的,后面一排查发现是 modalContent
的问题
最后在了解了一下整个项目后,做出了这样的修改:
-
创建一个新的 redux modal slice
-
重新定义了一个新的 hooks 去管理 CUD 的操作
type IUseSetModal<T> = { currentEntityName: string; readOnly?: boolean; addStructure?: Record<string, Sth>; addSubmitFn?: (args: T) => Promise<any>; editStructure?: Record<string, Sth>; editSubmitFn?: (args: T) => Promise<any>; selectedEntities?: Record<string, Partial<T>>; deleteSubmitFn?: (args: Record<string, Partial<T>>) => Promise<any>; }; const useSetModal = <T,>({}: // 所有上面定义的属性,并且为可选属性提供默认值 IUseSetModal<T>) => { const dispatch = useAppDispatch(); const handleAdd = () => { dispatch( setModal({ readOnly, submitFn: addSubmitFn, structure: addStructure, currentEntityName, modalOp: 'add', }) ); }; const handleDelete = () => { dispatch( setModal({ readOnly, submitFn: deleteSubmitFn, currentEntityName, modalOp: 'delete', selectedEntities, deselectAll, }) ); }; const handleEdit = () => { dispatch( setModal({ readOnly, submitFn: editSubmitFn, structure: editStructure, currentEntityName, modalOp: 'edit', selectedEntities: Object.values(selectedEntities!)[0], }) ); }; return { handleAdd, handleDelete, handleEdit } as const; }; export default useSetModal;
-
将 modal wrapper 的东西放到 App.js 下面去进行全局渲染
大致代码如下:
const GlobalModalWrapper = () => { const { isOpen, submitFn, modalOp, deselectAll, currentEntityName, selectedEntities, readOnly, customValidator, structure, isLoading, } = useAppSelector<IModalSlice<any>>((state: RootState) => state.modal); const dispatch = useAppDispatch(); const [entity, setEntity] = useState<any | Record<string, any>>({}); const onChangeHandler = () => { // do sth }; useEffect(() => { if (modalOp === 'add' || modalOp === 'edit') { // do sth return; } if (modalOp === 'delete') { // do sth } }, [selectedEntities, modalOp]); let headerText = '', content = null; const entityName = startCase(currentEntityName), op = startCase(modalOp); switch (modalOp) { case 'add': case 'edit': headerText = `${op} a ${entityName}`; content = sth; break; case 'delete': headerText = `${op} ${entityName}(s)`; content = sth; } return ( <ModalWrapper headerText={headerText} modalId={`${snakeCase(currentEntityName)}-${modalOp}`} readOnly={readOnly} isLoading={isLoading} isOpen={isOpen} isDelete={modalOp === 'delete'} closeModal={() => { dispatch(closeModal()); }} submitFn={async () => submitFn!(entity)} deselectAll={deselectAll} > {content} </ModalWrapper> ); }; export default GlobalModalWrapper;
所以最终在组件里面的调用就是这样的:
export const Example = () => {
const { handleAdd, handleEdit, handleDelete } = useSetModal<Sth>({
currentEntityName: '',
readOnly: some_bool,
addStructure: {},
addSubmitFn: add_function,
editStructure: {},
editSubmitFn: edit_func,
selectedEntities,
deleteSubmitFn: edit_func,
customValidator: some_func,
});
};
这样每个页面上的 modal 操作从 30+行 省略到了 10+ 行,需要写的瘦身差不多一半,class based component 的话省的要稍微多一些,不过主要也是因为重复声明两个函数造成的
总结
总体上来说这次重构的效果还是挺好的,完成了:
-
测试代码的添加
虽然是从 redux 开始,不过我们终于开始写测试了……
-
代码更加的模块化
-
使用 redux 集中化数据
这一部分其实也减少了一些重复调用的代码,毕竟数据和页面也存在 1-to-many 的情况
-
单个页面开发速度提升 50%
之前简单的页面实现周期大概是一周左右(5-6 天),现在因为代码已经封装好了,所以实现周期提速到了 2-3 天
-
现有的代码量大概优化了 50%+
每个页面渲染本身优化了大概 40%左右
数据处理是个重灾区,之前每个 entity 都是独立处理的,现在可以通过通过调用封装后函数进行处理。平均上来说大概优化了 60-70%
总体平均一下,代码量对半砍是肯定有的