Gitee 企业版前端技术优化 - Part 1
Gitee 企业版前端项目在开发初期由于如期交付压力较大,各方面设计考虑得并不全面,因此遗留了一批技术债务。随着项目的迭代更新,这些技术债务对新功能开发的负面影响逐渐明显,有时还不得不基于糟糕的设计方案继续做糟糕的改动,致使技术债务越积越多。
为了改善项目的开发效率和体验,我们在之前一段时间里针对部分技术债务制定了优化方案,涉及构建工具、NPE 错误处理、API 库维护以及模块拆分解耦等方面。
优化构建性能
构建性能是影响开发效率的常见原因之一,在优化前,webpack-dev-server 不仅构建速度慢,CPU 和内存占用高,而且热更新机制只会整页刷新,这导致我们在进行开发的时候需要花费很多的时间在等待 webpack-dev-server 启动和热更新上。
专门花时间优化 webpack 的成本较高,所以换个构建工具是最好的选择。首先,鉴于项目内的文件非常多,构建时间大部分都花费在模块的加载和转译上,我们可以排除掉和 webpack 性能相差不大的工具。其次,鉴于我们在开发环境中调试用的浏览器都很新,无需处理 JavaScript 新标准的兼容问题,那么我们可以选择打包工序更简单的构建工具。
经调研后我们选择了 Vite 作为开发环境下的构建工具,原有的 webpack 仍然保留。
减少 NPE 错误
NPE(Null Pointer Exception)就是我们常常看到的 Cannot read properties of undefined
、xxx is not a function
、Cannot set properties of null
这类 TypeError,频繁出现这类错误会对产品质量和用户体验造成不好的影响,因此我们有必要减少这类错误。
出现 NPE 的主要原因有:
- 后端返回的数据格式与约定的不一致
- API 的函数没有写 return 关键字返回 Promise 对象
1
2
3function fetchData(params) {
request(params);
} - 调用 API 的函数时没有写 await 关键字获取返回值
1
2
3
4const st = fetchData();
if (!st.isFail) {
st.data.data ......
} - 没有处理请求失败的情况就直接使用数据
1
2
3
4
5
6
7
8let list = data.list;
if (!list) {
const st = await fetchData();
if (st.isSuccess) {
list = st.data.data;
}
}
data.map(); // TypeError: Cannot read properties of undefined (reading 'map') - 组件内部数据的结构和流向不够明确,数据容易被改成错误的格式
这些原因本质上是源于数据类型不明确,采用 TypeScript 可以解决,对于老代码,我们可以使用 JSDoc + @ts-check 注释。
优化 API 库
现有的 API 客户端库维护成本很高,每次新增接口都需要人工编写代码,比较费时间,因此我们需要一个能自动生成 API 客户端库且 TypeScript 友好的工具来解决这个问题。
经过简单的调研后,我们决定采用 pont。pont 在初始配置下生成的 API 代码命名太长,不易于使用,为此我们还做了一些定制,包括:简化命名、补全数据类型、转换后端数据类型名等。
简化模块间的依赖关系
在企业版前端项目中有个 app.js 模块,它集成了全局状态管理、API、数据采集、自动更新、通知、主题管理等职责,是被依赖得最多的模块,大部分模块和组件都会引入它,其中的部分模块还有循环依赖的问题。像这样一种内部设计复杂、依赖关系混乱且职责不单一的模块,显然是需要尽早处理掉的,而处理方案就是将各种职责的代码分离出去成为较独立的模块。
解耦 API 模块
app.ent 提供绑定了当前企业 id 参数的 API 请求方法,它依赖 app.localData.enterpriseId
,存在的意义就是简化传参代码。由此可看出只要解除对 app.localData.enterpriseId
依赖就能将 API 分离出来,大致的解耦方法是:移除 app.ent,改用新的 api 模块,原有的 app.localData.enterpriseId
依赖项改成由 App 对象初在始化时调用 api 模块的方法传入。
解耦后,依赖关系就由之前的:
1 | 模块 -> app.js -> app.ent -> new API(app.localData.enterpriseId) |
变成了:
1 | app.js |
解耦全局状态管理模块
app.localData 提供全局状态管理,对其进行解耦的话有三种候选方案:
- mobx + 全局变量
- mobx + Context
- useState + Context
参考资料:https://mobx.js.org/react-integration.html#using-external-state-in-observer-components
mobx + 全局变量
将 app 中的状态拆分到独立的 js 文件中,每个 js 文件创建一个全局状态实例,其它组件按需 import 使用。
优点:
- 修改成本小,现有的基于 mobx 状态管理的组件可以继续使用。
- 能够从 import 的模块路径看出组件依赖哪些全局状态,并快速跳转到状态定义。
缺点: 所有组件共用同一个全局状态实例,在进行单元测试时如果想创建多个全局状态实例来测试组件的话,可能会变得复杂。
mobx + Context
将 app 中的状态拆分到独立的 js 文件中,每个文件提供状态的定义和 Context 对象,由入口 js 文件实例化这些全局状态并通过 Context 共享。
优点:
- 修改成本小,现有的基于 mobx 状态管理的组件可以继续使用。
- 能够从 import 的模块路径看出组件依赖哪些全局状态,并快速跳转到状态定义。
- 便于单元测试,可按需创建多个状态实例对组件进行测试。
缺点: 在 React devtools 中会看到嵌套了很多层的 Context.Provider,看着可能会比较难受。
useState + Context
mobx 适用于状态较多但组件只用到其中部分状态的场景,它可以有效减少因状态变化而重新渲染的组件数量。对于企业版前端现而言,全局状态中的企业、用户、主题、颜色等数据的变更频率很低,即便它们发生了变化,所产生影响也可以忽略不计,而且 mobx 要求所有用到 observable 状态的组件都用 observer()
包裹,也就是将组件与 mobx 强绑定在一起,这对组件的侵入性很强,因此,我们可以选择简化成用 Context 实现全局状态管理。
将全局状态拆分成多个状态分配给多个 Context 实现共享,例如:
1 | function AppStoreProvider({ children }) { |
优点: 少了 mobx 依赖,不用再调用 observer()
、makeObservable()
了。
缺点:
- 修改成本大,需要对现有的基于 mobx 状态管理的组件进行修改。
- 在 React devtools 中会看到嵌套了很多层的 Context.Provider,看着可能会比较难受。
通过对比上述三种方案的优缺点,我们决定采用 mobx + Context 方式管理状态,在 src/stores 目录中存放所有状态源码,由入口 js 文件实例化它们并通过 Context 共享。
Store 示例写法:
1 | // src/stores/EnterpriseStore.ts |
解耦 UI 状态
app.ui 提供主题、颜色表、提示框、弹窗等 UI 相关的状态管理,解耦方案是拆分 toast、popStacks、themeContext 相关代码。考虑到 app.ui.showPop()、app.ui.confirm()、app.ui.toastFail() 等函数已在大量文件中使用,修改成本很高,故应该让拆分出来的 js 模块继续提供相同功能的函数。
解耦主题管理模块
将 app.ui.useTheme()
、app.ui.themeContext
、app.ui.colorCodes
等的相关代码拆分到 src/theme.js
中,提供 ThemeProvider
和 useTheme()
,示例:
1 | // components/ThemeProvider/index.js |
1 | // 入口文件 |
1 | // 示例组件 |
解耦弹窗组件和管理模块
将 app.ui 里的 showPop()
、confirm()
等 popLayer 相关方法都拆分到 src/components/PopLayer/index.js 中,在内部创建全局的 observable 状态,提供 PopLayerProvider,由它负责根据全局状态渲染弹框。
示例用法:
1 | import PopLayer from '@web/components/PopLayer'; |
1 | // 根组件 |
改进对话框组件
现有的 Dialog 组件是用 app.ui.showPop()
来控制显示的,示例代码如下:
1 | function Example() { |
这种用法会导致显示后的弹框与 Example 组件状态失去同步,因为 MyDialog 并没有挂载在 Example 组件内部,不受 Example 组件渲染过程影响,state 变化后 MyDialog 组件函数不会再次调用,导致 MyDialog 使用的 state 和 onSubmit 一直是调用 showPop() 时的值。
在不改变现有写法的情况下,这个问题的解决方法有两个:让 MyDialog 的回调函数不读当前状态、用 useRef()
保存最新状态供回调函数使用。但按照 React 的编程风格,最合适的解决方法是改成声明式写法:
1 | return <MyDialog visible={visible} state={state} setState={setState} onSubmit={submit} />; |
state 变化后 MyDialog 重新渲染,onSubmit 被更新为最新的 submit。
改进通知组件
将 app 里的 toast 相关方法都拆分到 src/components/Toast/index.js 中,内部创建全局的 observable 状态,提供 Toast 和 ToastProvider,ToastProvider 负责根据全局状态渲染 Toast 组件。
示例用法:
1 | import Toast from '@web/components/Toast'; |
1 | // 根组件 |
参考资料:https://ant.design/components/notification-cn/
解耦数据采集模块
app.sensor 并不依赖 app 其它资源,所以可以直接删掉 app.sensor,其它用到 sensor 对象的模块按需引入 sensor。原代码是引入预先下载的 js sdk 模块,现改成从 node_modules 引入 js sdk 包,方便管理版本,减少代码库中的第三方代码。
解耦自动更新器模块
app.autoUpdater 只用于向导航菜单提供 hasNewVersion 状态,解耦方法是改成 Hook + Context.Provider 方式提供 hasNewVersion 状态,示例如下:
1 | // components/AutoUpdaterProvider/index.tsx |
1 | // 入口文件 |
1 | // 示例组件 |
总结
这一期的技术优化方案包括以下优化项:
- 使用 vite 提升开发服务器的构建速度和热更新体验
- 使用 TypeScript 编写新代码,减少 NPM 错误
- 使用 pont 生成 API 客户端库,减少人工维护成本,提升 API 使用体验
- 拆分 app.js 模块,降低其复杂度以便于单元测试
其中,vite 已经投入在开发环境中使用,pont 工具相关的配置、模板和新生成的 API 库已经提交到代码库中,新代码将会使用新 API 库,而旧代码仅在修改到它时顺便迁移到新 API。对于 app.js 模块的拆分解耦,目前已经实施的只有数据采集、自动更新器和主题管理这几个模块的拆分方案,剩下的方案将会在以后实施。